stoptime/stoptime.rb

3218 lines
110 KiB
Ruby
Raw Normal View History

#!usr/bin/env camping
2012-06-06 11:28:59 +02:00
# encoding: UTF-8
#
# stoptime.rb - The Stop… Camping Time! time registration and invoicing
# application
#
# Stop… Camping Time! is Copyright © 2011 Paul van Tilburg <paul@luon.net>
#
# This program is free software; you can redistribute it and/or modify it
# under the terms of the GNU General Public License as published by the
# Free Software Foundation; either version 2 of the License, or (at your
# option) any later version.
require "action_view"
require "active_support"
require "camping"
2012-01-30 12:57:15 +01:00
require "camping/mab"
2012-01-25 15:52:46 +01:00
require "camping/ar"
require "pathname"
require "sass/plugin/rack"
Camping.goes :StopTime
2011-11-03 11:39:58 +01:00
unless defined? PUBLIC_DIR
# The directory with public data.
2011-11-03 11:39:58 +01:00
PUBLIC_DIR = Pathname.new(__FILE__).dirname.expand_path + "public"
# The directory with template data.
2011-11-03 11:39:58 +01:00
TEMPLATE_DIR = Pathname.new(__FILE__).dirname.expand_path + "templates"
# Set up the locales.
I18n.load_path += Dir[ File.join('locale', '*.yml') ]
# Set up SASS.
Sass::Plugin.options[:template_location] = "templates/sass"
2012-06-06 11:28:59 +02:00
# Set the default encodings.
if RUBY_VERSION =~ /^1\.9/
Encoding.default_external = Encoding::UTF_8
Encoding.default_internal = Encoding::UTF_8
end
# Set the default date(/time) format.
Time::DATE_FORMATS.merge!(
default: "%Y-%m-%d %H:%M",
month_and_year: "%B %Y",
date_only: "%Y-%m-%d",
time_only: "%H:%M",
day_code: "%Y%m%d")
Date::DATE_FORMATS.merge!(
default: "%Y-%m-%d",
month_and_year: "%B %Y")
end
# = The main application module
module StopTime
# The version of the application
2015-06-20 21:53:43 +02:00
VERSION = '1.16.0'
puts "Starting Stop… Camping Time! version #{VERSION}"
# @return [Hash{String=>Object}] The parsed configuration.
attr_reader :config
# Overrides controller call handler so that the configuration is available
# for all controllers and views.
def service(*a)
@config = StopTime::Models::Config.instance
@format = @request.path_info[/.([^.]+)/, 1];
@headers["Content-Type"] = "text/html; charset=utf-8"
super(*a)
end
# Trap the HUP signal and reload the configuration.
Signal.trap("HUP") do
$stderr.puts "I: caught signal HUP, reloading config"
Models::Config.instance.reload
end
# Add support for PUT and DELETE.
use Rack::MethodOverride
# Enable SASS CSS generation from templates/sass.
2011-11-09 23:00:13 +01:00
use Sass::Plugin::Rack
# Create/migrate the database when needed.
# @return [void]
def self.create
StopTime::Models.create_schema
end
end # module StopTime
# = The Stop… Camping Time! Markaby extensions
module StopTime::Mab
# List of support methods supported by the server.
SUPPORTED = [:get, :post]
# When a tag is completed (i.e. closed), perform some post-processing
# on the tag's attributes.
#
# * Fix up URLs in attributes by applying +Camping::Helpers#/+ to them.
# * Change action methods besides the supported methods (see {SUPPORTED})
# to +:post+ and add a hidden +_method+ input that still contains
# the original method.
# * Replace underscores in the class attribute by dashes (needed for
# Bootstrap).
#
# @param [Mab::Mixin::Tag] tag the tag that is completed
# @return [Mab::Mixin::Tag] the post-processed tag
def mab_done(tag)
attrs = tag._attributes
# Fix up URLs (normally done by Camping::Mab::mab_done)
[:href, :action, :src].map { |a| attrs[a] &&= self/attrs[a] }
# Transform underscores into dashes in all attributes
attrs.select { |attr_sym, _| attr_sym.to_s =~ /_/ } \
.each do |attr_sym, _|
new_attr_sym = attr_sym.to_s.gsub('_', '-').to_sym
attrs[new_attr_sym] = attrs.delete(attr_sym)
end
# Transform underscores into dashs in class names
if attrs.has_key?(:class) and attrs[:class].present?
attrs[:class] = attrs[:class].gsub('_', '-')
end
# The followin method processing is only for form tags.
return super unless tag._name == :form
meth = attrs[:method]
attrs[:method] = 'post' if override = !SUPPORTED.include?(meth)
# Inject a hidden input element with the proper method to the tag block
# if the form method is unsupported.
tag._block do |orig_blk|
input type: "hidden", name: "_method", value: meth
orig_blk.call
end if override
return super
end
include Mab::Indentation
end # module StopTime::Mab
# = The Stop… Camping Time! models
module StopTime::Models
# == The configuration class
#
# This class contains the application configuration constructed by
# default options (see {DefaultConfig}) merged with the configuration
# found in the configuration file (see {ConfigFile}).
class Config
# There should only be a single configuration object (for reloading).
include Singleton
# The default configuation file. (FIXME: shouldn't be hardcoded!)
ConfigFile = File.dirname(__FILE__) + "/config.yaml"
# The default configuration. Note that the configuration of the root
# will be merged with this configuration.
DefaultConfig = { "invoice_id" => "%Y%N",
"invoice_template" => "invoice",
"hourly_rate" => 20.0,
"time_resolution" => 1,
"vat_rate" => 21.0 }
# Creates a new configuration object and loads the configuation.
# by reading the file +config.yaml+ on disk (see {ConfigFile}, parsing
# it, and performing a merge with the default config (see
# {DefaultConfig}).
def initialize
@config = DefaultConfig.dup
cfg = nil
# Read and parse the configuration.
begin
File.open(ConfigFile, "r") { |file| cfg = YAML.load(file) }
rescue => e
$stderr.puts "E: couldn't read configuration file: #{e}"
end
# Merge the loaded config with the default config (if it's a Hash)
case cfg
when Hash
@config.merge! cfg if cfg
when nil, false
# It's ok, it is empty.
else
$stderr.puts "W: wrong format detected in configuration file!"
end
end
# Reloads the configuration file.
def reload
load
end
# Returns the value for the given configuration option.
#
# @param [String] option a configuration option
# @return [Object] the value for the given configuration option
def [](option)
@config[option]
end
end # class StopTime::Models::Config
# == The customer class
#
2011-11-28 12:46:48 +01:00
# This class represents a customer that has projects/tasks
# for which invoices need to be generated.
class Customer < Base
# @!attribute id [r]
# @return [Fixnum] unique identification number
# @!attribute name
# @return [String] official (long) name
# @!attribute short_name
# @return [String] abbreviated name
# @!attribute financial_contact
# @return [String] name of the financial contact person/department
# @!attribute address_street
# @return [String] street part of the address
# @!attribute address_postal_code
# @return [String] zip/postal code part of the address
# @!attribute address_city
# @return [String] city part of the postal code
# @!attribute email
# @return [String] email address
# @!attribute phone
# @return [String] phone number
# @!attribute hourly_rate
# @return [Float] default hourly rate
# @!attribute time_specification
# @return [Boolean] flag whether the customer requires time
# specifications
# @!attribute created_at
# @return [Time] time of creation
# @!attribute updated_at
# @return [Time] time of last update
# @!attribute invoices
# @return [Array<Invoice>] associated invoices
has_many :tasks
# @!attribute tasks
# @return [Array<Task>] associated tasks
has_many :invoices
# @!attribute time_entries
# @return [Array<TimeEntry>] associated time entries
has_many :time_entries, through: :tasks
2011-12-22 16:32:47 +01:00
# Returns the short name if set, otherwise the full name.
#
# @return [String] the shortest name
2011-12-22 16:32:47 +01:00
def shortest_name
short_name.present? ? short_name : name
end
# Returns a list of tasks that have not been billed via in invoice.
#
# @return [Array<Task>] associated unbilled tasks
def unbilled_tasks
tasks.where("invoice_id IS NULL").order("name ASC")
end
# Returns a list of tasks that are active, i.e. that have not been
# billed and are either fixed cost or have some registered time.
#
# @return [Array<Task>] associated active tasks
def active_tasks
unbilled_tasks.select do |task|
task.fixed_cost? or task.time_entries.present?
end
end
end # class StopTime::Models::Customer
# == The task class
#
# This class represents a task (or project) of a customer on which time can
# be registered.
# There are two types of classes: with an hourly and with a fixed cost.
class Task < Base
# @!attribute id [r]
# @return [Fixnum] unique identification number
# @!attribute name
# @return [String] description
# @!attribute fixed_cost
# @return [Float] fixed cost of the task
# @!attribute hourly_rate
# @return [Float] hourly rate for the task
# @!attribute vat_rate
# @return [Float] VAT rate at time of billing
# @!attribute invoice_comment
# @return [String] extra comment for the invoice
# @!attribute created_at
# @return [Time] time of creation
# @!attribute updated_at
# @return [Time] time of last update
# @!attribute customer
# @return [Customer] associated customer
belongs_to :customer
# @!attribute time_entries
# @return [Array<TimeEntry>] associated registered time entries
has_many :time_entries
# @!attribute invoice
# @return [Invoice, nil] associated invoice if the task is billed,
# +nil+ otherwise
belongs_to :invoice
# Determines whether the task has a fixed cost.
# When +false+ is returned, one can assume the task has an hourly rate.
#
# @return [Boolean] whether the task has a fixed cost
def fixed_cost?
not self.fixed_cost.blank?
end
# Returns the type of the task
#
# @return ["fixed_cose", "hourly_rate"] the type of the task
def type
fixed_cost? ? "fixed_cost" : "hourly_rate"
end
# Returns a list of time entries that should be (and are not yet)
# billed.
#
# @return [Array<TimeEntry>] associated billable time entries
def billable_time_entries
time_entries.where("bill = 't'").order("start ASC")
end
# Returns the bill period of the task by means of an Array containing
# the first and last Time object found for registered time on this
# task.
# If no time is registered, the last time the task has been updated
# is returned.
#
# @return [Array(Time, Time)] the bill period of the task
def bill_period
bte = billable_time_entries
if bte.empty?
# FIXME: better defaults?
[updated_at, updated_at]
else
[bte.first.start, bte.last.end]
end
end
# Returns whether the task is billed, i.e. included in an invoice.
#
# @return [Boolean] whether the task is billed
def billed?
not invoice.nil?
end
# Returns a time and cost summary of the registered time on the task
# by means of Array of four values.
# In case of a fixed cost task, the first value is the total of time
# (in hours), the third value is the fixed cost, and the fourth value
# is the VAT.
# In case of a task with an hourly rate, the first value is
# the total of time (in hours), the second value is the hourly rate,
# the third value is the total amount (time times rate), and the fourth
# value is the VAT.
#
# @return [Array(Float, Float, Float, Float)] the summary of the task
def summary
case type
when "fixed_cost"
total = time_entries.inject(0.0) { |summ, te| summ + te.hours_total }
[total, nil, fixed_cost, fixed_cost * (vat_rate/100.0)]
when "hourly_rate"
time_entries.inject([0.0, hourly_rate, 0.0, 0.0]) do |summ, te|
total_cost = te.hours_total * hourly_rate
summ[0] += te.hours_total
summ[2] += total_cost
summ[3] += total_cost * (vat_rate/100.0)
summ
end
end
end
# Returns an invoice comment if the task is billed and if it is
# set, otherwise the name.
#
# @return [String] the invoice comment or task name (if not billed)
def comment_or_name
if billed? and self.invoice_comment.present?
self.invoice_comment
else
self.name
end
end
end # class StopTime::Models::Task
# == The time entry class
#
# This class represents an amount of time that is registered on a certain
# task.
class TimeEntry < Base
# @!attribute id [r]
# @return [Fixnum] unique identification number
# @!attribute date
# @return [Time] date of the entry
# @!attribute start
# @return [Time] start time of the entry
# @!attribute end
# @return [Time] finish time of the entry
# @!attribute bill
# @return [Boolean] flag whether to bill or not
# @!attribute comment
# @return [String] additional comment
# @!attribute created_at
# @return [Time] time of creation
# @!attribute updated_at
# @return [Time] time of last update
# @!attribute task
# @return [Task] associated task the time is registered for
belongs_to :task
# @!attribute customer
# @return [Customer] associated customer
has_one :customer, through: :task
before_validation :round_start_end
# Returns the total amount of time, the duration, in hours (up to
# 2 decimals only!).
#
# @return [Float] the total amount of registered time
def hours_total
((self.end - self.start) / 1.hour).round(2)
end
def in_current_month?
self.end.month == Time.now.month
end
#########
protected
# Rounds the start and end time to the configured resolution using
# {#round_time).
#
# @return [void]
def round_start_end
self.start = round_time(self.start)
self.end = round_time(self.end)
end
#######
private
# Rounds the time using the configured time resolution.
#
# @param [Time] t the input time
# @return [Time] the rounded time
def round_time(t)
config = Config.instance
res = config["time_resolution"]
down = t - (t.to_i % res.minutes)
up = down + res.minutes
diff_down = t - down
diff_up = up - t
diff_down < diff_up ? down : up
end
end # class StopTime::Models::TimeEntry
# == The invoice class
#
# This class represents an invoice for a customer that contains billed
# tasks and through the tasks registered time.
class Invoice < Base
# @!attribute id [r]
# @return [Fixnum] unique identification number
# @!attribute number
# @return [Fixnum] invoice number
# @!attribute paid
# @return [Boolean] flag whether the invoice has been paid
# @!attribute include_specification
# @return [Boolean] flag whether the invoice should include a time
# specification
# @!attribute created_at
# @return [Time] time of creation
# @!attribute updated_at
# @return [Time] time of last update
# @!attribute company_info
# @return [CompanyInfo] associated company info
belongs_to :company_info
# @!attribute customer
# @return [Customer] associated customer
belongs_to :customer
# @!attribute tasks
# @return [Array<Task>] associated billed tasks
has_many :tasks
# @!attribute time_entries
# @return [Array<TimeEntry>] associated billed time entries
has_many :time_entries, through: :tasks
default_scope lambda { order('number DESC') }
# Returns a time and cost summary for each of the associated tasks.
# See also {Task#summary} for the specification of the array.
#
# @return [Hash{Task=>Array(Float, Float, Float, Float)}] mapping from
# task to summary
def summary
summ = {}
tasks.each { |task| summ[task] = task.summary }
return summ
end
# Returns a total per VAT rate for the summary of the associated tasks.
# See also {#summary}.
#
# @return [Hash{Float=>Float}] mapping from VAT rate to total for that
# rate with respect to the summary
def vat_summary
vatsumm = Hash.new(0.0)
summary.each do |task, summ|
vatsumm[task.vat_rate] += summ[3]
end
return vatsumm
end
# Returns the invoice period based on the associated tasks. This
# period is a tuple of the earliest of the starting times and the
# latest of the ending times of all associated tasks.
# If there are no tasks, the creation time is used.
#
# See also {Task#bill_period}.
#
# @return [Array(Time, Time)] the invoice period
def period
return [created_at, created_at] if tasks.empty?
p = [DateTime.now, DateTime.new(0)]
tasks.each do |task|
tp = task.bill_period
p[0] = tp[0] if tp[0] < p[0]
p[1] = tp[1] if tp[1] > p[1]
end
return p
end
2012-01-09 17:38:06 +01:00
# Returns the total amount (including VAT).
#
# @note VAT will only be applied if the VAT number is given in the
# associated company information!
#
# @return [Float] the total amount (including VAT)
2012-01-09 17:38:06 +01:00
def total_amount
subtotal, vattotal = summary.inject([0.0, 0.0]) do |tot, (task, summ)|
tot[0] += summ[2]
tot[1] += summ[3]
tot
end
2012-01-09 17:38:06 +01:00
if company_info.vatno.blank?
subtotal
else
subtotal + vattotal
2012-01-09 17:38:06 +01:00
end
end
# Returns if the invoice is past due (i.e. it has not been paid within
# the required amount of days).
#
# @return [Boolean] whether payment of the invoice is past due
def past_due?
not paid? and (Time.now - created_at) > 30.days # FIXME: hardcoded!
end
# Returns if the invoice is _way_ past due (i.e. it has not been paid
# within twice the required amount of days).
#
# @return [Boolean] whether payment of the invoice is way past due
def way_past_due?
past_due? and (Time.now - created_at) > 2 * 30.days
end
end # class StopTime::Models::Invoice
# == The company information class
#
# This class contains information about the company or sole
# proprietorship of the user of Stop… Camping Time!
class CompanyInfo < Base
# @!attribute id [r]
# @return [Fixnum] unique identification number
# @!attribute name
# @return [String] official company name
# @!attribute contact_name
# @return [String] optional personal contact name
# @!attribute address_street
# @return [String] street part of the address
# @!attribute address_postal_code
# @return [String] zip/postal code part of the address
# @!attribute address_city
# @return [String] city part of the postal code
# @!attribute country
# @return [String] country of residence
# @!attribute country_code
# @return [String] two letter country code
# @!attribute email
# @return [String] email address
# @!attribute phone
# @return [String] phone number
# @!attribute cell
# @return [String] cellular phone number
# @!attribute website
# @return [String] web address
# @!attribute chamber
# @return [String] optional chamber of commerce ID number
# @!attribute vatno
# @return [String] optional VAT number
# @!attribute bank_name
# @return [String] name of the bank
# @!attribute bank_bic
# @return [String] bank identification code (or: SWIFT code)
# @!attribute accountname
# @return [String] name of the bank account holder
# @!attribute accountno
# @return [String] number of the bank account
# @!attribute accountiban
# @return [String] international bank account number
# @!attribute created_at
# @return [Time] time of creation
# @!attribute updated_at
# @return [Time] time of last update
# @!attribute invoices
# @return [Array<Invoice>] associated invoices
has_many :invoices
# @!attribute original
# @return [CompanyInfo] original (previous) revision
belongs_to :original, class_name: "CompanyInfo"
# Returns the revision number.
# @return [Fixnum] the revision number
def revision
id
end
end # class StopTime::Models::CompanyInfo
# @private
class StopTimeTables < V 1.0
def self.up
create_table Customer.table_name do |t|
2011-11-09 15:14:48 +01:00
t.string :name, :short_name,
:address_street, :address_postal_code, :address_city,
:email, :phone
t.timestamps
end
create_table Task.table_name do |t|
t.integer :customer_id
t.string :name
t.timestamps
end
create_table TimeEntry.table_name do |t|
t.integer :task_id, :invoice_id
t.datetime :start, :end
t.timestamps
end
end
def self.down
drop_table Customer.table_name
drop_table Task.table_name
drop_table TimeEntry.table_name
end
end
# @private
class CommentSupport < V 1.1
2011-11-03 22:23:50 +01:00
def self.up
add_column(TimeEntry.table_name, :comment, :string)
end
def self.down
remove_column(TimeEntry.table_name, :comment)
end
end
# @private
class BilledFlagSupport < V 1.2
def self.up
add_column(TimeEntry.table_name, :bill, :boolean)
end
def self.down
remove_column(TimeEntry.table_name, :bill)
end
end
# @private
class HourlyRateSupport < V 1.3
def self.up
config = Config.instance
add_column(Customer.table_name, :hourly_rate, :float,
null: false,
default: config["hourly_rate"])
end
def self.down
remove_column(Customer.table_name, :hourly_rate)
end
end
# @private
class FixedCostTaskSupport < V 1.4
def self.up
add_column(Task.table_name, :billed, :boolean)
add_column(Task.table_name, :fixed_cost, :float)
add_column(Task.table_name, :hourly_rate, :float)
end
def self.down
remove_column(Task.table_name, :billed)
remove_column(Task.table_name, :fixed_cost)
remove_column(Task.table_name, :hourly_rate, :float)
end
end
# @private
class InvoiceSupport < V 1.5
def self.up
create_table Invoice.table_name do |t|
t.integer :number, :customer_id
t.boolean :payed
t.timestamps
end
end
def self.down
drop_table Invoice.table_name
end
end
# @private
class CompanyInfoSupport < V 1.6
def self.up
create_table CompanyInfo.table_name do |t|
t.string :name, :contact_name,
:address_street, :address_postal_code, :address_city,
:country, :country_code,
:phone, :cell, :email, :website,
:chamber, :vatno, :accountname, :accountno
t.timestamps
end
# Add company info record with defaults.
cinfo = CompanyInfo.create(name: "My Company",
contact_name: "Me",
country: "The Netherlands",
country_code: "NL")
cinfo.save
end
def self.down
drop_table CompanyInfo.table_name
end
end
# @private
class ImprovedInvoiceSupport < V 1.7
def self.up
add_column(Task.table_name, :invoice_id, :integer)
remove_column(Task.table_name, :billed)
remove_column(TimeEntry.table_name, :invoice_id)
end
def self.down
remove_column(Task.table_name, :invoice_id, :integer)
add_column(Task.table_name, :billed, :boolean)
add_column(TimeEntry.table_name, :invoice_id)
end
end
# @private
class TimeEntryDateSupport < V 1.8
def self.up
add_column(TimeEntry.table_name, :date, :datetime)
TimeEntry.all.each do |te|
te.date = te.start.at_beginning_of_day
te.save
end
end
def self.down
remove_column(TimeEntry.table_name, :date)
end
end
# @private
class PaidFlagTypoFix < V 1.9
2011-11-28 12:55:13 +01:00
def self.up
rename_column(Invoice.table_name, :payed, :paid)
2011-11-28 12:55:13 +01:00
end
def self.down
rename_column(Invoice.table_name, :paid, :payed)
2011-11-28 12:55:13 +01:00
end
end
# @private
class InvoiceCommentsSupport < V 1.91
def self.up
add_column(Task.table_name, :invoice_comment, :string)
end
def self.down
remove_column(Task.table_name, :invoice_comment)
end
end
# @private
class FinancialInfoSupport < V 1.92
def self.up
add_column(CompanyInfo.table_name, :bank_name, :string)
add_column(CompanyInfo.table_name, :bank_bic, :string)
add_column(CompanyInfo.table_name, :accountiban, :string)
add_column(Customer.table_name, :financial_contact, :string)
end
def self.down
remove_column(CompanyInfo.table_name, :bank_name)
remove_column(CompanyInfo.table_name, :bank_bic)
remove_column(CompanyInfo.table_name, :accountiban)
remove_column(Customer.table_name, :financial_contact)
end
end
# @private
class CompanyInfoRevisioning < V 1.93
def self.up
add_column(CompanyInfo.table_name, :original_id, :integer)
add_column(Invoice.table_name, :company_info_id, :integer)
ci = CompanyInfo.last
Invoice.all.each do |i|
i.company_info = ci
i.save
end
end
def self.down
remove_column(CompanyInfo.table_name, :original_id)
remove_column(Invoice.table_name, :company_info_id)
end
end
# @private
class VATRatePerTaskSupport < V 1.94
2012-09-28 10:09:42 +02:00
def self.up
add_column(Task.table_name, :vat_rate, :float)
config = Config.instance
Task.all.each do |t|
t.vat_rate = config['vat_rate']
t.save
end
end
def self.down
remove_column(Task.table_name, :vat_rate)
end
end
# @private
class TimeSpecificationSupport < V 1.95
def self.up
add_column(Customer.table_name, :time_specification, :boolean)
add_column(Invoice.table_name, :include_specification, :boolean)
Customer.reset_column_information
Invoice.reset_column_information
end
def self.down
remove_column(Customer.table_name, :time_specification)
remove_column(Invoice.table_name, :include_specification)
end
end
2011-11-03 22:23:50 +01:00
end # StopTime::Models
# = The Stop… Camping Time! controllers
module StopTime::Controllers
# == The index controller
#
# Controller that presents the overview as the index, listing
# the running tasks and projects per customer.
#
# path:: +/+
# view:: {Views#overview}
class Index
# Shows an overview of all unbilled projects/tasks per customer using
# {Views#overview}.
def get
@tasks = {}
@task_count = 0
@active_tasks = {}
@active_tasks_summary = {}
@totals = [0.0, 0,0]
Customer.all.each do |customer|
tasks = customer.unbilled_tasks
@tasks[customer] = tasks
@task_count += tasks.count
active_tasks = customer.active_tasks
@active_tasks[customer] = active_tasks
@active_tasks_summary[customer] =
active_tasks.inject([0.0, 0.0]) do |summ, task|
task_summ = task.summary
summ[0] += task_summ[0]
@totals[0] += task_summ[0]
summ[1] += task_summ[2]
@totals[1] += task_summ[2]
summ
end
end
render :overview
end
end # class StopTime::Controllers::Index
2011-10-31 16:14:54 +01:00
# == The customers controller
#
# Controller for viewing a list of existing customers or creating a new
# one.
#
# path:: +/customers+
# view:: {Views#customers} and {Views#customer_form}
2011-10-31 16:14:54 +01:00
class Customers
# Show the list of customers and displays them using {Views#customers}.
2011-10-31 16:14:54 +01:00
def get
@customers = Customer.order("name ASC")
2011-10-31 16:14:54 +01:00
render :customers
end
# Creates a new customer object ({Models::Customer}) if the input is
# valid and redirects to {CustomersN}.
# If the provided information is invalid, the errors are retrieved
# and shown in the initial form ({Views#customer_form}).
2011-10-31 16:14:54 +01:00
def post
return redirect R(Customers) if @input.cancel
@customer = Customer.create(
name: @input.name,
short_name: @input.short_name,
financial_contact: @input.financial_contact,
address_street: @input.address_street,
address_postal_code: @input.address_postal_code,
address_city: @input.address_city,
email: @input.email,
phone: @input.phone,
hourly_rate: @input.hourly_rate)
2011-10-31 16:14:54 +01:00
@customer.save
if @customer.invalid?
@errors = @customer.errors
@target = [Customer]
@button = "create"
return render :customer_form
2011-10-31 16:14:54 +01:00
end
redirect R(CustomersN, @customer.id)
2011-10-31 16:14:54 +01:00
end
end # class StopTime::Controllers::CustomersN
2011-10-31 16:14:54 +01:00
# == The customer creation controller
#
# Controller for filling in the information to create a new customer.
#
# path:: +/customers/new+
# view:: {Views#customer_form}
class CustomersNew
# Generates the form to create a new customer object ({Models::Customer})
# using {Views#customer_form}.
def get
@customer = Customer.new(hourly_rate: @config['hourly_rate'])
@input = @customer.attributes
@tasks = []
@target = [Customers]
@button = "create"
render :customer_form
end
end
# == The customer controller
#
# Controller for viewing and updating information of a customer.
#
# path:: +/customers/+_customer_id_
# view:: {Views#customer_form}
class CustomersN
# Finds the specific customer for the given customer ID and shows
# a form for updating using {Views#customer_form}.
#
# @param [Fixnum] customer_id ID of the customer
2011-10-31 16:14:54 +01:00
def get(customer_id)
@customer = Customer.find(customer_id)
@input = @customer.attributes
@tasks = @customer.tasks.order("name ASC, invoice_id ASC")
# FIXME: this dirty hack assumes that tasks have unique names,
# becasue there is no reference from billed tasks to its original
# task.
@billed_tasks = {}
cur_active_task = nil
@tasks.each do |task|
if task.billed?
if cur_active_task.nil? or
task.name != cur_active_task.name
# Apparently, this is billed but it does not belong to the
# current active task, so probably it was a fixed-cost task
cur_active_task = task
@billed_tasks[task] = [task]
else
@billed_tasks[cur_active_task] << task
end
else
cur_active_task = task
@billed_tasks[task] = []
end
end
@time_entries = @customer.time_entries.order("start DESC")
@invoices = @customer.invoices
@invoices.each do |i|
@input["paid_#{i.number}"] = true if i.paid?
end
@task_list = Hash.new { |h, k| h[k] = Array.new }
@customer.tasks.reject { |t| t.billed? }.each do |t|
@task_list[t.customer.shortest_name] << [t.id, t.name]
end
@input["bill"] = true # Bill by default.
@input["task"] = @time_entries.first.task.id if @time_entries.present?
@target = [CustomersN, @customer.id]
@button = "update"
@edit_task = true
render :customer_form
2011-10-31 16:14:54 +01:00
end
# Updates or deletes the customer with the given customer ID if the
# input is valid and redirects to {CustomersN}.
# If the provided information is invalid, the errors are retrieved
# and shown in the initial form ({Views#customer_form}).
#
# @param [Fixnum] customer_id ID of the customer
2011-10-31 16:14:54 +01:00
def post(customer_id)
return redirect R(Customers) if @input.cancel
@customer = Customer.find(customer_id)
if @input.has_key? "delete"
@customer.delete
elsif @input.has_key? "update"
attrs = ["name", "short_name", "financial_contact",
"address_street", "address_postal_code", "address_city",
"email", "phone", "hourly_rate"]
attrs.each do |attr|
@customer[attr] = @input[attr]
end
@customer.time_specification = @input.has_key? "time_specification"
@customer.save
if @customer.invalid?
@errors = @customer.errors
2015-06-20 18:31:57 +02:00
@button = "update"
return render :customer_form
end
end
redirect R(Customers)
2011-10-31 16:14:54 +01:00
end
end # class StopTime::Controllers::CustomersN
2011-10-31 16:14:54 +01:00
# == The tasks controller for a specific customer
#
# Controller for creating, editing and deleting a task for a
# specific customer.
#
# path:: +/customers/+_customer_id_+/tasks+
# view:: {Views#task_form}
class CustomersNTasks
# Creates, updates or deletes a task object ({Models::Task}) for a
# customer with the given customer ID if the input is valid and
# redirects to {CustomersN}.
# If the provided information is invalid, the errors are retrieved and
# shown in the initial form ({Views#task_form}).
#
# @param [Fixnum] customer_id ID of the customer
def post(customer_id)
return redirect R(Customers) if @input.cancel
if @input.has_key? "delete"
@task = Task.find(@input.task_id)
@task.delete
elsif @input.has_key? "edit"
return redirect R(CustomersNTasksN, customer_id, @input.task_id)
else
@task = Task.create(
customer_id: customer_id,
name: @input.name)
case @input.type
when "fixed_cost"
@task.fixed_cost = @input.fixed_cost
@task.hourly_rate = nil
when "hourly_rate"
@task.fixed_cost = nil
@task.hourly_rate = @input.hourly_rate
# FIXME: catch invalid task types!
end
@task.vat_rate = @input.vat_rate
@task.save
if @task.invalid?
@errors = @task.errors
@customer = Customer.find(customer_id)
@customer_list = Customer.all.map { |c| [c.id, c.shortest_name] }
@time_entries = @task.time_entries.order("start DESC")
@time_entries.each do |te|
@input["bill_#{te.id}"] = true if te.bill?
end
@target = [CustomersNTasks, customer_id]
@method = "create"
return render :task_form
end
end
redirect R(CustomersN, customer_id)
end
end # class StopTime::Controllers::CustomersNTasks
# == The task creation controller for a specific customer
#
# Controller for filling in the information to create a new task
# for a specific customer.
#
# path:: +/customers/+_customer_id_+/tasks/new+
# view:: {Views#task_form}
class CustomersNTasksNew
# Generates the form to create a new task object ({Models::Task})
# for a customer with the given customer ID_ using {Views#task_form}.
#
# @param [Fixnum] customer_id ID of the customer
def get(customer_id)
@customer = Customer.find(customer_id)
@customer_list = Customer.all.map { |c| [c.id, c.shortest_name] }
@task = Task.new(hourly_rate: @customer.hourly_rate,
vat_rate: @config["vat_rate"])
@input = @task.attributes
@input["type"] = @task.type # FIXME: find nicer way!
@input["customer"] = @customer.id
@target = [CustomersNTasks, customer_id]
@method = "create"
render :task_form
end
end # class StopTime::Controllers::CustomersNTasksNew
# == The task controller for a specific customer
#
# Controller for viewing and updating information of a task for
# a specific customer.
#
# path:: +/customers/+_customer_id_+/tasks/+_task_id_
# view:: {Views#task_form}
class CustomersNTasksN
# Finds the task with the given task ID for the customer with the given
# customer ID and shows a form for updating using {Views#task_form}.
#
# @param [Fixnum] customer_id ID of the customer
# @param [Fixnum] task_id ID of the task
def get(customer_id, task_id)
@customer = Customer.find(customer_id)
@customer_list = Customer.all.map { |c| [c.id, c.shortest_name] }
@task = Task.find(task_id)
@time_entries = @task.time_entries.order("start DESC")
@input = @task.attributes
@input["type"] = @task.type
@input["customer"] = @customer.id
@time_entries.each do |te|
@input["bill_#{te.id}"] = true if te.bill?
end
@input["bill"] = true # Bill new entries by default.
# FIXME: Check that task is of that customer.
@target = [CustomersNTasksN, customer_id, task_id]
@method = "update"
render :task_form
end
# Updates the task with the given task ID_ for the customer with
# the given customer ID if the input is valid and redirects to
# {CustomersN}.
# If the provided information is invalid, the errors are retrieved
# and shown in the intial form ({Views#task_form}).
#
# @param [Fixnum] customer_id ID of the customer
# @param [Fixnum] task_id ID of the task
def post(customer_id, task_id)
return redirect R(CustomersN, customer_id) if @input.cancel
@task = Task.find(task_id)
if @input.has_key? "update"
2014-10-25 22:32:57 +02:00
@task.customer = Customer.find(@input["customer"])
@task.name = @input["name"] unless @input["name"].blank?
if @task.billed? and @input["invoice_comment"].present?
@task.invoice_comment = @input["invoice_comment"]
end
case @input.type
when "fixed_cost"
@task.fixed_cost = @input.fixed_cost
@task.hourly_rate = nil
when "hourly_rate"
@task.fixed_cost = nil
@task.hourly_rate = @input.hourly_rate
end
@task.save
if @task.invalid?
@errors = @task.errors
@customer = Customer.find(customer_id)
@customer_list = Customer.all.map { |c| [c.id, c.shortest_name] }
@target = [CustomersNTasksN, customer_id, task_id]
@method = "update"
return render :task_form
end
end
redirect R(CustomersN, customer_id)
2011-10-31 16:14:54 +01:00
end
end # class StopTime::Controllers::CustomersNTasksN
2011-10-31 16:14:54 +01:00
# == The invoices controller for a specific customer
#
# Controller for creating and viewing invoices for a specific customer.
#
# path:: +/customers/+_customer_id_+/invoices+
# view:: {Views#invoices}
class CustomersNInvoices
# Creates a new invoice object ({Models::Invoice}) for the customer
# with the given customer ID if the input is valid and redirects to
# {CustomersNInvoicesX}.
#
# A unique number is generated for the invoice by taking the
# year and a sequence number.
#
# A fixed cost task is directly tied to the invoice.
#
# For a task with an hourly rate, a task copy is created with the
# selected time to bill and put in the invoice; the remaining unbilled
# time is left in the original task.
#
# @param [Fixnum] customer_id ID of the customer
def post(customer_id)
return redirect R(CustomersN, customer_id) if @input.cancel
# Create the invoice.
last = Invoice.reorder('number ASC').last
number = if last
last_year = last.number.to_s[0..3].to_i
if Time.now.year > last_year
number = ("%d%02d" % [Time.now.year, 1])
else
2011-11-09 15:14:48 +01:00
number = last.number.succ
end
else
number = ("%d%02d" % [Time.now.year, 1])
end
invoice = Invoice.create(number: number)
invoice.customer = Customer.find(customer_id)
invoice.company_info = CompanyInfo.last
invoice.include_specification = invoice.customer.time_specification
# Handle the hourly rated tasks first by looking at the selected time
# entries.
tasks = Hash.new { |h, k| h[k] = Array.new }
@input["time_entries"].each do |entry|
time_entry = TimeEntry.find(entry)
tasks[time_entry.task] << time_entry
end unless @input["time_entries"].blank?
tasks.each_key do |task|
# Create a new (billed) task clone that contains the selected time
# entries, leave the rest unbilled and associated with their task.
bill_task = task.dup # FIXME: depends on rails version!
task.time_entries = task.time_entries - tasks[task]
task.save
bill_task.time_entries = tasks[task]
bill_task.invoice_comment = @input["task_#{task.id}_comment"]
bill_task.save
invoice.tasks << bill_task
end
# Then, handle the (selected) fixed cost tasks.
@input["tasks"].each do |task|
task = Task.find(task)
next unless task.fixed_cost?
task.invoice_comment = @input["task_#{task.id}_comment"]
task.save
invoice.tasks << task
end unless @input["tasks"].blank?
invoice.save
redirect R(CustomersNInvoicesX, customer_id, number)
end
end # class StopTime::Controllers::CustomersNInvoices
# == The invoice controller for a specific customer
#
# Controller for viewing and updating information of an invoice for a
# specific customer.
#
# path:: +/customers/+_customer_id_+/invoices/+_invoice_number_
# view:: {Views#invoice_form}
class CustomersNInvoicesX < R '/customers/(\d+)/invoices/([^/]+)'
include ActionView::Helpers::NumberHelper
include I18n
# Finds the invoice with the given invoice number for the customer
# with the given customer ID and shows a form for updating using
# {Views#invoice_form}.
# If the invoice_number has a .pdf or .tex suffix, a PDF or LaTeX
# source document is generated for the invoice (if not already
# existing) and served via a redirect to the {Static} controller.
#
# @param [Fixnum] customer_id ID of the customer
# @param [Fixnum] invoice_number number of the invoice
def get(customer_id, invoice_number)
# FIXME: make this (much) nicer!
if m = invoice_number.match(/(\d+)\.(\w+)$/)
@number = m[1].to_i
@format = m[2]
else
@number = invoice_number.to_i
@format = "html"
end
@invoice = Invoice.find_by_number(@number)
@customer = Customer.find(customer_id)
@company = @invoice.company_info
@tasks = @invoice.summary
@vat = @invoice.vat_summary
@period = @invoice.period
tex_file = PUBLIC_DIR + "invoices/#{@number}.tex"
pdf_file = PUBLIC_DIR + "invoices/#{@number}.pdf"
if @format == "html"
@input = @invoice.attributes
@invoice_file_present = tex_file.exist?
render :invoice_form
elsif @format == "tex"
_generate_invoice_tex(@number) unless tex_file.exist?
redirect R(Static, "") + "invoices/#{tex_file.basename}"
elsif @format == "pdf"
_generate_invoice_pdf(@number) unless pdf_file.exist?
redirect R(Static, "") + "invoices/#{pdf_file.basename}"
end
end
# Updates the invoice with the given invoice number for the customer
# with the given customer ID and redirects to {CustomersNInvoicesX}.
#
# @param [Fixnum] customer_id ID of the customer
# @param [Fixnum] invoice_number number of the invoice
def post(customer_id, invoice_number)
invoice = Invoice.find_by_number(invoice_number)
2011-11-28 12:55:13 +01:00
invoice.paid = @input.has_key? "paid"
invoice.include_specification = @input.has_key? "include_specification"
invoice.save
redirect R(CustomersNInvoicesX, customer_id, invoice_number)
end
# Find the invoice with the given invoice number for the customer
# with the given customer ID and deletes existing invoice files.
#
# @param [Fixnum] customer_id ID of the customer
# @param [Fixnum] invoice_number number of the invoice
2014-10-31 21:59:17 +01:00
def delete(customer_id, invoice_number)
@invoice = Invoice.find_by_number(@number)
@customer = Customer.find(customer_id)
tex_file = PUBLIC_DIR + "invoices/#{invoice_number}.tex"
File.unlink(tex_file) if tex_file.exist?
pdf_file = PUBLIC_DIR + "invoices/#{invoice_number}.pdf"
File.unlink(pdf_file) if pdf_file.exist?
redirect R(CustomersNInvoicesX, customer_id, invoice_number)
end
########################
2011-12-10 21:05:58 +01:00
# Private helper methods
#
private
# Escapes the given string such that it can be used as in in
# LaTeX.
#
# @param [String] string the given string
def _escape_latex(string)
escape_chars = { '#' => '\#',
'$' => '\$',
'%' => '\%',
'&' => '\&',
'\\' => '\textbackslash{}',
'^' => '\textasciicircum{}',
'_' => '\_',
'{' => '\{',
'}' => '\}',
'~' => '\textasciitilde{}' }
regexp_str = escape_chars.keys.map { |c| Regexp.escape(c) }.join('|')
regexp = Regexp.new(regexp_str)
string.to_s.gsub(regexp, escape_chars)
end
alias_method :l, :_escape_latex
# Generates a LaTex document for the invoice with the given number.
#
# @param [Fixnum] number number of the invoice
def _generate_invoice_tex(number)
template = TEMPLATE_DIR + "#{@config["invoice_template"]}.tex.erb"
tex_file = PUBLIC_DIR + "invoices/#{number}.tex"
2011-11-09 19:44:59 +01:00
I18n.with_locale :nl do
erb = ERB.new(File.read(template))
File.open(tex_file, "w") { |f| f.write(erb.result(binding)) }
end
2012-01-03 16:56:51 +01:00
rescue Exception => err
tex_file.delete if File.exist? tex_file
2012-01-03 16:56:51 +01:00
raise err
end
# Generates a PDF document for the invoice with the given number
# using {#_generate_invoice_tex}.
#
# @param [Fixnum] number number of the invoice
def _generate_invoice_pdf(number)
tex_file = PUBLIC_DIR + "invoices/#{@number}.tex"
_generate_invoice_tex(number) unless tex_file.exist?
# FIXME: remove rubber depend, use pdflatex directly
system("rubber --pdf --inplace #{tex_file}")
system("rubber --clean --inplace #{tex_file}")
end
end # class StopTime::Controllers::CustomerNInvoicesX
# == The invoice creating controller for a specifc customer
#
# Controller for creating a new invoice for a specific customer.
#
# path:: +/customers/+_customer_id_+/invoices/new+
# view:: {Views#invoice_select_form}
class CustomersNInvoicesNew
# Generates the form to create a new invoice object ({Models::Invoice})
# by listing unbilled fixed cost tasks and unbilled registered time
# (for tasks with an hourly rate) of the customer with the given
# customer ID so that it can be individually selected using
# {Views#invoice_select_form}.
#
# @param [Fixnum] customer_id ID of the customer
def get(customer_id)
@customer = Customer.find(customer_id)
@hourly_rate_tasks = {}
@fixed_cost_tasks = {}
@customer.active_tasks.each do |task|
case task.type
when "fixed_cost"
total = task.time_entries.inject(0.0) { |s, te| s + te.hours_total }
@fixed_cost_tasks[task] = total
when "hourly_rate"
time_entries = task.billable_time_entries
@hourly_rate_tasks[task] = time_entries
end
end
@none_found = @hourly_rate_tasks.empty? and @fixed_cost_tasks.empty?
render :invoice_select_form
end
end # class StopTime::Controllers::CustomersNInvoicesNew
# == The timeline controller
#
# Controller for presenting a timeline of registered time and
# also for quickly registering time.
#
# path:: +/timeline+
# view:: {Views#timeline}
class Timeline
# Retrieves all registered time in descending order to present
# the timeline using {Views#timeline}.
2011-10-31 16:14:54 +01:00
def get
if @input["show"] == "all"
@time_entries = TimeEntry.order("start DESC")
else
@time_entries = TimeEntry.joins(:task)\
.where("stoptime_tasks.invoice_id" => nil)\
.order("start DESC")
end
@time_entries.each do |te|
@input["bill_#{te.id}"] = true if te.bill?
end
@customer_list = Customer.all.map { |c| [c.id, c.shortest_name] }
@task_list = Hash.new { |h, k| h[k] = Array.new }
Task.all.reject { |t| t.billed? }.each do |t|
@task_list[t.customer.shortest_name] << [t.id, t.name]
end
2011-11-07 17:44:58 +01:00
@input["bill"] = true # Bill by default.
@input["task"] = @time_entries.first.task.id if @time_entries.present?
render :timeline
2011-10-31 16:14:54 +01:00
end
2014-10-25 22:33:05 +02:00
# Registers a time entry and redirects back to the referer.
# If the provided information was invalid, the errors are retrieved.
def post
if @input.has_key? "enter"
@time_entry = TimeEntry.create(
task_id: @input.task,
date: @input.date,
start: "#{@input.date} #{@input.start}",
end: "#{@input.date} #{@input.end}",
comment: @input.comment,
bill: @input.has_key?("bill"))
# Add a day to the end date if the total hours is negative.
# It means that the end time was before the begin time, i.e.
# overnight.
@time_entry.end += 1.day if @time_entry.hours_total < 0
@time_entry.save
if @time_entry.invalid?
@errors = @time_entry.errors
end
end
redirect @request.referer
end
end # class StopTime::Controllers::Timeline
# == The timeline quick register controller
#
# Controller that presents a view for quickly registering time
# on a task.
#
# path:: +/timeline/new+
# view:: {Views#time_entry_form}
class TimelineNew
# Retrieves a list of customers and tasks and the current date
# and time for prefilling a form ({Views#time_entry_form}) for quickly
# registering time.
def get
@customer_list = Customer.all.map { |c| [c.id, c.shortest_name] }
@task_list = Hash.new { |h, k| h[k] = Array.new }
Task.all.reject { |t| t.billed? }.each do |t|
@task_list[t.customer.shortest_name] << [t.id, t.name]
end
@input["bill"] = true
@input["date"] = DateTime.now.to_date
@input["start"] = Time.now.to_formatted_s(:time_only)
@target = [Timeline]
@button = "enter"
render :time_entry_form
2011-10-31 16:14:54 +01:00
end
end # class StopTime::Controllers::TimelineNew
# == The timeline time entry controller
#
# Controller for viewing and updating information of a time entry.
#
# path:: +/timeline/+_entry_id_
# view:: {Views#time_entry_form}
class TimelineN
# Finds the time entry with the given time entry ID and shows
# a form for updating using {Views#time_entry_form}.
#
# @param [Fixnum] entry_id ID of the time entry
2011-11-07 17:44:58 +01:00
def get(entry_id)
@time_entry = TimeEntry.find(entry_id)
@input = @time_entry.attributes
@input["customer"] = @time_entry.task.customer.id
@input["task"] = @time_entry.task.id
@input["date"] = @time_entry.date.to_date
@input["start"] = @time_entry.start.to_formatted_s(:time_only)
@input["end"] = @time_entry.end.to_formatted_s(:time_only)
@customer_list = Customer.all.map { |c| [c.id, c.shortest_name] }
@task_list = Hash.new { |h, k| h[k] = Array.new }
Task.order("name ASC, invoice_id ASC").each do |t|
name = t.billed? ? t.name + " (#{t.invoice.number})" : t.name
@task_list[t.customer.shortest_name] << [t.id, name]
end
@target = [TimelineN, entry_id]
@button = "update"
2011-11-07 17:44:58 +01:00
render :time_entry_form
end
# Updates or deletes the time entry if the input is valid and redirects
# to {Timeline}.
# If the provided information is invalid, the errors are retrieved
# and shown in the initial form ({Views#time_entry_form}).
#
# @param [Fixnum] entry_id ID of the time entry
def post(entry_id)
return redirect R(Timeline) if @input.cancel
@time_entry = TimeEntry.find(entry_id)
2011-11-07 17:44:58 +01:00
if @input.has_key? "delete"
@time_entry.delete
2011-11-07 17:44:58 +01:00
elsif @input.has_key? "update"
attrs = ["date", "comment"]
2011-11-07 17:44:58 +01:00
attrs.each do |attr|
@time_entry[attr] = @input[attr]
2011-11-07 17:44:58 +01:00
end
@time_entry.start = "#{@input["date"]} #{@input["start"]}"
@time_entry.end = "#{@input["date"]} #{@input["end"]}"
@time_entry.task = Task.find(@input.task)
@time_entry.bill = @input.has_key? "bill"
# Add a day to the end date if the total hours is negative.
@time_entry.end += 1.day if @time_entry.hours_total < 0
@time_entry.save
if @time_entry.invalid?
@errors = @time_entry.errors
2011-11-07 17:44:58 +01:00
return render :time_entry_form
end
end
redirect @request.referer
end
end # class StopTime::Controllers::TimelineN
# == The invoices controller
#
# Controller for viewing a list of all invoices.
#
# path:: +/invoices+
# view:: {Views#invoices}
class Invoices
# Retrieves the list of invoices, sorted per customer, and displays
# them using {Views#invoices}.
def get
@invoices = {}
@invoice_count = 0
Customer.all.each do |customer|
invoices = customer.invoices
next if invoices.empty?
@invoices[customer] = invoices
invoices.each do |i|
@input["paid_#{i.number}"] = true if i.paid?
end
@invoice_count += invoices.count
end
render :invoices
end
end # class StopTime::Controllers::Invoices
# == The invoices per period controller
#
# Controller for viewing a list of all invoices sorted by period.
#
# path:: +/invoices/period+
# view:: {Views#invoices}
class InvoicesPeriod
# Retrieves the list of invoices, sorted per period, and displays
# them using {Views#invoices}.
def get
@invoices = Hash.new { |h, k| h[k] = Array.new }
Invoice.all.each do |invoice|
# FIXME: this is an unformatted key!
@invoices[invoice.period.first.at_beginning_of_month] << invoice
end
render :invoices
end
end
# == The company controller
#
# Controller for viewing and updating information of the company of
# the user (stored in Models::CompanyInfo).
#
# path:: +/company+
# view:: {Views#company_form}
class Company
# Retrieves the company information and shows a form for updating
# using {Views#company_form}.
def get
@company = if @input.revision.present?
CompanyInfo.find(@input.revision)
else
CompanyInfo.last
end
@company_last = @company == CompanyInfo.last
@input = @company.attributes
@history_warn = true if @company != CompanyInfo.last
render :company_form
end
# Updates the company information and shows the updated form
# ({Views#company_form}).
# If the provided information was invalid, the errors are retrieved.
def post
@company = if @input.revision.present?
CompanyInfo.find(@input.revision)
else
CompanyInfo.last
end
# If we are editing the current info and it is already associated
# with some invoices, create a new revision.
@history_warn = true if @company != CompanyInfo.last
if @company == CompanyInfo.last and @company.invoices.length > 0
old_company = @company
@company = old_company.clone # FIXME: depends on rails version!
@company.original = old_company
end
attrs = ["name", "contact_name",
"address_street", "address_postal_code", "address_city",
"country", "country_code",
"phone", "cell", "email", "website",
"chamber", "vatno",
"bank_name", "bank_bic", "accountno", "accountiban"]
attrs.each do |attr|
@company[attr] = @input[attr]
end
@company.save
if @company.invalid?
@errors = @company.errors
end
render :company_form
end
end # class StopTime::Controllers::Company
# == The static data controller
#
# Controller for serving static data information available in the
# +public/+ subdirectory.
#
# path:: +/static/+_path_
# view:: N/A (X-Sendfile)
class Static < R '/static/(.*?)'
# Sets the headers such that the web server will fetch and offer
# the file identified by the path relative to the +public/+ subdirectory.
#
# @param [String] path the relative path to a public data file
def get(path)
unless path.include? ".."
full_path = PUBLIC_DIR + path
@headers['Content-Type'] = Rack::Mime.mime_type(full_path.extname)
@headers['X-Sendfile'] = full_path.to_s
else
@status = "403"
"Error 403: Invalid path: #{path}"
end
end
end # class StopTime::Controllers::Static
end # module StopTime::Controllers
# = The Stop… Camping Time! views
module StopTime::Views
# The main layout used by all views.
#
# @return [Mab::Mixin::Tag] the main layout
def layout
2014-10-18 21:27:46 +02:00
doctype!
html(lang: "en") do
head do
title "Stop… Camping Time!"
meta name: "viewport",
content: "width=device-width, initial-scale=1.0"
# Bootstrap core CSS
link rel: "stylesheet", type: "text/css", media: "screen",
href: (R(Static, "") + "stylesheets/bootstrap.min.css")
# FIXME: improve static serving so that the hack below is not needed.
link rel: "stylesheet", type: "text/css", media: "screen",
href: (R(Static, "") + "stylesheets/style.css")
# Enable responsiveness
meta name: "viewport",
content: "width=device-width, initial-scale=1, " +
"maximum-scale=1, user-scalable=no"
end
body do
2013-06-16 20:27:11 +02:00
_menu
div.container do
self << yield
footer { br }
end
2015-06-06 12:26:16 +02:00
footer.footer do
div.container do
small do
text! "Stop… Camping Time! v#{StopTime::VERSION} — by "
a "Mozcode", href: "https://mozcode.nl"
2015-06-06 12:26:16 +02:00
end
end
end
2013-06-16 20:27:11 +02:00
# JQuery and Bootstrap JavaScript
script src: (R(Static, "") + "javascripts/jquery.min.js")
script src: (R(Static, "") + "javascripts/bootstrap.min.js")
end
end
end
# The main overview showing accumulated time per task per customer.
#
# @return [Mab::Mixin::Tag] the main overview
def overview
header.page_header do
h1 do
text! "Overview"
small "#{@tasks.count} customers, #{@task_count} active projects/tasks"
end
2013-06-16 20:27:11 +02:00
end
if @tasks.empty?
div.alert.alert_info do
text! "No customers, projects or tasks found! Set them up " +
"#{a "here", href: R(CustomersNew)}."
end
else
div.row do
@tasks.keys.sort_by { |c| c.name }.each do |customer|
div.col_md_6 do
inv_klass = "text_info"
if customer.invoices.any? { |inv| inv.past_due? }
inv_klass = "text_warning"
end
if customer.invoices.any? { |inv| inv.way_past_due? }
inv_klass = "text_danger"
end
h3 do
a customer.name, class: inv_klass,
href: R(CustomersN, customer.id)
end
if @tasks[customer].empty?
p do
text! "No projects/tasks found! Create one " +
"#{a "here", href: R(CustomersNTasksNew, customer.id)}."
end
elsif @active_tasks[customer].empty?
p do
em "No active projects/tasks found! " +
"Register time on one of these tasks: "
br
@tasks[customer].each do |task|
a task.name, href: R(CustomersNTasksN, customer.id, task.id)
text! "&centerdot;" unless task == @tasks[customer].last
end
end
else
table.table.table_condensed do
@active_tasks[customer].each do |task|
tr do
summary = task.summary
td.col_md_4.col_xs_6 do
a task.name, href: R(CustomersNTasksN,
customer.id,
task.id)
2013-06-16 20:27:11 +02:00
end
summary = task.summary
td.col_md_1.col_xs_3.text_right { "%.2fh" % summary[0] }
td.col_md_1.col_xs_3.text_right { "€ %.2f" % summary[2] }
2013-06-16 20:27:11 +02:00
end
end
tr do
td.col_md_4.col_xs_6 { strong "Total" }
td.col_md_1.col_xs_3.text_right do
"%.2fh" % @active_tasks_summary[customer][0]
end
td.col_md_1.col_xs_3.text_right do
"€ %.2f" % @active_tasks_summary[customer][1]
end
end
end
end
end
end
end
div.row do
div.col_md_12 do
table.table.table_condensed.grand_total do
tr do
td.col_md_10.col_xs_6 { big { strong "Grand total" } }
td.col_md_1.col_xs_3.text_right do
big { strong("%.2fh" % @totals[0]) }
end
td.col_md_1.col_xs_3.text_right do
big { strong("€ %.2f" % @totals[1]) }
end
end
end
end
end
end
end
# The main overview showing the timeline of registered time.
#
# @return [Mab::Mixin::Tag] the main timeline (time entry list) overview
def timeline
header.page_header do
h1 do
text! "Timeline"
small "#{@time_entries.count} time entries"
div.btn_group.navbar_right do
a.btn.btn_default.btn_sm role: "button", href: R(TimelineNew) do
_icon("time")
span "Register time"
end
a.btn.btn_default.btn_sm.dropdown_toggle role: "button", href: "#",
data_toggle: "dropdown" do
_icon("filter")
text! @input["show"] == "all" ? "All" : "Unbilled"
span.caret
end
ul.dropdown_menu role: "menu", aria_labelledby: "dLabel" do
li { a "All", href: R(Timeline, show: "all") }
li { a "Unbilled", href: R(Timeline, show: "unbilled") }
end
end
end
2011-10-31 16:14:54 +01:00
end
_time_entries
2011-10-31 16:14:54 +01:00
end
# Form for editing a time entry ({Models::TimeEntry}).
#
# @return [Mab::Mixin::Tag] the time entry form
2011-11-07 17:44:58 +01:00
def time_entry_form
header.page_header do
h1 do
text! "Time Entry Information"
small @input["comment"]
end
end
div.alert.alert_warning do
button.close(type: "button", data_dismiss: "alert") { "&times;" }
strong "Warning!"
text! "This time entry is already billed! Only make changes if you " +
"know what you are doing!"
end if @time_entry.present? and @time_entry.task.billed?
div.row do
div.col_md_6.col_xs_12 do
form.form_horizontal.form_condensed action: R(*@target), method: :post do
div.form_group do
label.control_label.col_sm_3.col_xs_4 "Customer", for: "customer"
div.col_sm_4.col_xs_8 do
_form_select("customer", @customer_list)
end
div.col_sm_offset_2.col_sm_3.hidden_xs do
if @time_entry.present?
a.btn.btn_default role: "button",
href: R(CustomersN, @time_entry.customer.id) do
_icon("user")
span "Show customer"
end
end
end
end
div.form_group do
label.control_label.col_sm_3.col_xs_4 "Project/Task", for: "task"
div.col_sm_6.col_xs_8 do
_form_select_nested("task", @task_list)
end
div.col_sm_3.hidden_xs do
if @time_entry.present?
a.btn.btn_default role: "button",
href: R(CustomersNTasksN,
@time_entry.customer.id,
@time_entry.task.id) do
_icon("pencil")
span "Show project/task"
end
end
end
end
if @time_entry.present? and @time_entry.task.billed?
div.form_group do
label.control_label.col_sm_3.col_xs_4 "Billed in invoice"
div.col_sm_6.col_xs_8 do
a.btn.btn_default role: "button",
href: R(CustomersNInvoicesX,
@time_entry.customer.id,
@time_entry.task.invoice.number) do
_icon("file")
span @time_entry.task.invoice.number
end
end
end
2013-06-16 20:27:11 +02:00
end
_form_input_with_label("Date", "date", :text,
control_class: "col-sm-3 col-xs-5")
_form_input_with_label("Start time", "start", :text,
control_class: "col-sm-2 col-xs-3")
_form_input_with_label("End time", "end", :text,
control_class: "col-sm-2 col-xs-3")
_form_input_with_label("Comment", "comment", :text,
control_class: "col-sm-6 col-xs-8")
div.form_group do
label.control_label.col_sm_3.col_xs_4 "Bill?", for: "bill"
div.col_sm_3.col_xs_5 do
div.checkbox do
_form_input_checkbox("bill")
end
end
end
div.form_group do
div.col_sm_offset_3.col_sm_6.col_xs_offset_4.col_xs_8 do
button.btn.btn_primary @button.capitalize, type: "submit",
name: @button, value: @button.capitalize
button.btn.btn_default "Cancel", type: "submit",
name: "cancel", value: "Cancel"
end
end
end
end
2011-11-07 17:44:58 +01:00
end
end
# The main overview of the list of customers.
#
# @return [Mab::Mixin::Tag] the customer list overview
2011-10-31 16:14:54 +01:00
def customers
header.page_header do
2013-06-26 20:23:03 +02:00
h1 do
text! "Customers"
div.btn_group.navbar_right do
a.btn.btn_default.btn_sm role: "button", href: R(CustomersNew) do
_icon("plus")
span "Add new customer"
end
2013-06-26 20:23:03 +02:00
end
end
2013-06-16 20:27:11 +02:00
end
if @customers.empty?
div.alert.alert_info do
text! "None found! You can create one " +
"#{a "here", href: R(CustomersNew)}."
end
else
table.table.table_striped.table_condensed do
thead do
tr do
th.col_md_2.col_xs_5 "Name"
th.col_md_1.hidden_xs "Short name"
th.col_md_4.col_xs_5 "Address"
th.col_md_2.hidden_xs "Email"
th.col_md_2.hidden_xs "Phone"
th.col_md_1.col_xs_2 {}
end
2013-06-16 20:27:11 +02:00
end
tbody do
@customers.each do |customer|
2013-06-16 20:27:11 +02:00
tr do
td { a customer.name, href: R(CustomersN, customer.id) }
td.hidden_xs { customer.short_name || ""}
td do
if customer.address_street.present?
text! customer.address_street
br
text! customer.address_postal_code + "&nbsp;" +
customer.address_city
if customer.email.present?
a.visible_xs customer.email,
href: "mailto:#{customer.email}"
end
if customer.phone.present?
# FIXME: hardcoded prefix!
span.visible_xs "0#{customer.phone}"
end
else
""
2013-06-16 20:27:11 +02:00
end
end
td.hidden_xs do
if customer.email.present?
a customer.email, href: "mailto:#{customer.email}"
else
""
2013-06-16 20:27:11 +02:00
end
end
td.hidden_xs do
if customer.phone.present?
# FIXME: hardcoded prefix!
"0#{customer.phone}"
else
""
2013-06-16 20:27:11 +02:00
end
end
td do
form action: R(CustomersN, customer.id), method: :post do
button.btn.btn_xs.btn_danger "Delete", type: :submit,
name: "delete", value: "Delete"
2013-06-16 20:27:11 +02:00
end
end
end
end
2011-10-31 16:14:54 +01:00
end
2013-06-16 20:27:11 +02:00
end
end
end
2011-11-09 15:14:48 +01:00
# Form for editing the properties of customer ({Models::Customer}) but also
# for adding/editing/deleting tasks and showing a list of invoices for
# the customer.
#
# @return [Mab::Mixin::Tag] the customer form
def customer_form
header.page_header do
h1 do
text! "Customer Information"
small @input["name"]
end
end
div.row do
div.col_md_6 do
h2 "Details"
form.form_horizontal.form_condensed action: R(*@target),
method: :post do
_form_input_with_label("Name", "name", :text,
control_class: "col-sm-6 col-xs-8")
_form_input_with_label("Short name", "short_name", :text,
control_class: "col-sm-4 col-xs-6")
_form_input_with_label("Street address", "address_street", :text,
control_class: "col-sm-6 col-xs-8")
_form_input_with_label("Postal code", "address_postal_code", :text,
control_class: "col-sm-3 col-xs-4")
_form_input_with_label("City/town", "address_city", :text,
control_class: "col-sm-6 col-xs-8")
_form_input_with_label("Email address", "email", :email,
control_class: "col-sm-6 col-xs-8")
_form_input_with_label("Phone number", "phone", :tel,
control_class: "col-sm-3 col-xs-4")
_form_input_with_label("Financial contact", "financial_contact", :text,
control_class: "col-sm-6 col-xs-8")
_form_input_with_label("Default hourly rate", "hourly_rate", :text,
control_class: "col-sm-3 col-xs-4",
input_addon: "€ / h")
div.form_group do
label.control_label.col_sm_4.col_xs_4 "Time specifications?"
div.col_sm_6.col_xs_8 do
div.checkbox do
_form_input_checkbox("time_specification")
end
end
end
div.form_group do
div.col_sm_offset_4.col_sm_6.col_xs_offset_4.col_xs_8 do
button.btn.btn_primary @button.capitalize, type: "submit",
name: @button, value: @button.capitalize
button.btn.btn_default "Cancel", type: "submit",
name: "cancel", value: "Cancel"
end
end
end
end
div.col_md_6 do
if @edit_task
h2 do
text! "Projects & Tasks"
div.btn_group.navbar_right do
a.btn.btn_default.btn_sm role: "button",
href: R(CustomersNTasksNew, @customer.id) do
_icon("plus")
span "Add new project/task"
end
end
end
if @billed_tasks.empty?
p { em "None found!" }
else
div.accordion.task_list! do
@billed_tasks.keys.sort_by { |task| task.name }.each do |task|
div.panel.panel_default do
div.panel_heading role: "tab", id: "heading#{task.id}" do
h3.panel_title do
a task.name, data_toggle: "collapse",
data_parent: "#task_list",
aria_expanded: true,
aria_controls: "#collapse#{task.id}",
role: "button",
href: "#collapse#{task.id}"
# FXIME: the following is not very RESTful!
form.form_inline.pull_right action: R(CustomersNTasks,
@customer.id),
method: :post do
a.btn.btn_default.btn_xs "Edit", role: "button",
href: R(CustomersNTasksN, @customer.id, task.id)
input type: :hidden, name: "task_id", value: task.id
button.btn.btn_danger.btn_xs "Delete", type: :submit,
name: "delete", value: "Delete"
end
end
end
div.panel_collapse.collapse role: "tabpanel",
id: "collapse#{task.id}" do
div.panel_body do
if @billed_tasks[task].empty?
em { "No billed projects/tasks found!" }
else
table.table.table_condensed do
@billed_tasks[task] \
.sort_by { |t| t.invoice.number } \
.each do |billed_task|
tr do
td.col_md_9 do
a billed_task.comment_or_name,
href: R(CustomersNTasksN,
@customer.id,
billed_task.id)
small do
text! "(billed in invoice "
a billed_task.invoice.number,
title: billed_task.invoice.number,
href: R(CustomersNInvoicesX,
@customer.id,
billed_task.invoice.number)
text! ")"
end
2013-07-14 14:33:12 +02:00
end
td.col_md_3 do
# FXIME: the following is not very RESTful!
form.form_inline.pull_right \
action: R(CustomersNTasks, @customer.id),
method: :post do
a.btn.btn_default.btn_xs "Edit",
href: R(CustomersNTasksN,
@customer.id,
billed_task.id),
role: "button"
input type: :hidden, name: "task_id",
value: billed_task.id
button.btn.btn_danger.btn_xs "Delete",
type: :submit, name: "delete",
value: "Delete"
end
2013-07-14 14:33:12 +02:00
end
end
end
end
end
end
end
2013-06-16 20:27:11 +02:00
end
end
end
end
h2 do
text! "Invoices"
2015-06-20 18:27:33 +02:00
div.btn_group.navbar_right do
a.btn.btn_default.btn_sm role: "button",
href: R(CustomersNInvoicesNew, @customer.id) do
2015-06-20 18:27:33 +02:00
_icon("plus")
span "Create new invoice"
end
end
end
_invoice_list(@invoices)
2013-06-16 20:27:11 +02:00
end
end
end
# Show registered time using the time_entries view as partial view.
div.row do
div.col_md_10.col_xs_12 do
h2 "Registered time"
_time_entries(@customer)
end
end unless @button == "create"
end
# Form for updating the properties of a task ({Models::Task}).
#
# @return [Mab::Mixin::Tag] the task form
def task_form
header.page_header do
h1 do
text! "Task Information"
small @task.name
end
end
div.alert.alert_warning do
button.close(type: "button", data_dismiss: "alert") { "&times;" }
strong "Warning!"
text! "This task is already billed! Only make changes if you know " +
"what you are doing!"
end if @task.billed?
div.row do
div.col_md_6.col_xs_12 do
form.form_horizontal.form_condensed action: R(*@target), method: :post do
div.form_group do
label.control_label.col_sm_3.col_xs_4 "Customer", for: "customer"
div.col_sm_4.col_xs_8 do
_form_select("customer", @customer_list)
end
div.col_sm_offset_2.col_sm_3.hidden_xs do
a.btn.btn_default role: "button", href: R(CustomersN, @customer.id) do
_icon("user")
span "Show customer"
end
end
end
_form_input_with_label("Name", "name", :text)
div.form_group do
label.control_label.col_sm_3.col_xs_4 "Project/Task type"
div.col_sm_4.col_xs_8 do
div.radio do
label do
_form_input_radio("type", "hourly_rate", true)
text!("Hourly rate: ")
div.input_group do
_form_input("hourly_rate", :number, "Hourly rate")
span.input_group_addon "€ / h"
end
end
end
div.radio do
label do
_form_input_radio("type", "fixed_cost")
text!("Fixed cost: ")
div.input_group do
_form_input("fixed_cost", :number, "Fixed cost")
span.input_group_addon ""
end
end
end
end
end
_form_input_with_label("VAT rate", "vat_rate", :number,
control_class: "col-sm-3 col-xs-6",
input_addon: "%")
if @task.billed?
div.form_group do
label.control_label.col_sm_3.col_xs_4 "Billed in invoice"
div.col_sm_6.col_xs_8 do
a.btn.btn_default role: "button",
href: R(CustomersNInvoicesX,
@customer.id, @task.invoice.number) do
_icon("file")
span @task.invoice.number
end
end
end
_form_input_with_label("Invoice comment", "invoice_comment", :text)
2013-06-16 20:27:11 +02:00
end
div.form_group do
div.col_sm_offset_3.col_sm_6.col_xs_offset_4.col_xs_8 do
button.btn.btn_primary @method.capitalize,
type: "submit", name: @method, value: @method.capitalize
button.btn.btn_default "Cancel", type: "submit",
name: "cancel", value: "Cancel"
end
end
2011-11-09 15:14:48 +01:00
end
end
end
# Show registered time (ab)using the time_entries view as partial view.
div.row do
div.col_md_8.col_xs_12 do
h2 "Registered #{@task.billed? ? "billed" : "unbilled"} time"
_time_entries(@customer, @task)
end
end unless @method == "create"
end
# The main overview of the existing invoices.
#
# @return [Mab::Mixin::Tag] the invoices list overview
def invoices
header.page_header do
h1 do
text! "Invoices"
small "#{@invoices.count} customers, #{@invoice_count} invoices"
end
end
if @invoices.values.flatten.empty?
div.alert.alert_info do
text! "Found none! You can create one by " +
"#{a "selecting a customer", href: R(Customers)}."
end
else
div.row do
@invoices.keys.sort.each do |customer|
div.col_md_6 do
next if @invoices[customer].empty?
h2 do
text! customer.name
div.btn_group.navbar_right do
a.btn.btn_default.btn_sm role: "button",
href: R(CustomersNInvoicesNew, customer.id) do
_icon("plus")
span "Create new invoice"
end
end
end
_invoice_list(@invoices[customer])
2013-06-16 20:27:11 +02:00
end
end
end
end
end
# A view displaying the information (billed tasks and time) of an
# invoice ({Models::Invoice}) that also allows for updating the "+paid+"
# property.
#
# @return [Mab::Mixin::Tag] the invoice form
def invoice_form
header.page_header do
h1 do
text! "Invoice for "
a @customer.name, href: R(CustomersN, @customer.id)
end
end
div.row do
div.col_md_6.col_xs_12 do
h2 "Details"
form.form_horizontal.form_condensed \
action: R(CustomersNInvoicesX, @customer.id, @invoice.number),
method: :post do
_form_input_with_label("Number", "number", :text, disabled: true,
control_class: "col-sm-3 col-xs-4")
div.form_group do
label.control_label.col_sm_3.col_xs_4 "Date"
div.col_sm_3.col_xs_4 do
input.form_control type: :text, name: "created_at",
id: "created_at",
value: @invoice.created_at.to_formatted_s(:date_only),
placeholder: "Date", disabled: true
2013-06-16 20:27:11 +02:00
end
end
div.form_group do
label.control_label.col_sm_3.col_xs_4 "Period"
div.col_sm_6.col_xs_8 do
input.form_control type: :text, name: "period",
id: "period",
value: _format_period(@invoice.period),
placeholder: "Period", disabled: true
2013-06-16 20:27:11 +02:00
end
end
div.form_group do
label.control_label.col_sm_3.col_xs_4 "Paid?"
div.checkbox do
label { _form_input_checkbox("paid") }
2013-06-16 20:27:11 +02:00
end
end
div.form_group do
label.control_label.col_sm_3.col_xs_4 "Include specification?"
div.checkbox do
label { _form_input_checkbox("include_specification") }
end
end
div.form_group do
div.col_sm_offset_3.col_sm_4.col_xs_offset_4.col_xs_8 do
button.btn.btn_primary "Update", type: :submit,
name: "update", value: "Update"
button.btn.btn_default "Reset", type: :reset,
name: "reset", value: "Reset"
end
end
end
end
div.col_md_6.col_xs_12 do
h2 "Billed time"
table.table.table_condensed.table_striped do
thead do
tr do
th.col_md_6 { "Project/Task" }
th.col_md_2.text_right { "Registered" }
th.col_md_2.text_right { "Hourly rt." }
th.col_md_2.text_right { "Amount" }
end
end
tbody do
subtotal = 0.0
@tasks.each do |task, line|
2013-06-16 20:27:11 +02:00
tr do
td.col_md_6 do
a task.comment_or_name, title: task.comment_or_name,
href: R(CustomersNTasksN,
task.customer.id, task.id)
end
if line[1].blank?
# FIXME: information of time spent is available in the summary
# but show it?
td.col_md_2.text_right { "%.2fh" % line[0] }
td.col_md_2.text_right ""
else
td.col_md_2.text_right { "%.2fh" % line[0] }
td.col_md_2.text_right { "€ %.2f" % line[1] }
end
td.col_md_2.text_right { "€ %.2f" % line[2] }
2013-06-16 20:27:11 +02:00
end
subtotal += line[2]
task.time_entries.each do |entry|
2013-06-16 20:27:11 +02:00
tr do
td.col_md_6.indent do
time_spec =
"from #{entry.start.to_formatted_s(:time_only)} " +
"until #{entry.end.to_formatted_s(:time_only)} " +
"on #{entry.date.to_date}"
if entry.comment.present?
a "#{entry.comment}",
href: R(TimelineN, entry.id),
title: "#{entry.comment} (#{time_spec})"
else
a(href: R(TimelineN, entry.id),
title: time_spec) { em "• None" }
2013-06-16 20:27:11 +02:00
end
end
td.col_md_2.text_right { "%.2fh" % entry.hours_total }
td.col_md_2.text_right { "" }
td.col_md_2.text_right { "" }
2013-06-16 20:27:11 +02:00
end
end unless task.fixed_cost?
end
vattotal = 0.0
if @company.vatno.present?
2013-06-16 20:27:11 +02:00
tr.total do
td { em "Sub-total" }
2013-06-16 20:27:11 +02:00
td ""
td ""
td.text_right { "€ %.2f" % subtotal }
end
@vat.keys.sort.each do |rate|
vattotal += @vat[rate]
tr do
td { em "VAT %d%%" % rate }
td ""
td ""
td.text_right { "€ %.2f" % @vat[rate] }
end
end
end
tr.total do
td { strong "Total" }
td ""
td ""
td.text_right { "€ %.2f" % (subtotal + vattotal) }
end
end
end
2013-06-16 20:27:11 +02:00
div.btn_group do
a.btn.btn_primary role: "button",
href: R(CustomersNInvoicesX,
@customer.id, "#{@invoice.number}.pdf") do
_icon("download")
span "Download PDF"
end
a.btn.btn_default role: "button",
href: R(CustomersNInvoicesX,
@customer.id, "#{@invoice.number}.tex") do
_icon("download")
span "Download LaTeX source"
end
a.btn.btn_default role: "button",
href: R(Company, revision: @company.revision) do
_icon("briefcase")
span "Show company info"
end
end
div.alert.alert_danger do
form.form_inline action: R(CustomersNInvoicesX,
@customer.id, @invoice.number),
method: :delete do
button.btn.btn_danger type: "submit" do
_icon("trash")
span "Remove old"
end
text! "An invoice has already been generated!"
end
end if @invoice_file_present
end
end
2011-10-31 16:14:54 +01:00
end
# Form for selecting fixed cost tasks and registered time for tasks with
# an hourly rate that need to be billed.
#
# @return [Mab::Mixin::Tag] the invoice construction form
def invoice_select_form
header.page_header do
h1 do
text! "Create Invoice for "
a @customer.name, href: R(CustomersN, @customer.id)
end
end
div.row do
div.col_md_10.col_xs_12 do
if @none_found
div.alert.alert_info do
em "No fixed costs tasks or tasks with an hourly rate found!"
end
end
form.form_horizontal action: R(CustomersNInvoices, @customer.id),
method: :post do
unless @hourly_rate_tasks.empty?
h3 "Projects/Tasks with an Hourly Rate"
table.table.table_striped.table_condensed do
thead do
tr do
th.col_md_1 "Bill?"
th.col_md_2 "Date"
th.col_md_1 "Start"
th.col_md_1 "End"
th.col_md_4 "Comment"
th.col_md_1.text_right "Total"
th.col_md_2.text_right "Amount"
2013-06-16 20:27:11 +02:00
end
end
tbody do
@hourly_rate_tasks.keys.each do |task|
tr.info do
td.col_md_1 do
label.checkbox do
_form_input_checkbox("tasks[]", task.id, true)
end
2013-06-16 20:27:11 +02:00
end
td.col_md_2 colspan: 3 do
label.control_label { task.name }
end
td.col_md_1 do
input.form_control.input_sm type: :text,
name: "task_#{task.id}_comment", value: task.name,
id: "tasks_#{task.id}_comment"
end
td.col_md_1 {}
td.col_md_2 {}
end
@hourly_rate_tasks[task].each do |entry|
tr do
td.col_md_1.indent do
label.checkbox do
_form_input_checkbox("time_entries[]", entry.id,
!entry.in_current_month?)
end
end
td.col_md_2 do
label.control_label entry.date.to_date,
for: "time_entries[]_#{entry.id}"
end
td.col_md_1 do
label.control_label do
entry.start.to_formatted_s(:time_only)
end
end
td.col_md_1 do
label.control_label do
entry.end.to_formatted_s(:time_only)
end
end
td.col_md_4 do
label.control_label do
a entry.comment, href: R(TimelineN, entry.id),
title: entry.comment
end
end
td.col_md_1.text_right do
label.control_label { "%.2fh" % entry.hours_total }
end
td.col_md_2.text_right do
label.control_label do
"€ %.2f" %
(entry.hours_total * entry.task.hourly_rate)
end
end
2013-06-16 20:27:11 +02:00
end
end
end
end
end
end
2013-06-16 20:27:11 +02:00
unless @fixed_cost_tasks.empty?
h3 "Fixed Cost Projects/Tasks"
table.table.table_striped.table_condensed do
thead do
tr do
th.col_md_1 "Bill?"
th.col_md_4 "Project/Task"
th.col_md_4 "Comment"
th.col_md_1.text_right "Registered time"
th.col_md_2.text_right "Amount"
2013-06-16 20:27:11 +02:00
end
end
tbody do
@fixed_cost_tasks.keys.each do |task|
tr do
td.col_md_1 do
label.checkbox do
_form_input_checkbox("tasks[]", task.id, true)
end
end
td.col_md_4 do
label.control_label task.name, for: "tasks[]_#{task.id}"
end
td.col_md_4 do
input.form_control.input_sm type: :text,
name: "task_#{task.id}_comment",
value: task.name, class: "form-control",
id: "tasks_#{task.id}_comment"
end
td.col_md_1.text_right do
label.control_label do
"%.2fh" % @fixed_cost_tasks[task]
end
end
td.col_md_2.text_right do
label.control_label { task.fixed_cost }
2013-06-16 20:27:11 +02:00
end
end
end
end
end
end
div.form_group do
div.col_md_12 do
button.btn.btn_primary "Create invoice", type: :submit,
name: "create", value: "Create invoice",
disabled: @none_found
button.btn.btn_default "Cancel", type: :submit, name: "cancel",
value: "Cancel"
end
end
end
end
end
end
# Form for editing the company information ({Models::CompanyInfo}).
#
# @return [Mab::Mixin::Tag] the company information form
def company_form
header.page_header do
h1 do
text! "Company Information"
small @company.name
end
end
div.alert.alert_danger do
button.close(type: "button", data_dismiss: "alert") { "&times;" }
h4 "There were #{@errors.count} errors in the form!"
ul do
@errors.each do |attrib, msg|
li "#{attrib.to_s.capitalize} #{msg}"
2013-06-16 20:27:11 +02:00
end
end
end if @errors
div.alert.alert_info do
text! " Viewing revision #{@company.revision}, " +
" last update at #{@company.updated_at}."
div.btn_group do
if @company.original.present?
a.btn.btn_default role: "button",
href: R(Company, revision: @company.original.revision) do
_icon("backward")
span "View previous revision"
end
end
unless @company_last
a.btn.btn_default role: "button",
href: R(Company, revision: @company.revision.succ) do
_icon("forward")
span "View next revision"
end
a.btn.btn_default role: "button",
href: R(Company) do
_icon("fast-forward")
span "Most recent revision"
end
end
end
end
div.alert.alert_warning do
button.close(type: "button", data_dismiss: "alert") { "&times;" }
h4 "Warning!"
text! "This company information is already associated with invoices!"
br
text! "Only make changes if you know what you are doing!"
end if @history_warn
div.row do
div.col_md_6.col_xs_12 do
form.form_horizontal.form_condensed \
action: R(Company, revision: @company.revision),
method: :post do
_form_input_with_label("Name", "name", :text,
control_class: "col-sm-6 col-xs-8")
_form_input_with_label("Contact name", "contact_name", :text,
control_class: "col-sm-6 col-xs-8")
_form_input_with_label("Street address", "address_street", :text,
control_class: "col-sm-6 col-xs-8")
_form_input_with_label("Postal code", "address_postal_code", :text,
control_class: "col-sm-3 col-xs-4")
_form_input_with_label("City/town", "address_city", :text,
control_class: "col-sm-6 col-xs-8")
_form_input_with_label("Phone number", "phone", :tel,
control_class: "col-sm-3 col-xs-4")
_form_input_with_label("Cellular number", "cell", :tel,
control_class: "col-sm-3 col-xs-4")
_form_input_with_label("Email address", "email", :email,
control_class: "col-sm-6 col-xs-8")
_form_input_with_label("Web address", "website", :url,
control_class: "col-sm-6 col-xs-8")
h3 "Corporate information"
_form_input_with_label("Chamber number", "chamber", :text,
control_class: "col-sm-3 col-xs-4")
_form_input_with_label("VAT number", "vatno", :text,
control_class: "col-sm-5 col-xs-6")
h3 "Bank information"
_form_input_with_label("Name", "bank_name", :text,
control_class: "col-sm-4 col-xs-5")
_form_input_with_label("Identification code", "bank_bic", :text,
control_class: "col-sm-3 col-xs-4")
_form_input_with_label("Account holder", "accountname", :text)
_form_input_with_label("Account number", "accountno", :text,
control_class: "col-sm-5 col-xs-6")
_form_input_with_label("Intl. account number", "accountiban", :text,
control_class: "col-sm-5 col-xs-6")
div.form_group do
div.col_sm_offset_3.col_sm_6.col_xs_offset_4.col_xs_8 do
button.btn.btn_primary "Update", type: "submit",
name: "update", value: "Update"
button.btn.btn_default "Reset", type: :reset,
name: "reset", value: "Reset"
end
end
end
2013-06-16 20:27:11 +02:00
end
end
end
2011-12-10 21:05:58 +01:00
###############
# Partial views
#
private
# Parial view that generates an icon.
#
# @return [Mab::Mixin::Tag] the icon
def _icon(name)
span.glyphicon.send("glyphicon_#{name}", { aria_hidden: true })
end
2011-12-10 21:05:58 +01:00
# Partial view that generates the menu.
#
# @return [Mab::Mixin::Tag] the main menu
2011-12-10 21:05:58 +01:00
def _menu
nav.navbar.navbar_inverse.navbar_fixed_top do
div.container do
div.navbar_header do
button.navbar_toggle.collapsed(data_toggle: "collapse",
data_target: "#menu",
aria_expanded: false) do
span.sr_only "Toggle navigation"
span.icon_bar {}
span.icon_bar {}
span.icon_bar {}
end
a.navbar_brand(href: R(Index)) { "Stop… Camping Time!" }
end
div.navbar_collapse.collapse.menu! do
ul.nav.navbar_nav do
2013-06-16 20:27:11 +02:00
[["Overview", Index],
["Timeline", Timeline],
["Customers", Customers],
["Invoices", Invoices],
["Company", Company]].each do |label, ctrl|
_menu_link(label, ctrl)
end
2013-06-16 20:27:11 +02:00
end
end
end
2011-12-10 21:05:58 +01:00
end
end
# Partial view that generates the menu link and determines the active
# menu item.
#
# @param [String] label menu item label
# @param [Object] ctrl menu item target controller
# @return [Mab::Mixin::Tag] a menu item link tag
2011-12-10 21:05:58 +01:00
def _menu_link(label, ctrl)
# FIXME: dirty hack?
if self.class.to_s.match(/^#{ctrl.to_s}/)
li.active { a label, href: R(ctrl) }
2011-12-10 21:05:58 +01:00
else
li { a label, href: R(ctrl) }
2011-12-10 21:05:58 +01:00
end
end
# Partial view that generates a list of invoices.
#
# @param [Array<Invoice>] invoices list of invoices
# @return [Mab::Mixin::Tag] a list of invoices
2011-12-10 21:05:58 +01:00
def _invoice_list(invoices)
if invoices.empty?
p { em "None found!" }
2011-12-10 21:05:58 +01:00
else
2013-06-16 20:27:11 +02:00
table.table.table_striped.table_condensed do
thead do
2011-12-10 21:05:58 +01:00
tr do
th.col_md_2.col_xs_3 "Number"
th.col_md_2.hidden_xs "Date"
th.col_md_5.col_xs_5 "Period"
th.col_md_2.col_xs_3.text_right "Amount"
th.col_md_1.col_xs_1 "Paid?"
2013-06-16 20:27:11 +02:00
end
end
tbody do
invoices.each do |invoice|
due_class = invoice.past_due? ? "warning" : ""
due_class = "error" if invoice.way_past_due?
tr(class: due_class) do
2013-06-16 20:27:11 +02:00
td do
a invoice.number,
href: R(CustomersNInvoicesX,
invoice.customer.id, invoice.number)
2013-06-16 20:27:11 +02:00
end
td.hidden_xs { invoice.created_at.to_formatted_s(:date_only) }
2013-06-16 20:27:11 +02:00
td { _format_period(invoice.period) }
td.text_right { "€ %.2f" % invoice.total_amount }
td do
_icon("ok") if invoice.paid?
2013-06-16 20:27:11 +02:00
end
2011-12-10 21:05:58 +01:00
end
end
end
end
end
end
# Partial view for formatting the period of an invoice.
#
# @param [Array(Time), Array(Time, Time)] period invoice period
# @return [String] formatted period of an invoice
2011-12-10 21:05:58 +01:00
def _format_period(period)
period = period.map { |m| m.to_formatted_s(:month_and_year) }.uniq
case period.length
2012-06-06 11:28:59 +02:00
when 1
period.first
when 2
period.join("")
2011-12-10 21:05:58 +01:00
end
end
# Partial view that generates a form input.
2013-06-16 20:27:11 +02:00
#
# @param [String] input_name name of the input
# @param [String] type type of the input
# @param [String] placeholder placeholder text
# @param [Hash] html_options options passed on to the resulting
# Markaby/Mab tag
# @return [Mab::Mixin::Tag] a form input tag
2013-06-16 20:27:11 +02:00
def _form_input(input_name, type, placeholder, html_options={})
html_class = html_options.delete(:class)
html_options.merge!(type: type, name: input_name, id: input_name,
value: @input[input_name],
class: "form-control #{html_class}",
placeholder: placeholder)
2013-06-16 20:27:11 +02:00
input(html_options)
end
# Partial view that generates a form label and a form input, such that
# the label is linked to the input.
2013-06-16 20:27:11 +02:00
#
# @param [String] label_name label for the input
# @param [String] input_name name of the input
# @param [String] type type of the input
# @param [Hash] html_options options passed on to the resulting
# Markaby/Mab tag
# @return [Mab::Mixin::Tag] a form input tag
2013-06-16 20:27:11 +02:00
def _form_input_with_label(label_name, input_name, type, html_options={})
controls_class = html_options.delete(:control_class) || "col-sm-6 col-xs-8"
label_class = html_options.delete(:label_class) || "col-sm-3 col-xs-4"
input_addon = html_options.delete(:input_addon)
div.form_group do
label.control_label(label_name,
class: "control-label #{label_class}",
for: input_name)
div(class: controls_class) do
if input_addon
div.input_group do
_form_input(input_name, type, label_name, html_options)
span.input_group_addon input_addon
end
else
_form_input(input_name, type, label_name, html_options)
end
2013-06-16 20:27:11 +02:00
end
end
2011-10-31 16:14:54 +01:00
end
# Partial view that generates a form radio button.
#
# @param [String] name name of the radio button
# @param [String] value value of the radio button
# @param [Boolean] default whether the radio button is initially selected
# @param [Array] opts additional Markaby/Mab tag options
# @return [Mab::Mixin::Tag] a form radio button input tag
def _form_input_radio(name, value, default=false, *opts)
input_val = @input[name]
if input_val == value or (input_val.blank? and default)
input({ type: "radio", id: "#{name}_#{value}", name: name,
value: value, checked: true }, *opts)
else
input({ type: "radio", id: "#{name}_#{value}", name: name,
value: value }, *opts)
end
end
# Partial view that generates a form checkbox.
# Whether it is initially checked is determined by the _default_ flag.
# Additional options can be passed via the collection _opts_.
#
# @param [String] name name of the checkbox
# @param [Boolean] value whether the checkbox is checked
# @param [Boolean] default whether the checkbox is initially checked
# @param [Array] opts additional Markaby/Mab tag options
# @return [Mab::Mixin::Tag] a form checkbox input tag
def _form_input_checkbox(name, value=true, default=false, *opts)
if @input[name] == value or default
input({ type: "checkbox", id: "#{name}_#{value}", name: name,
value: value, checked: true }, *opts)
else
input({ type: "checkbox", id: "#{name}_#{value}", name: name,
value: value }, *opts)
end
end
# Partial view that generates a form select element.
#
# The option list is an Array of a 2-valued array containg a value label
# and a human readable description for the value.
2013-06-16 20:27:11 +02:00
#
# @param [String] name name of the form select element
# @param [Array(String, String)] opts_list list of options (value and
# description)
# @param [Hash] html_options options passed on to the resulting
# Markaby/Mab tag
# @return [Mab::Mixin::Tag] a form select input tag
2013-06-16 20:27:11 +02:00
def _form_select(name, opts_list, html_options={})
html_class = html_options.delete(:class)
if opts_list.blank?
html_options.merge!(name: name, id: name,
class: "form-control", disabled: true)
2013-06-16 20:27:11 +02:00
select(html_options) do
option "None found", value: "none", selected: true
end
else
html_options.merge!(name: name, id: name,
class: "form-control #{html_class}")
2013-06-16 20:27:11 +02:00
select(html_options) do
opts_list.sort_by { |o| o.last }.each do |opt_val, opt_str|
if @input[name] == opt_val
option opt_str, value: opt_val, selected: true
else
option opt_str, value: opt_val
end
end
end
end
end
# Partial view similar to {Views#_form_select} that generates a (nested)
# select element for a form.
#
# In this case, the option lists represents a subdivision of the options,
# where the key is the name of the subdivision and the value the options
# list as in {Views#_form_select}.
#
# So, the option list is an Hash of Strings mapping to Arrays of a
# 2-valued array containg a value label and a human readable description
# for the value.
2013-06-16 20:27:11 +02:00
#
# @param [String] name name of the form select element
# @param [Hash{String=>Array(String, String)}] opts list of options
# (section name to value and description)
# @param [Hash] html_options options passed on to the resulting
# Markaby/Mab tag
# @return [Mab::Mixin::Tag] a form select input tag
2013-06-16 20:27:11 +02:00
def _form_select_nested(name, opts, html_options={})
html_class = html_options.delete(:class)
if opts.blank?
html_options.merge!(name: name, id: name, disabled: true,
class: "form-control")
2013-06-16 20:27:11 +02:00
select(html_options) do
option "None found", value: "none", selected: true
end
else
html_options.merge!(name: name, id: name,
class: "form-control #{html_class}")
2013-06-16 20:27:11 +02:00
select(html_options) do
opts.keys.sort.each do |key|
optgroup label: key do
opts[key].sort_by { |o| o.last }.each do |opt_val, opt_str|
if @input[name] == opt_val
option(opt_str, { value: opt_val, selected: true })
else
option(opt_str, { value: opt_val })
end
end
end
end
end
end
end
# Partial view that shows time entries. If a customer is given,
# only time entries of that customer are shown, and if also a task is
# given, then only the time entries of that task are shown.
#
# @param [Customer, nil] customer an customer to show time entries for
# @param [Customer, nil] task a task to show time entries for
# @return [Mab::Mixin::Tag] the main menu
def _time_entries(customer=nil, task=nil)
form.form_inline action: R(Timeline), method: :post do
table.table.table_condensed.table_striped.table_hover do
thead do
tr do
if customer.blank?
th.col_md_2.hidden_xs "Customer"
end
if task.blank?
th.col_md_2.col_xs_2 "Project/Task"
end
th.col_md_2.col_xs_2 "Date"
th.col_md_1.hidden_xs "Start"
th.col_md_1.hidden_xs "End"
th.col_md_2.col_xs_3 "Comment"
th.col_md_1.col_xs_2 "Total"
th.col_md_1.col_xs_1 "Bill?"
th.col_md_1.col_xs_2 {}
end
end
tbody do
tr.hidden_xs do
if task.present?
input type: :hidden, name: "task", value: task.id
else
if customer.blank?
td.col_md_1.hidden_xs { }
end
td.col_md_2 do
_form_select_nested("task", @task_list)
end
end
td.col_md_1 do
input.form_control type: :text, name: "date",
value: DateTime.now.to_date.to_formatted_s
end
td.col_md_1 do
input.form_control type: :text, name: "start",
value: DateTime.now.to_time.to_formatted_s(:time_only)
end
td.col_md_1 do
input.form_control type: :text, name: "end"
end
td.col_md_2 do
input.form_control type: :text, name: "comment"
end
td.col_md_1 do
p.form_control_static "N/A"
end
td.col_md_1 do
label.checkbox {_form_input_checkbox("bill") }
end
td.col_md_1 do
button.btn.btn_primary.btn_sm "Enter", type: :submit,
name: "enter", value: "Enter"
end
end
end
@time_entries.each do |entry|
tr(class: entry.task.billed? ? "billed" : nil) do
if customer.blank?
td.col_md_1.hidden_xs do
a entry.customer.shortest_name,
title: entry.customer.shortest_name,
href: R(CustomersN, entry.customer.id)
end
end
if task.blank?
td.col_md_2 do
a entry.task.name,
title: entry.task.name,
href: R(CustomersNTasksN, entry.customer.id, entry.task.id)
end
end
td.col_md_1 { entry.date.to_date }
td.col_md_1.hidden_xs { entry.start.to_formatted_s(:time_only) }
td.col_md_1.hidden_xs { entry.end.to_formatted_s(:time_only)}
if entry.comment.present?
td.col_md_2 do
a entry.comment, href: R(TimelineN, entry.id),
title: entry.comment
end
else
td.col_md_2 { a(href: R(TimelineN, entry.id)) { em "None" } }
end
td.col_md_1 { "%.2fh" % entry.hours_total }
td.col_md_1 do
_icon("ok") if entry.bill?
end
td.col_md_1 do
form.form_inline action: R(TimelineN, entry.id),
method: :post do
button.btn.btn_xs.btn_danger "Delete", type: :submit,
name: "delete", value: "Delete"
end
end
end
end
end
end
end
end # module StopTime::Views