Merge branch 'master' of git.luon.net:users/paul/stoptime
This commit is contained in:
commit
2f36d30873
|
@ -1,4 +1,7 @@
|
|||
.sass-cache
|
||||
htpasswd
|
||||
db/*
|
||||
public/*.pdf
|
||||
public/*.tex
|
||||
public/stylesheets/*.css
|
||||
tmp/*
|
||||
|
|
87
README
87
README
|
@ -7,7 +7,7 @@ invoicing.
|
|||
|
||||
* Running project & tasks overview
|
||||
* Timeline overview of registered time
|
||||
* Management customer information
|
||||
* Management customer information
|
||||
* Administration of running and billed projects/task with
|
||||
* fixed cost, or
|
||||
* hourly rates
|
||||
|
@ -22,13 +22,13 @@ Stop… Camping Time! is a Camping application, so you need:
|
|||
* Camping (>= 2.1) with
|
||||
* Active Record (>= 2.3)
|
||||
* Markaby, and optionally:
|
||||
* Mongrel
|
||||
* Mongrel (for testing and deployment without Apache/Rackup)
|
||||
|
||||
The following Ruby libraries are required:
|
||||
|
||||
* ActionPack (>= 2.3) for ActionView
|
||||
* ActiveSupport (>= 2.3)
|
||||
* MIME-Types
|
||||
* Rack (for deployment using Apache/Rackup)
|
||||
* SASS
|
||||
|
||||
and the following LaTeX programs:
|
||||
|
@ -44,13 +44,92 @@ site-wide deployment yet.
|
|||
|
||||
== Usage
|
||||
|
||||
Run from the command line:
|
||||
Stop… Camping Time! can be deployed directly using the Camping server
|
||||
(which uses Mongrel, or optionally Webrick). This is for simple
|
||||
deployments or for testing purposes.
|
||||
Easy deployment via Apache is possible using Phusion Passenger, aka
|
||||
_mod_rails_ or _mod_rack_ (see http://modrails.com). See below for the
|
||||
basic instructions.
|
||||
|
||||
Note that this application is a valid Rack application (see
|
||||
http://rack.rubyforge.org/) and can be deployed by anything that supports
|
||||
them.
|
||||
|
||||
=== Camping Server/Mongrel
|
||||
|
||||
Simply run from the command line:
|
||||
|
||||
$ camping stoptime.rb
|
||||
|
||||
and head over to http://localhost:3301/ to view and use the web
|
||||
application.
|
||||
|
||||
=== Physion Passenger (mod_rails/mod_rack)/Apache
|
||||
|
||||
Camping applications are Rack applications. Deployment follows the
|
||||
standard way of deploying Rack applications using mod_rack.
|
||||
Stop… Camping Time! additionally needs to have the +xsendfile+
|
||||
module installed.
|
||||
|
||||
*N.B.* Ensure that Apache can, in both types of setups, write in the +db/+
|
||||
and +public/+ folder.
|
||||
|
||||
==== Deployment on a virtual host
|
||||
|
||||
Use the following basic configuration:
|
||||
|
||||
<VirtualHost *:80>
|
||||
ServerName some.domain.tld
|
||||
DocumentRoot /path/to/stoptime/public
|
||||
<Directory /path/to/stoptime/public>
|
||||
Allow from all
|
||||
Options -MultiViews
|
||||
</Directory>
|
||||
|
||||
XSendFile on
|
||||
</VirtualHost>
|
||||
|
||||
Now, restart Apache and visit http://some.domain.tld/.
|
||||
|
||||
==== Deployment on a sub URI
|
||||
|
||||
For deployment on a sub URI, let us assume there is some virtual host
|
||||
serving files under +/path/to/document_root+, i.e. something like:
|
||||
|
||||
<VirtualHost *:80>
|
||||
ServerName some.domain.tld
|
||||
DocumentRoot /path/to/document_root
|
||||
<Directory /path/to/document_root/
|
||||
Allow from all
|
||||
</Directory>
|
||||
</VirtualHost>
|
||||
|
||||
Then, add a symlink from the +public+ subdirectory of to the document
|
||||
root, e.g.
|
||||
|
||||
ln -s /path/to/stoptime/public /path/to/document_root/stoptime
|
||||
|
||||
Then, add a +RackBaseURI+ option to the virtual host configuration.
|
||||
For example:
|
||||
|
||||
<VirtualHost *:80>
|
||||
ServerName some.domain.tld
|
||||
...
|
||||
|
||||
RackBaseUri /stoptime
|
||||
<Directory /path/to/document_root/stoptime>
|
||||
Options -Multiviews
|
||||
</Directory>
|
||||
|
||||
XSendFile on
|
||||
</VirtualHost>
|
||||
|
||||
Now, restart Apache and visit http://some.domain.tld/stoptime.
|
||||
|
||||
For more extensive information, please refer to the documentat of
|
||||
Phusion Passenger:
|
||||
http://www.modrails.com/documentation/Users%20guide%20Apache.html#_deploying_a_rack_based_ruby_application
|
||||
|
||||
== License
|
||||
|
||||
Stop… Camping Time! is free software; you can redistribute it and/or
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
#!/usr/bin/env rackup
|
||||
|
||||
require "./stoptime"
|
||||
|
||||
StopTime::Models::Base.establish_connection( :adapter => 'sqlite3',
|
||||
:database => 'db/stoptime.db',
|
||||
:timeout => 10000 )
|
||||
StopTime.create
|
||||
run StopTime
|
340
stoptime.rb
340
stoptime.rb
|
@ -1,7 +1,6 @@
|
|||
#!/usr/bin/env camping
|
||||
#
|
||||
# stoptime.rb - The Stop… Camping Time! time registration and invoice
|
||||
# application.
|
||||
# stoptime.rb - The Stop… Camping Time! time registration and invoicing application.
|
||||
#
|
||||
# Stop… Camping Time! is Copyright © 2011 Paul van Tilburg <paul@luon.net>
|
||||
#
|
||||
|
@ -14,7 +13,6 @@ require "action_view"
|
|||
require "active_support"
|
||||
require "camping"
|
||||
require "markaby"
|
||||
require "mime/types"
|
||||
require "pathname"
|
||||
require "sass/plugin/rack"
|
||||
|
||||
|
@ -70,8 +68,28 @@ module StopTime::Models
|
|||
|
||||
# == The customer class
|
||||
#
|
||||
# This class represents a customer that has projects, tasks
|
||||
# This class represents a customer that has projects/tasks
|
||||
# for which invoices need to be generated.
|
||||
#
|
||||
# === Attributes
|
||||
#
|
||||
# [id] unique identification number (Fixnum)
|
||||
# [name] official (long) name (String)
|
||||
# [short_name] abbreviated name (String)
|
||||
# [address_street] street part of the address (String)
|
||||
# [address_postal_code] zip/postal code part of the address (String)
|
||||
# [address_city] city part of the postal code (String)
|
||||
# [email] email address (String)
|
||||
# [phone] phone number (String)
|
||||
# [hourly_rate] default hourly rate (Float)
|
||||
# [created_at] time of creation (Time)
|
||||
# [updated_at] time of last update (Time)
|
||||
#
|
||||
# === Attributes by association
|
||||
#
|
||||
# [invoices] list of invoices (Array of Invoice)
|
||||
# [tasks] list of tasks (Array of Task)
|
||||
# [time_entries] list of time entries (Array of TimeEntry)
|
||||
class Customer < Base
|
||||
has_many :tasks
|
||||
has_many :invoices
|
||||
|
@ -79,7 +97,7 @@ module StopTime::Models
|
|||
|
||||
# Returns a list of tasks that have not been billed via in invoice.
|
||||
def unbilled_tasks
|
||||
tasks.all(:conditions => ["invoice_id IS NULL"])
|
||||
tasks.all(:conditions => ["invoice_id IS NULL"], :order => "name ASC")
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -88,6 +106,22 @@ module StopTime::Models
|
|||
# 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.
|
||||
#
|
||||
# === Attributes
|
||||
#
|
||||
# [id] unique identification number (Fixnum)
|
||||
# [name] description (String)
|
||||
# [fixed_cost] fixed cost of the task (Float)
|
||||
# [hourly_rate] hourly rate for the task (Float)
|
||||
# [invoice_comment] extra comment for the invoice (String)
|
||||
# [created_at] time of creation (Time)
|
||||
# [updated_at] time of last update (Time)
|
||||
#
|
||||
# === Attributes by association
|
||||
#
|
||||
# [customer] associated customer (Customer)
|
||||
# [invoice] associated invoice if the task is billed (Invoice)
|
||||
# [time_entries] list of registered time entries (Array of TimeEntry)
|
||||
class Task < Base
|
||||
has_many :time_entries
|
||||
belongs_to :customer
|
||||
|
@ -141,7 +175,8 @@ module StopTime::Models
|
|||
def summary
|
||||
case type
|
||||
when "fixed_cost"
|
||||
[nil, nil, fixed_cost]
|
||||
total = time_entries.inject(0.0) { |summ, te| summ + te.hours_total }
|
||||
[total, nil, fixed_cost]
|
||||
when "hourly_rate"
|
||||
time_entries.inject([0.0, hourly_rate, 0.0]) do |summ, te|
|
||||
summ[0] += te.hours_total
|
||||
|
@ -150,12 +185,38 @@ module StopTime::Models
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Returns an invoice comment if the task is billed and if it is
|
||||
# set, otherwise the name.
|
||||
def comment_or_name
|
||||
if billed? and self.invoice_comment.present?
|
||||
self.invoice_comment
|
||||
else
|
||||
self.name
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# == The time entry class
|
||||
#
|
||||
# This class represents an amount of time that is registered on a certain
|
||||
# task.
|
||||
#
|
||||
# === Attributes
|
||||
#
|
||||
# [id] unique identification number (Fixnum)
|
||||
# [date] date of the entry (Time)
|
||||
# [start] start time of the entry (Time)
|
||||
# [end] finish time of the entry (Time)
|
||||
# [bill] flag whether to bill or not (FalseClass/TrueClass)
|
||||
# [comment] additional comment (String)
|
||||
# [created_at] time of creation (Time)
|
||||
# [updated_at] time of last update (Time)
|
||||
#
|
||||
# === Attributes by association
|
||||
#
|
||||
# [task] task the entry registers time for (Task)
|
||||
# [customer] associated customer (Customer)
|
||||
class TimeEntry < Base
|
||||
belongs_to :task
|
||||
has_one :customer, :through => :task
|
||||
|
@ -170,20 +231,35 @@ module StopTime::Models
|
|||
#
|
||||
# This class represents an invoice for a customer that contains billed
|
||||
# tasks and through the tasks registered time.
|
||||
#
|
||||
# === Attributes
|
||||
#
|
||||
# [id] unique identification number (Fixnum)
|
||||
# [number] invoice number (Fixnum)
|
||||
# [paid] flag whether the invoice has been paid (TrueClass/FalseClass)
|
||||
# [created_at] time of creation (Time)
|
||||
# [updated_at] time of last update (Time)
|
||||
#
|
||||
# === Attributes by association
|
||||
#
|
||||
# [customer] associated customer (Customer)
|
||||
# [tasks] billed tasks by the invoice (Array of Task)
|
||||
# [time_entries] billed time entries (Array of TimeEntry)
|
||||
class Invoice < Base
|
||||
has_many :tasks
|
||||
has_many :time_entries, :through => :tasks
|
||||
belongs_to :customer
|
||||
|
||||
# Returns a a time and cost summary of the contained tasks.
|
||||
# See also Task#summary.
|
||||
# Returns a time and cost summary of the contained tasks (Hash of
|
||||
# Task to Array).
|
||||
# See also Task#summary for the specification of the array.
|
||||
def summary
|
||||
summ = {}
|
||||
tasks.each { |task| summ[task.name] = task.summary }
|
||||
tasks.each { |task| summ[task] = task.summary }
|
||||
return summ
|
||||
end
|
||||
|
||||
# Returns the invoice period based on the contained tasks.
|
||||
# Returns the invoice period based on the contained tasks (Array of Time).
|
||||
# See also Task#bill_period.
|
||||
def period
|
||||
# FIXME: maybe should be updated_at?
|
||||
|
@ -202,6 +278,27 @@ module StopTime::Models
|
|||
#
|
||||
# This class contains information about the company or sole
|
||||
# proprietorship of the user of Stop… Camping Time!
|
||||
#
|
||||
# === Attributes
|
||||
#
|
||||
# [id] unique identification number (Fixnum)
|
||||
# [name] official company name (String)
|
||||
# [contact_name] optional personal contact name (String)
|
||||
# [address_street] street part of the address (String)
|
||||
# [address_postal_code] zip/postal code part of the address (String)
|
||||
# [address_city] city part of the postal code (String)
|
||||
# [country] country of residence (String)
|
||||
# [country_code] two letter country code (String)
|
||||
# [email] email address (String)
|
||||
# [phone] phone number (String)
|
||||
# [cell] cellular phone number (String)
|
||||
# [website] web address (String)
|
||||
# [chamber] optional chamber of commerce ID number (String)
|
||||
# [vatno] optional VAT number (String)
|
||||
# [accountname] name of the bank account holder (String)
|
||||
# [accountno] number of the bank account (String)
|
||||
# [created_at] time of creation (Time)
|
||||
# [updated_at] time of last update (Time)
|
||||
class CompanyInfo < Base
|
||||
end
|
||||
|
||||
|
@ -343,6 +440,36 @@ module StopTime::Models
|
|||
end
|
||||
end
|
||||
|
||||
class PaidFlagTypoFix < V 1.9 # :nodoc:
|
||||
def self.up
|
||||
add_column(Invoice.table_name, :paid, :boolean)
|
||||
Invoice.all.each do |i|
|
||||
i.paid = i.payed unless i.payed.blank?
|
||||
i.save
|
||||
end
|
||||
remove_column(Invoice.table_name, :payed)
|
||||
end
|
||||
|
||||
def self.down
|
||||
add_column(Invoice.table_name, :payed, :boolean)
|
||||
Invoice.all.each do |i|
|
||||
i.payed = i.paid unless i.paid.blank?
|
||||
i.save
|
||||
end
|
||||
remove_column(Invoice.table_name, :paid)
|
||||
end
|
||||
end
|
||||
|
||||
class InvoiceCommentsSupport < V 1.91 # :nodoc:
|
||||
def self.up
|
||||
add_column(Task.table_name, :invoice_comment, :string)
|
||||
end
|
||||
|
||||
def self.down
|
||||
remove_column(Task.table_name, :invoice_comment)
|
||||
end
|
||||
end
|
||||
|
||||
end # StopTime::Models
|
||||
|
||||
# = The Stop… Camping Time! controllers
|
||||
|
@ -359,7 +486,7 @@ module StopTime::Controllers
|
|||
def get
|
||||
@tasks = {}
|
||||
Customer.all.each do |customer|
|
||||
@tasks[customer] = customer.unbilled_tasks
|
||||
@tasks[customer] = customer.unbilled_tasks.sort_by { |t| t.name }
|
||||
end
|
||||
render :overview
|
||||
end
|
||||
|
@ -375,7 +502,7 @@ module StopTime::Controllers
|
|||
class Customers
|
||||
# Gets the list of customers and displays them via Views#customers.
|
||||
def get
|
||||
@customers = Customer.all
|
||||
@customers = Customer.all(:order => "name ASC")
|
||||
render :customers
|
||||
end
|
||||
|
||||
|
@ -507,6 +634,9 @@ module StopTime::Controllers
|
|||
if @task.invalid?
|
||||
@errors = @task.errors
|
||||
@customer = Customer.find(customer_id)
|
||||
@customer_list = Customer.all.map do |c|
|
||||
[c.id, c.short_name.present? ? c.short_name : c.name]
|
||||
end
|
||||
@target = [CustomersNTasks, customer_id]
|
||||
@method = "create"
|
||||
return render :task_form
|
||||
|
@ -528,9 +658,13 @@ module StopTime::Controllers
|
|||
# for a customer with the given _customer_id_ using Views#task_form.
|
||||
def get(customer_id)
|
||||
@customer = Customer.find(customer_id)
|
||||
@customer_list = Customer.all.map do |c|
|
||||
[c.id, c.short_name.present? ? c.short_name : c.name]
|
||||
end
|
||||
@task = Task.new(:hourly_rate => @customer.hourly_rate)
|
||||
@input = @task.attributes
|
||||
@input["type"] = @task.type # FIXME: find nicer way!
|
||||
@input["customer"] = @customer.id
|
||||
|
||||
@target = [CustomersNTasks, customer_id]
|
||||
@method = "create"
|
||||
|
@ -551,11 +685,15 @@ module StopTime::Controllers
|
|||
# Views#task_form.
|
||||
def get(customer_id, task_id)
|
||||
@customer = Customer.find(customer_id)
|
||||
@customer_list = Customer.all.map do |c|
|
||||
[c.id, c.short_name.present? ? c.short_name : c.name]
|
||||
end
|
||||
@task = Task.find(task_id)
|
||||
@target = [CustomersNTasksN, customer_id, task_id]
|
||||
@method = "update"
|
||||
@input = @task.attributes
|
||||
@input["type"] = @task.type
|
||||
@input["customer"] = @customer.id
|
||||
# FIXME: Check that task is of that customer.
|
||||
render :task_form
|
||||
end
|
||||
|
@ -567,10 +705,9 @@ module StopTime::Controllers
|
|||
# and shown in the intial form (Views#task_form).
|
||||
def post(customer_id, task_id)
|
||||
return redirect R(CustomersN, customer_id) if @input.cancel
|
||||
@customer = Customer.find(customer_id)
|
||||
@task = Task.find(task_id)
|
||||
if @input.has_key? "update"
|
||||
# FIXME: task should be cloned/dupped as to prevent rewriting history!
|
||||
@task["customer"] = Customer.find(@input["customer"])
|
||||
@task["name"] = @input["name"] unless @input["name"].blank?
|
||||
case @input.type
|
||||
when "fixed_cost"
|
||||
|
@ -583,10 +720,12 @@ module StopTime::Controllers
|
|||
@task.save
|
||||
if @task.invalid?
|
||||
@errors = @task.errors
|
||||
@customer = Customer.find(customer_id)
|
||||
@customer_list = Customer.all.map do |c|
|
||||
[c.id, c.short_name.present? ? c.short_name : c.name]
|
||||
end
|
||||
@target = [CustomersNTasksN, customer_id, task_id]
|
||||
@method = "update"
|
||||
@input = @task.attributes
|
||||
@input["type"] = @input.type
|
||||
return render :task_form
|
||||
end
|
||||
end
|
||||
|
@ -651,13 +790,17 @@ module StopTime::Controllers
|
|||
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 fixed cost tasks.
|
||||
@input["tasks"].each do |task|
|
||||
invoice.tasks << Task.find(task)
|
||||
task = Task.find(task)
|
||||
task.invoice_comment = @input["task_#{task.id}_comment"]
|
||||
task.save
|
||||
invoice.tasks << task
|
||||
end unless @input["tasks"].blank?
|
||||
invoice.save
|
||||
|
||||
|
@ -672,7 +815,7 @@ module StopTime::Controllers
|
|||
#
|
||||
# path:: /customers/_customer_id_/invoices/_invoice_number_
|
||||
# view:: Views#invoice
|
||||
class CustomersNInvoicesX
|
||||
class CustomersNInvoicesX < R '/customers/(\d+)/invoices/([^/]+)'
|
||||
include ActionView::Helpers::NumberHelper
|
||||
include I18n
|
||||
|
||||
|
@ -716,7 +859,7 @@ module StopTime::Controllers
|
|||
# with the given _customer_id_ and redirects to CustomersNInvoicesX.
|
||||
def post(customer_id, invoice_number)
|
||||
invoice = Invoice.find_by_number(invoice_number)
|
||||
invoice.payed = @input.has_key? "payed"
|
||||
invoice.paid = @input.has_key? "paid"
|
||||
invoice.save
|
||||
|
||||
redirect R(CustomersNInvoicesX, customer_id, invoice_number)
|
||||
|
@ -791,10 +934,15 @@ module StopTime::Controllers
|
|||
@customer_list = Customer.all.map do |c|
|
||||
[c.id, c.short_name.present? ? c.short_name : c.name]
|
||||
end
|
||||
@task_list = Task.all.reject { |t| t.billed? }.map do |t|
|
||||
[t.id, t.name]
|
||||
@task_list = Hash.new { |h, k| h[k] = Array.new }
|
||||
Task.all.reject { |t| t.billed? }.each do |t|
|
||||
customer = t.customer
|
||||
cust_name = customer.short_name.present? ? customer.short_name \
|
||||
: customer.name
|
||||
@task_list[cust_name] << [t.id, t.name]
|
||||
end
|
||||
@input["bill"] = true # Bill by default.
|
||||
@input["task"] = @time_entries.first.task.id if @time_entries.present?
|
||||
render :time_entries
|
||||
end
|
||||
|
||||
|
@ -885,10 +1033,12 @@ module StopTime::Controllers
|
|||
if @input.has_key? "delete"
|
||||
@time_entry.delete
|
||||
elsif @input.has_key? "update"
|
||||
attrs = ["date", "start", "end", "comment"]
|
||||
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"
|
||||
@time_entry.save
|
||||
|
@ -982,14 +1132,14 @@ module StopTime::Controllers
|
|||
#
|
||||
# path:: /static/_path_
|
||||
# view:: N/A (X-Sendfile)
|
||||
class Static < R '/static/(.+)'
|
||||
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.
|
||||
def get(path)
|
||||
mime_type = MIME::Types.type_for(path).first
|
||||
@headers['Content-Type'] = mime_type.nil? ? "text/plain" : mime_type.to_s
|
||||
unless path.include? ".."
|
||||
@headers['X-Sendfile'] = (PUBLIC_DIR + path).to_s
|
||||
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}"
|
||||
|
@ -1007,8 +1157,10 @@ module StopTime::Views
|
|||
xhtml_strict do
|
||||
head do
|
||||
title "Stop… Camping Time!"
|
||||
# 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")
|
||||
:media => "screen",
|
||||
:href => (R(Static, "") + "stylesheets/style.css")
|
||||
end
|
||||
body do
|
||||
div.wrapper! do
|
||||
|
@ -1036,7 +1188,8 @@ module StopTime::Views
|
|||
# Partial view that generates the menu link and determines the active
|
||||
# menu item.
|
||||
def _menu_link(label, ctrl)
|
||||
if ctrl == self.helpers.class # FIXME: dirty hack?
|
||||
# FIXME: dirty hack?
|
||||
if self.helpers.class.to_s.match(/^#{ctrl.to_s}/)
|
||||
li.selected { a label, :href => R(ctrl) }
|
||||
else
|
||||
li { a label, :href => R(ctrl) }
|
||||
|
@ -1049,17 +1202,16 @@ module StopTime::Views
|
|||
|
||||
if @tasks.empty?
|
||||
p do
|
||||
text "No customers, projects or tasks found! Set them up "
|
||||
a "here", :href => R(CustomersNew)
|
||||
text "."
|
||||
"No customers, projects or tasks found! Set them up " +
|
||||
"#{a "here", :href => R(CustomersNew)}."
|
||||
end
|
||||
else
|
||||
@tasks.keys.sort_by { |c| c.name }.each do |customer|
|
||||
h3 { a customer.name, :href => R(CustomersN, customer.id) }
|
||||
if @tasks[customer].empty?
|
||||
p do
|
||||
text "No projects/tasks found! Create one "
|
||||
a "here", :href => R(CustomersNTasksNew, customer.id)
|
||||
text "No projects/tasks found! Create one " +
|
||||
"#{a "here", :href => R(CustomersNTasksNew, customer.id)}."
|
||||
end
|
||||
else
|
||||
table.overview do
|
||||
|
@ -1068,19 +1220,14 @@ module StopTime::Views
|
|||
col.hours {}
|
||||
col.amount {}
|
||||
tr do
|
||||
summary = task.summary
|
||||
td do
|
||||
a task.name,
|
||||
:href => R(CustomersNTasksN, customer.id, task.id)
|
||||
end
|
||||
summary = task.summary
|
||||
case task.type
|
||||
when "fixed_rate"
|
||||
td ""
|
||||
td.right { "€ %.2f" % summary[2] }
|
||||
when "hourly_rate"
|
||||
td.right { "%.2fh" % summary[0] }
|
||||
td.right { "€ %.2f" % summary[2] }
|
||||
end
|
||||
td.right { "%.2fh" % summary[0] }
|
||||
td.right { "€ %.2f" % summary[2] }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
@ -1108,10 +1255,11 @@ module StopTime::Views
|
|||
th "Comment"
|
||||
th "Total time"
|
||||
th "Bill?"
|
||||
th {}
|
||||
end
|
||||
form :action => R(Timeline), :method => :post do
|
||||
tr do
|
||||
td { _form_select("task", @task_list) }
|
||||
td { _form_select_nested("task", @task_list) }
|
||||
td { input :type => :text, :name => "date",
|
||||
:value => DateTime.now.to_date.to_formatted_s }
|
||||
td { input :type => :text, :name => "start",
|
||||
|
@ -1127,7 +1275,7 @@ module StopTime::Views
|
|||
end
|
||||
end
|
||||
@time_entries.each do |entry|
|
||||
tr do
|
||||
tr(:class => entry.task.billed? ? "billed" : nil) do
|
||||
td { a entry.task.name,
|
||||
:href => R(CustomersNTasksN, entry.customer.id, entry.task.id) }
|
||||
td { a entry.date.to_date,
|
||||
|
@ -1187,9 +1335,8 @@ module StopTime::Views
|
|||
h2 "Customers"
|
||||
if @customers.empty?
|
||||
p do
|
||||
text "None found! You can create one "
|
||||
a "here", :href => R(CustomersNew)
|
||||
text "."
|
||||
text "None found! You can create one " +
|
||||
"#{a "here", :href => R(CustomersNew)}."
|
||||
end
|
||||
else
|
||||
table.customers do
|
||||
|
@ -1204,6 +1351,7 @@ module StopTime::Views
|
|||
th "Address"
|
||||
th "Email"
|
||||
th "Phone"
|
||||
th {}
|
||||
end
|
||||
@customers.each do |customer|
|
||||
tr do
|
||||
|
@ -1223,7 +1371,7 @@ module StopTime::Views
|
|||
end
|
||||
end
|
||||
|
||||
a "Add a new customer", :href=> R(CustomersNew)
|
||||
a "» Add a new customer", :href=> R(CustomersNew)
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -1263,14 +1411,14 @@ module StopTime::Views
|
|||
div do
|
||||
input :type => :submit, :name => "edit", :value => "Edit"
|
||||
input :type => :submit, :name => "delete", :value => "Delete"
|
||||
a "Add a new project/task", :href => R(CustomersNTasksNew, @customer.id)
|
||||
a "» Add a new project/task", :href => R(CustomersNTasksNew, @customer.id)
|
||||
end
|
||||
end
|
||||
|
||||
div.clear do
|
||||
h2 "Invoices"
|
||||
_invoice_list(@invoices)
|
||||
a "Create a new invoice", :href => R(CustomersNInvoicesNew, @customer.id)
|
||||
a "» Create a new invoice", :href => R(CustomersNInvoicesNew, @customer.id)
|
||||
end
|
||||
end
|
||||
div.clear {}
|
||||
|
@ -1290,7 +1438,7 @@ module StopTime::Views
|
|||
th "Number"
|
||||
th "Date"
|
||||
th "Period"
|
||||
th "Payed"
|
||||
th "Paid?"
|
||||
end
|
||||
invoices.each do |invoice|
|
||||
tr do
|
||||
|
@ -1301,8 +1449,8 @@ module StopTime::Views
|
|||
end
|
||||
td { invoice.created_at.to_formatted_s(:date_only) }
|
||||
td { _format_period(invoice.period) }
|
||||
# FIXME: really retrieve the payed flag.
|
||||
td { _form_input_checkbox("payed_#{invoice.number}") }
|
||||
# FIXME: really retrieve the paid flag.
|
||||
td { _form_input_checkbox("paid_#{invoice.number}") }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
@ -1323,6 +1471,10 @@ module StopTime::Views
|
|||
h2 "Task Information"
|
||||
form :action => R(*@target), :method => :post do
|
||||
ol do
|
||||
li do
|
||||
label "Customer", :for => "customer"
|
||||
_form_select("customer", @customer_list)
|
||||
end
|
||||
li { _form_input_with_label("Name", "name", :text) }
|
||||
li do
|
||||
label "Project/Task type"
|
||||
|
@ -1351,8 +1503,7 @@ module StopTime::Views
|
|||
if @invoices.values.flatten.empty?
|
||||
p do
|
||||
text "Found none! You can create one by "
|
||||
a "selecting a customer", :href => R(Customers)
|
||||
text "."
|
||||
"#{a "selecting a customer", :href => R(Customers)}."
|
||||
end
|
||||
else
|
||||
@invoices.keys.sort.each do |key|
|
||||
|
@ -1364,7 +1515,7 @@ module StopTime::Views
|
|||
end
|
||||
|
||||
# A view displaying the information (billed tasks and time) of an
|
||||
# invoice (Models::Invoice) that also allows for updating the "+payed+"
|
||||
# invoice (Models::Invoice) that also allows for updating the "+paid+"
|
||||
# property.
|
||||
def invoice
|
||||
h2 do
|
||||
|
@ -1388,9 +1539,9 @@ module StopTime::Views
|
|||
td.val { _format_period(@invoice.period) }
|
||||
end
|
||||
tr do
|
||||
td.key { b "Payed" }
|
||||
td.key { b "Paid?" }
|
||||
td.val do
|
||||
_form_input_checkbox("payed")
|
||||
_form_input_checkbox("paid")
|
||||
input :type => :submit, :name => "update", :value => "Update"
|
||||
input :type => :reset, :name => "reset", :value => "Reset"
|
||||
end
|
||||
|
@ -1412,8 +1563,10 @@ module StopTime::Views
|
|||
subtotal = 0.0
|
||||
@tasks.each do |task, line|
|
||||
tr do
|
||||
td { task }
|
||||
if line[0].nil? and line[1].nil?
|
||||
td { task.comment_or_name }
|
||||
if line[1].nil?
|
||||
# FIXME: information of time spent is available in the summary
|
||||
# but show it?
|
||||
td.right "–"
|
||||
td.right "–"
|
||||
else
|
||||
|
@ -1449,19 +1602,20 @@ module StopTime::Views
|
|||
end
|
||||
end
|
||||
|
||||
a "Download PDF",
|
||||
a "» Download PDF",
|
||||
:href => R(CustomersNInvoicesX, @customer.id, "#{@invoice.number}.pdf")
|
||||
a "Download Latex source",
|
||||
a "» Download LaTeX source",
|
||||
:href => R(CustomersNInvoicesX, @customer.id, "#{@invoice.number}.tex")
|
||||
end
|
||||
|
||||
# Form for selecting fixed cost tasks and registered time for tasks with
|
||||
# an hourly rate that need to be billed.
|
||||
def invoice_select_form
|
||||
h2 "Registered Time"
|
||||
form :action => R(CustomersNInvoices, @customer.id), :method => :post do
|
||||
h3 "Projects/Tasks with an Hourly Rate"
|
||||
unless @hourly_rate_tasks.empty?
|
||||
h2 "Registered Time"
|
||||
table.time_entries do
|
||||
table.invoice_select do
|
||||
col.flag {}
|
||||
col.date {}
|
||||
col.start_time {}
|
||||
|
@ -1470,7 +1624,7 @@ module StopTime::Views
|
|||
col.hours {}
|
||||
col.amount {}
|
||||
tr do
|
||||
th ""
|
||||
th "Bill?"
|
||||
th "Date"
|
||||
th "Start time"
|
||||
th "End time"
|
||||
|
@ -1481,15 +1635,19 @@ module StopTime::Views
|
|||
@hourly_rate_tasks.keys.each do |task|
|
||||
tr.task do
|
||||
td { _form_input_checkbox("tasks[]", task.id) }
|
||||
td task.name, :colspan => 5
|
||||
td task.name, :colspan => 3
|
||||
td do
|
||||
input :type => :text, :name => "task_#{task.id}_comment",
|
||||
:id => "tasks_#{task.id}_comment", :value => task.name
|
||||
end
|
||||
end
|
||||
@hourly_rate_tasks[task].each do |entry|
|
||||
tr do
|
||||
td { _form_input_checkbox("time_entries[]", entry.id) }
|
||||
td.indent { _form_input_checkbox("time_entries[]", entry.id) }
|
||||
td { label entry.date.to_date,
|
||||
:for => "time_entries[]_#{entry.id}" }
|
||||
td { entry.start }
|
||||
td { entry.end }
|
||||
td { entry.start.to_formatted_s(:time_only) }
|
||||
td { entry.end.to_formatted_s(:time_only) }
|
||||
td { entry.comment }
|
||||
td.right { "%.2fh" % entry.hours_total }
|
||||
td.right { "€ %.2f" % (entry.hours_total * entry.task.hourly_rate) }
|
||||
|
@ -1500,22 +1658,28 @@ module StopTime::Views
|
|||
end
|
||||
|
||||
unless @fixed_cost_tasks.empty?
|
||||
h2 "Fixed Cost Projects/Tasks"
|
||||
h3 "Fixed Cost Projects/Tasks"
|
||||
table.tasks do
|
||||
col.flag {}
|
||||
col.task {}
|
||||
col.comment {}
|
||||
col.hours {}
|
||||
col.amount {}
|
||||
tr do
|
||||
th ""
|
||||
th "Bill?"
|
||||
th "Project/Task"
|
||||
th "Registered time"
|
||||
th "Amount"
|
||||
th "Comment"
|
||||
th.right "Registered time"
|
||||
th.right "Amount"
|
||||
end
|
||||
@fixed_cost_tasks.keys.each do |task|
|
||||
tr do
|
||||
td { _form_input_checkbox("tasks[]", task.id) }
|
||||
td { label task.name, :for => "tasks[]_#{task.id}" }
|
||||
td do
|
||||
input :type => :text, :name => "task_#{task.id}_comment",
|
||||
:id => "tasks_#{task.id}_comment", :value => task.name
|
||||
end
|
||||
td.right { "%.2fh" % @fixed_cost_tasks[task] }
|
||||
td.right { task.fixed_cost }
|
||||
end
|
||||
|
@ -1604,7 +1768,7 @@ module StopTime::Views
|
|||
# The option list is an Array of a 2-valued array containg a value label
|
||||
# and a human readable description for the value.
|
||||
def _form_select(name, opts_list)
|
||||
if opts_list.empty?
|
||||
if opts_list.blank?
|
||||
select :name => name, :id => name, :disabled => true do
|
||||
option "None found", :value => "none", :selected => true
|
||||
end
|
||||
|
@ -1621,4 +1785,34 @@ module StopTime::Views
|
|||
end
|
||||
end
|
||||
|
||||
# Partial view similar to Views#_form_select that generates a select element
|
||||
# for a form with a field (and ID) _name_ and hash of _opts_.
|
||||
# The hash _opts_ represents a subdivision of the options, where the key
|
||||
# is the name of the subdivision and the value the options list as in
|
||||
# Views#_form_select.
|
||||
#
|
||||
# The option list is an Hash of Strings mapping to an Array of a 2-valued
|
||||
# array containg a value label and a human readable description for the
|
||||
# value.
|
||||
def _form_select_nested(name, opts)
|
||||
if opts.blank?
|
||||
select :name => name, :id => name, :disabled => true do
|
||||
option "None found", :value => "none", :selected => true
|
||||
end
|
||||
else
|
||||
select :name => name, :id => name do
|
||||
opts.keys.sort.each do |key|
|
||||
option "— #{key} —", :disabled => true
|
||||
opts[key].sort_by { |o| o.last }.each do |opt_val, opt_str|
|
||||
if @input[name] == opt_val
|
||||
option opt_str, :value => opt_val, :selected => true
|
||||
else
|
||||
option opt_str, :value => opt_val
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
end # module StopTime::Views
|
||||
|
|
|
@ -91,11 +91,11 @@
|
|||
\begin{ihtable}
|
||||
<% subtotal = 0.0
|
||||
@tasks.each do |task, line|
|
||||
if line[0].nil? and line[1].nil?
|
||||
%> \ifcitem{<%= task %>}%
|
||||
if line[2].nil?
|
||||
%> \ifcitem{<%= task.comment_or_name %>}%
|
||||
{<%= number_with_precision(line[2]) %>}<%
|
||||
else
|
||||
%> \ihitem{<%= task %>}%
|
||||
%> \ihitem{<%= task.comment_or_name %>}%
|
||||
{<%= number_with_precision(line[0]) %>}{<%= number_with_precision(line[1]) %>}%
|
||||
{<%= number_with_precision(line[2]) %>}<%
|
||||
end
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
// Colours
|
||||
$light-grey: #efefef
|
||||
$medium-grey: #cfcfcf
|
||||
$dark-grey: #9f9f9f
|
||||
$dark-red: #990000
|
||||
|
||||
|
@ -17,6 +18,12 @@ $dark-red: #990000
|
|||
clear: both
|
||||
padding-top: 10px
|
||||
|
||||
.billed
|
||||
text-decoration: line-through
|
||||
|
||||
.indent
|
||||
padding-left: 20px
|
||||
|
||||
/* Basic elements */
|
||||
a
|
||||
text-decoration: none
|
||||
|
@ -30,6 +37,9 @@ body
|
|||
font-size: 13px
|
||||
margin: 0px
|
||||
|
||||
h3
|
||||
margin: 10px 0px
|
||||
|
||||
/* Main layout */
|
||||
#wrapper
|
||||
width: auto
|
||||
|
@ -65,9 +75,9 @@ table
|
|||
col.amount, col.hours, col.hourly_rate
|
||||
width: 10%
|
||||
col.flag
|
||||
width: 4%
|
||||
width: 3%
|
||||
col.date
|
||||
width: 8%
|
||||
width: 9%
|
||||
col.start_time, col.end_time
|
||||
width: 5%
|
||||
col.comment
|
||||
|
@ -94,7 +104,7 @@ table
|
|||
&.tasks
|
||||
width: 60%
|
||||
|
||||
&.customers
|
||||
&.customers, &.invoice_select
|
||||
width: 75%
|
||||
|
||||
&.invoices
|
||||
|
@ -109,8 +119,12 @@ table
|
|||
text-align: left
|
||||
background: $dark-grey
|
||||
|
||||
tr.total
|
||||
border-top: solid black
|
||||
tr
|
||||
&:hover
|
||||
background-color: $medium-grey
|
||||
|
||||
&.total
|
||||
border-top: solid black
|
||||
|
||||
td.key
|
||||
font-weight: bold
|
||||
|
@ -120,7 +134,7 @@ table
|
|||
input
|
||||
width: 100%
|
||||
|
||||
input[type="submit"], input[type="reset"]
|
||||
input[type="submit"], input[type="reset"], input[type="checkbox"]
|
||||
width: auto
|
||||
|
||||
/* Form layout */
|
||||
|
|
Loading…
Reference in New Issue