stoptime/stoptime.rb

3263 lines
111 KiB
Ruby
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!usr/bin/env camping
# 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"
require "camping/mab"
require "camping/ar"
require "pathname"
require "sass/plugin/rack"
Camping.goes :StopTime
unless defined? PUBLIC_DIR
# The directory with public data.
PUBLIC_DIR = Pathname.new(__FILE__).dirname.expand_path + "public"
# The directory with template data.
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"
# 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
VERSION = '1.17.1'
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.
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,
"date_new_entry" => "today",
"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
#
# 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
# Returns the short name if set, otherwise the full name.
#
# @return [String] the shortest name
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
# 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)
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
if company_info.vatno.blank?
subtotal
else
subtotal + vattotal
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|
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
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
def self.up
rename_column(Invoice.table_name, :payed, :paid)
end
def self.down
rename_column(Invoice.table_name, :paid, :payed)
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
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
end # StopTime::Models
# = The Stop… Camping Time! helpers
module StopTime::Helpers
# Returns the date/time to use for new time entry defaults, or +nil+ if
# none is to be used. This method can use the last time entry (if any
# and if so configured). The result is based on the +date_new_entry+
# configuration option.
#
# @param last_entry [DateTime] the last time entry to use if configured
# for "previous"
# @return [DateTime, nil] the date/time to be used for new entry defaults
def date_time_new_entry(last_entry = nil)
case @config["date_new_entry"]
when "previous"
TimeEntry.last.end
when "today"
DateTime.now
when "none"
nil
end
end
end
# = 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_task_count = 0
@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_task_count += active_tasks.count
@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
# == 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}
class Customers
# Show the list of customers and displays them using {Views#customers}.
def get
@customers = Customer.order("name ASC")
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}).
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)
@customer.save
if @customer.invalid?
@errors = @customer.errors
@target = [Customer]
@button = "create"
return render :customer_form
end
redirect R(CustomersN, @customer.id)
end
end # class StopTime::Controllers::CustomersN
# == 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
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")\
.reject { |te| te.task.billed? }
@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
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
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
@button = "update"
return render :customer_form
end
end
redirect R(CustomersN, customer_id)
end
end # class StopTime::Controllers::CustomersN
# == 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"
@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)
end
end # class StopTime::Controllers::CustomersNTasksN
# == 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
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
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)
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
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
########################
# 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"
I18n.with_locale :nl do
erb = ERB.new(File.read(template))
File.open(tex_file, "w") { |f| f.write(erb.result(binding)) }
end
rescue Exception => err
tex_file.delete if File.exist? tex_file
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}.
def get
if @input["time_entries"] == "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 = @time_entries.where.not(task_id: nil)
@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
@input["bill"] = true # Bill by default.
@input["task"] = @time_entries.first.task.id if @time_entries.present?
render :timeline
end
# 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
date_time_new = date_time_new_entry(TimeEntry.last)
if date_time_new
@input["date"] = date_time_new.to_date.to_formatted_s
@input["start"] = date_time_new.to_formatted_s(:time_only)
end
@target = [Timeline]
@button = "enter"
render :time_entry_form
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
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"
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)
if @input.has_key? "delete"
@time_entry.delete
elsif @input.has_key? "update"
attrs = ["date", "comment"]
attrs.each do |attr|
@time_entry[attr] = @input[attr]
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
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.id == CompanyInfo.last.id and @company.invoices.length > 0
old_company = @company
@company = old_company.dup
@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
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
_menu
div.container do
self << yield
footer { br }
end
footer.footer do
div.container do
small do
text! "Stop… Camping Time! v#{StopTime::VERSION} — by "
a "Mozcode", href: "https://mozcode.nl"
end
end
end
# 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, " +
"#{@active_task_count} active projects/tasks"
end
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 projects/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)
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] }
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["time_entries"] == "all" ? "All" : "Unbilled"
span.caret
end
ul.dropdown_menu role: "menu", aria_labelledby: "dLabel" do
li { a "All", href: R(Timeline, time_entries: "all") }
li { a "Unbilled", href: R(Timeline, time_entries: "unbilled") }
end
end
end
end
_time_entries
end
# Form for editing a time entry ({Models::TimeEntry}).
#
# @return [Mab::Mixin::Tag] the time entry form
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
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
end
end
# The main overview of the list of customers.
#
# @return [Mab::Mixin::Tag] the customer list overview
def customers
header.page_header do
h1 do
text! "Customers"
small "#{@customers.count} customers"
div.btn_group.navbar_right do
a.btn.btn_default.btn_sm role: "button", href: R(CustomersNew) do
_icon("plus")
span "Add customer"
end
end
end
end
if @customers.empty?
div.alert.alert_info do
text! "None found! You can create one " +
"#{a "here", href: R(CustomersNew)}."
end
else
div.row do
div.col_md_9.col_xs_12 do
table.table.table_striped.table_condensed do
thead do
tr do
th.col_md_2.col_xs_5 "Name"
th.col_md_2.hidden_xs "Short name"
th.col_md_3.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
end
tbody do
@customers.each do |customer|
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
""
end
end
td.hidden_xs do
if customer.email.present?
a customer.email, href: "mailto:#{customer.email}"
else
""
end
end
td.hidden_xs do
if customer.phone.present?
# FIXME: hardcoded prefix!
"0#{customer.phone}"
else
""
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"
end
end
end
end
end
end
end
end
end
end
# 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", :number,
control_class: "col-sm-4 col-xs-5",
input_addon: "€ / h",
min: "0.00",
step: "0.01")
div.form_group do
label.control_label.col_sm_3.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_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
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 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
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
end
end
end
end
end
end
end
end
end
end
end
h2 do
text! "Invoices"
div.btn_group.navbar_right do
a.btn.btn_default.btn_sm role: "button",
href: R(CustomersNInvoicesNew, @customer.id) do
_icon("plus")
span "Create invoice"
end
end
end
_invoice_list(@invoices)
end
end
end
# Show registered time using the time_entries view as partial view.
div.row do
div.col_xs_12 do
h2.timeline! "Registered unbilled 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_5.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",
min: "0.00", step: "0.01")
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",
min: "0.00", step: "0.01")
span.input_group_addon ""
end
end
end
end
end
_form_input_with_label("VAT rate", "vat_rate", :number,
control_class: "col-lg-3 col-sm-4 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)
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
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.timeline! "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 invoice"
end
end
end
_invoice_list(@invoices[customer])
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)
small @invoice.number
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
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
end
end
div.form_group do
label.control_label.col_sm_3.col_xs_4 "Paid?"
div.checkbox do
label { _form_input_checkbox("paid") }
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_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
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|
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)