3258 lines
111 KiB
Ruby
3258 lines
111 KiB
Ruby
#!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! "·" 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") { "×" }
|
||
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 + " " +
|
||
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") { "×" }
|
||
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)
|
||
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" % |