stoptime/stoptime.rb

940 lines
26 KiB
Ruby
Raw Normal View History

#!/usr/bin/env camping
#
# stoptime.rb - The Stop… Camping Time! time registration and invoice
# 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 "active_support"
require "camping"
require "markaby"
require "mime/types"
require "pathname"
Markaby::Builder.set(:indent, 2)
Camping.goes :StopTime
2011-11-03 11:39:58 +01:00
unless defined? PUBLIC_DIR
PUBLIC_DIR = Pathname.new(__FILE__).dirname.expand_path + "public"
TEMPLATE_DIR = Pathname.new(__FILE__).dirname.expand_path + "templates"
# Set the default date(/time) format.
ActiveSupport::CoreExtensions::Time::Conversions::DATE_FORMATS.merge!(
:default => "%Y-%m-%d %H:%M",
:month_and_year => "%B %Y",
:month_code => "%Y%m",
:day_code => "%Y%m%d")
ActiveSupport::CoreExtensions::Date::Conversions::DATE_FORMATS.merge!(
:default => "%Y-%m-%d",
:month_and_year => "%B %Y")
# FIXME: this should be configurable.
HourlyRate = 20.0
VATRate = 19.0
end
module StopTime
def self.create
StopTime::Models.create_schema
end
end
module StopTime::Models
class Customer < Base
has_many :tasks
has_many :time_entries, :through => :tasks
end
class Task < Base
has_many :time_entries
belongs_to :customer
def fixed_cost?
not self.fixed_cost.blank?
end
def task_type
fixed_cost? ? "fixed_cost" : "hourly_rate"
end
end
class TimeEntry < Base
belongs_to :task
belongs_to :invoice
has_one :customer, :through => :task
def total
(self.end - self.start) / 1.hour
end
end
class Invoice < Base
has_many :time_entries
belongs_to :customer
def summary
# FIXME: ensure that month is a DateTime/Time object.
time_entries = self.time_entries.all
tasks = time_entries.inject({}) do |tasks, entry|
time = entry.total
if tasks.has_key? entry.task
tasks[entry.task][0] += time
tasks[entry.task][2] += time * entry.task.hourly_rate
else
tasks[entry.task] = [time, entry.task.hourly_rate,
time * entry.task.hourly_rate]
end
tasks
end
return tasks
end
end
class CompanyInfo < Base
end
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
2011-11-03 22:23:50 +01:00
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
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
class HourlyRateSupport < V 1.3
def self.up
add_column(Customer.table_name, :hourly_rate, :float,
:null => false, :default => HourlyRate)
end
def self.down
remove_column(Customer.table_name, :hourly_rate)
end
end
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
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
add_column(TimeEntry.table_name, :invoice_id, :integer)
end
def self.down
drop_table Invoice.table_name
remove_column(TimeEntry.table_name, :invoice_id)
end
end
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
2011-11-03 22:23:50 +01:00
end # StopTime::Models
module StopTime::Controllers
class Index
def get
redirect R(Timereg)
end
end
2011-10-31 16:14:54 +01:00
class Customers
def get
@customers = Customer.all
render :customers
end
def post
return redirect R(Customers) if @input.cancel
@customer = Customer.create(
:name => @input.name,
:short_name => @input.short_name,
:address_street => @input.address_street,
:address_postal_code => @input.address_postal_code,
:address_city => @input.address_city,
:email => @input.email,
:phone => @input.phone,
:hourly_rate => @input.hourly_rate)
2011-10-31 16:14:54 +01:00
@customer.save
if @customer.invalid?
@errors = @customer.errors
return render :customer_form
2011-10-31 16:14:54 +01:00
end
redirect R(Customers)
end
end
class CustomersNew
def get
# FIXME: set other defaults?
@customer = Customer.new(:hourly_rate => HourlyRate)
@target = [Customers]
render :customer_form
end
end
class CustomersN
2011-10-31 16:14:54 +01:00
def get(customer_id)
@customer = Customer.find(customer_id)
@edit_task = true
@target = [CustomersN, @customer.id]
@input = @customer.attributes
render :customer_form
2011-10-31 16:14:54 +01:00
end
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",
"address_street", "address_postal_code", "address_city",
"email", "phone", "hourly_rate"]
attrs.each do |attr|
@customer[attr] = @input[attr]
end
@customer.save
if @customer.invalid?
@errors = @customer.errors
return render :customer_form
end
end
redirect R(Customers)
2011-10-31 16:14:54 +01:00
end
end
class CustomersNTasks
def post(customer_id)
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.task_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.save
if @task.invalid?
@errors = @task.errors
@customer = Customer.find(customer_id)
@target = [CustomersNTasks, customer_id]
@method = "create"
return render :task_form
end
end
redirect R(CustomersN, customer_id)
end
end
class CustomersNTasksNew
def get(customer_id)
@customer = Customer.find(customer_id)
@task = Task.new(:hourly_rate => @customer.hourly_rate)
@target = [CustomersNTasks, customer_id]
@method = "create"
@input = @task.attributes
@input["task_type"] = @task.task_type # FIXME: find nicer way!
render :task_form
end
end
class CustomersNTasksN
def get(customer_id, task_id)
@customer = Customer.find(customer_id)
@task = Task.find(task_id)
@target = [CustomersNTasksN, customer_id, task_id]
@method = "update"
@input = @task.attributes
@input["task_type"] = @task.task_type
# FIXME: Check that task is of that customer.
render :task_form
end
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["name"] = @input["name"] unless @input["name"].blank?
case @input.task_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["billed"] = @input.has_key? "billed"
@task.save
if @task.invalid?
@errors = @task.errors
@target = [CustomersNTasksN, customer_id, task_id]
@method = "update"
@input = @task.attributes
@input["task_type"] = @input.task_type
return render :task_form
end
end
redirect R(CustomersN, customer_id)
2011-10-31 16:14:54 +01:00
end
end
class CustomersNInvoices
def post(customer_id)
return redirect R(CustomersN, customer_id) if @input.cancel
# Create the invoice.
last_id = Invoice.last ? Invoice.last.id : 0
number = ("%d%02d" % [Time.now.year, last_id + 1])
invoice = Invoice.create(:number => number)
invoice.time_entry_ids = @input["entries"]
@input["tasks"].each do |task|
task = Task.find(task)
task.billed = true
task.save
invoice.time_entries << task.time_entries
end
invoice.save
redirect R(CustomersNInvoicesX, customer_id, number)
end
end
class CustomersNInvoicesX
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)
@company = CompanyInfo.first
@customer = Customer.find(customer_id)
@tasks = @invoice.summary
# FIXME: dirty hack!
@month = @invoice.time_entries.first.start
if @format == "html"
render :invoice
elsif @format == "pdf"
pdf_file = PUBLIC_DIR + "#{@number}.pdf"
unless pdf_file.exist?
_generate_invoice_pdf(@customer, @tasks, @number)
end
redirect(StaticX, pdf_file.basename)
end
end
def _generate_invoice_pdf(customer, tasks, number)
template = TEMPLATE_DIR + "invoice.tex.erb"
tex_file = PUBLIC_DIR + "#{number}.tex"
erb = ERB.new(File.read(template))
File.open(tex_file, "w") { |f| f.write(erb.result(binding)) }
system("rubber --pdf --inplace #{tex_file}")
system("rubber --clean --inplace #{tex_file}")
end
end
class CustomersNInvoicesNew
def get(customer_id)
@customer = Customer.find(customer_id)
@entries = @customer.time_entries.all(:order => "start ASC",
:conditions => ["invoice_id IS NULL"])
@fixed_cost_tasks = @customer.tasks.all(:order => "updated_at ASC",
:conditions => ["fixed_cost IS NOT NULL AND billed = ?", 'f'])
p @entries, @fixed_cost_tasks
render :invoice_select_form
end
end
class Timereg
2011-10-31 16:14:54 +01:00
def get
2011-11-07 17:44:58 +01:00
@entries = TimeEntry.all(:order => "start DESC")
@customer_list = Customer.all.map { |c| [c.id, c.short_name] }
@task_list = Task.all.map { |t| [t.id, t.name] }
2011-11-07 17:44:58 +01:00
@input["bill"] = true # Bill by default.
render :time_entries
2011-10-31 16:14:54 +01:00
end
def post
if @input.has_key? "enter"
@entry = TimeEntry.create(
:task_id => @input.task,
:start => @input.start,
2011-11-03 22:23:50 +01:00
:end => @input.end,
:comment => @input.comment,
:bill => @input.has_key?("bill"))
@entry.save
if @entry.invalid?
@errors = @entry.errors
end
elsif @input.has_key? "delete"
end
2011-11-07 17:44:58 +01:00
@entries = TimeEntry.all(:order => "start DESC")
@customer_list = Customer.all.map { |c| [c.id, c.short_name] }
@task_list = Task.all.map { |t| [t.id, t.name] }
2011-10-31 16:14:54 +01:00
render :time_entries
end
end
class TimeregN
2011-11-07 17:44:58 +01:00
def get(entry_id)
@entry = TimeEntry.find(entry_id)
@input = @entry.attributes
@input["customer"] = @entry.task.customer.id
@input["task"] = @entry.task.id
@customer_list = Customer.all.map { |c| [c.id, c.short_name] }
@task_list = Task.all.map { |t| [t.id, t.name] }
render :time_entry_form
end
def post(entry_id)
2011-11-07 17:44:58 +01:00
return redirect R(Timereg) if @input.cancel
@entry = TimeEntry.find(entry_id)
if @input.has_key? "delete"
@entry.delete
elsif @input.has_key? "update"
attrs = ["start", "end", "comment"]
attrs.each do |attr|
@entry[attr] = @input[attr]
end
@entry.task = Task.find(@input.task)
@entry.bill = @input.has_key? "bill"
@entry.save
if @entry.invalid?
@errors = @entry.errors
return render :time_entry_form
end
end
redirect R(Timereg)
end
end
class Invoices
def get
# FIXME: set up a new overview per month/year
render :invoices
end
end
class Company
def get
@company = CompanyInfo.first
@input = @company.attributes
render :company_form
end
def post
@company = CompanyInfo.first
attrs = ["name", "contact_name",
"address_street", "address_postal_code", "address_city",
"country", "country_code",
"phone", "cell", "email", "website",
"chamber", "vatno", "accountname", "accountno"]
attrs.each do |attr|
@company[attr] = @input[attr]
end
@company.save
if @company.invalid?
@errors = @company.errors
end
render :company_form
end
end
class StaticX
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? ".."
2011-11-03 11:39:58 +01:00
@headers['X-Sendfile'] = (PUBLIC_DIR + path).to_s
else
@status = "403"
"Error 403: Invalid path: #{path}"
end
end
end
end # module StopTime::Controllers
module StopTime::Views
def layout
xhtml_strict do
head do
title "Stop… Camping Time!"
end
body do
div.wrapper! do
h1 "Stop… Camping Time!"
_menu
div.content! do
self << yield
end
end
end
end
end
def _menu
ol.menu! do
li { a "Overview", :href => R(Index) }
li { a "Time Registration", :href => R(Timereg) }
li { a "Customers", :href => R(Customers) }
li { a "Invoices", :href => R(Invoices) }
li { a "Company", :href => R(Company) }
end
2011-10-31 16:14:54 +01:00
end
def time_entries
h2 "List of time entries"
table do
tr do
th "Customer"
th "Project/task"
th "Start time"
th "End time"
2011-11-03 22:23:50 +01:00
th "Comment"
th "Total time"
th "Bill?"
end
form :action => R(Timereg), :method => :post do
tr do
td { _form_select("customer", @customer_list) }
td { _form_select("task", @task_list) }
td { input :type => :text, :name => "start",
:value => DateTime.now.to_date.to_formatted_s + " " }
td { input :type => :text, :name => "end",
:value => DateTime.now.to_date.to_formatted_s + " " }
2011-11-03 22:23:50 +01:00
td { input :type => :text, :name => "comment" }
td { "N/A" }
td { _form_input_checkbox("bill") }
td do
input :type => :submit, :name => "enter", :value => "Enter"
input :type => :reset, :name => "clear", :value => "Clear"
end
end
end
@entries.each do |entry|
tr do
td { a entry.customer.short_name,
:href => R(CustomersN, entry.customer.id) }
td { a entry.task.name,
:href => R(CustomersNTasksN, entry.customer.id, entry.task.id) }
td { a entry.start,
:href => R(TimeregN, entry.id) }
td { entry.end }
2011-11-03 22:23:50 +01:00
td { entry.comment }
td { "%.2fh" % entry.total }
td do
if entry.bill
input :type => "checkbox", :name => "bill_#{entry.id}",
:checked => true, :disabled => true
else
input :type => "checkbox", :name => "bill_#{entry.id}",
:disabled => true
end
end
td do
form :action => R(TimeregN, entry.id), :method => :post do
input :type => :submit, :name => "delete", :value => "Delete"
end
end
end
end
2011-10-31 16:14:54 +01:00
end
end
2011-11-07 17:44:58 +01:00
def time_entry_form
form :action => R(TimeregN, @entry.id), :method => :post do
ol do
li do
label "Customer", :for => "customer"
_form_select("customer", @customer_list)
end
li do
label "Task", :for => "task"
_form_select("task", @task_list)
end
li { _form_input_with_label("Start Time", "start", :text) }
li { _form_input_with_label("End Time", "end", :text) }
li { _form_input_with_label("Comment", "comment", :text) }
li do
_form_input_checkbox("bill")
label "Bill?", :for => "bill"
end
# FIXME: link to invoice if any
end
input :type => "submit", :name => "update", :value => "Update"
input :type => "submit", :name => "cancel", :value => "Cancel"
end
end
2011-10-31 16:14:54 +01:00
def customers
h2 "List of customers"
2011-10-31 16:14:54 +01:00
table do
tr do
th "Name"
th "Short name"
th "Address"
2011-10-31 16:14:54 +01:00
th "Email"
th "Phone"
end
@customers.each do |customer|
tr do
td { a customer.name, :href => R(CustomersN, customer.id) }
2011-10-31 16:14:54 +01:00
td { customer.short_name }
td { [customer.address_street,
customer.address_postal_code,
customer.address_city].join(", ") unless customer.address_street.blank? }
td { a customer.email, :href => "mailto:#{customer.email}" }
td { customer.phone }
td do
form :action => R(CustomersN, customer.id), :method => :post do
input :type => :submit, :name => "delete", :value => "Delete"
end
end
2011-10-31 16:14:54 +01:00
end
end
end
p do
a "Add a new customer", :href=> R(CustomersNew)
2011-10-31 16:14:54 +01:00
end
end
def customer_form
form :action => R(*@target), :method => :post do
2011-10-31 16:14:54 +01:00
ol do
li { _form_input_with_label("Name", "name", :text) }
li { _form_input_with_label("Short name", "short_name", :text) }
li { _form_input_with_label("Street address", "address_street", :text) }
li { _form_input_with_label("Postal code", "address_postal_code", :text) }
li { _form_input_with_label("City/town", "address_city", :text) }
li { _form_input_with_label("Email address", "email", :text) }
li { _form_input_with_label("Phone number", "phone", :text) }
li { _form_input_with_label("Hourly rate", "hourly_rate", :text) }
2011-10-31 16:14:54 +01:00
end
input :type => "submit", :name => "update", :value => "Update"
2011-10-31 16:14:54 +01:00
input :type => "submit", :name => "cancel", :value => "Cancel"
end
if @edit_task
# FXIME: the following is not very RESTful!
form :action => R(CustomersNTasks, @customer.id), :method => :post do
h2 "Projects & Tasks"
select :name => "task_id", :size => 6 do
@customer.tasks.each do |task|
option(:value => task.id) { task.name }
end
end
input :type => :submit, :name => "edit", :value => "Edit"
input :type => :submit, :name => "delete", :value => "Delete"
end
a "Add a new project/task", :href => R(CustomersNTasksNew, @customer.id)
a "Create a new invoice", :href => R(CustomersNInvoicesNew, @customer.id)
end
end
def task_form
form :action => R(*@target), :method => :post do
ul do
li { _form_input_with_label("Name", "name", :text) }
li do
ol.radio do
li do
_form_input_radio("task_type", "hourly_rate", default=true)
_form_input_with_label("Hourly rate", "hourly_rate", :text)
end
li do
_form_input_radio("task_type", "fixed_cost")
_form_input_with_label("Fixed cost", "fixed_cost", :text)
end
end
end
li do
_form_input_checkbox("billed")
label "Billed!", :for => "billed"
end
end
input :type => "submit", :name => @method, :value => @method.capitalize
input :type => "submit", :name => "cancel", :value => "Cancel"
end
end
def invoices
h2 "List of invoices"
p "N/A"
end
def invoice
h2 { "Invoice for #{@customer.name}, period N/A" }
table do
tr do
th { "Description" }
th { "Number of hours" }
th { "Hourly rate" }
th { "Amount" }
end
subtotal = 0.0
@tasks.each do |task, line|
tr do
td { task.name }
td { "%.2fh" % line[0] }
td { "€ %.2f" % line[1] }
td { "€ %.2f" % line[2] }
end
subtotal += line[2]
end
tr do
td { i "Sub-total" }
td ""
td ""
td { "€ %.2f" % subtotal }
end
vat = subtotal * VATRate/100
tr do
td { i "VAT #{VATRate}%" }
td ""
td ""
td { "€ %.2f" % vat }
end
tr do
td { b "Total amount" }
td ""
td ""
td { "€ %.2f" % (subtotal + vat) }
end
end
2011-10-31 16:14:54 +01:00
end
def invoice_select_form
form :action => R(CustomersNInvoices, @customer.id), :method => :post do
h2 "Registered time"
table do
tr do
th ""
th "Task"
th "Start"
th "End"
th "Comment"
th "Total"
th "Amount"
end
@entries.each do |entry|
tr do
td { _form_input_checkbox("entries[]", entry.id) }
td { entry.task.name }
td { label entry.start, :for => "entries[]_#{entry.id}" }
td { entry.end }
td { entry.comment }
td { entry.total }
td { entry.total * entry.task.hourly_rate }
end
end
end
h2 "Fixed cost tasks"
table do
tr do
th ""
th "Task"
th "Registered time"
th "Amount"
end
@fixed_cost_tasks.each do |task|
tr do
td { _form_input_checkbox("tasks[]", task.id) }
td { label task.name, :for => "tasks[]_#{task.id}" }
td "" # FIXME!
td { task.fixed_cost }
end
end
end
input :type => :submit, :name => "create", :value => "Create invoice"
input :type => "submit", :name => "cancel", :value => "Cancel"
end
end
def company_form
h2 "Company Information"
if @errors
div.form_errors do
h3 "There were #{@errors.count} errors in the form!"
ul do
@errors.each do |attrib, msg|
li "#{attrib.to_s.capitalize} #{msg}"
end
end
end
end
form :action => R(Company), :method => :post do
ol do
li { _form_input_with_label("Name", "name", :text) }
li { _form_input_with_label("Contact name", "contact_name", :text) }
li { _form_input_with_label("Street address", "address_street", :text) }
li { _form_input_with_label("Postal code", "address_postal_code", :text) }
li { _form_input_with_label("City/town", "address_city", :text) }
li { _form_input_with_label("Phone number", "phone", :text) }
li { _form_input_with_label("Cellular number", "cell", :text) }
li { _form_input_with_label("Email address", "email", :text) }
li { _form_input_with_label("Web address", "website", :text) }
li { _form_input_with_label("Chamber number", "chamber", :text) }
li { _form_input_with_label("VAT number", "vatno", :text) }
li { _form_input_with_label("Account name", "accountname", :text) }
li { _form_input_with_label("Account number", "accountno", :text) }
end
input :type => "submit", :name => "update", :value => "Update"
end
end
def _form_input_with_label(label_name, input_name, type)
2011-10-31 16:14:54 +01:00
label label_name, :for => input_name
input :type => type, :name => input_name, :id => input_name,
:value => @input[input_name]
2011-10-31 16:14:54 +01:00
end
def _form_input_radio(name, value, default=false)
input_val = @input[name]
if input_val == value or (input_val.blank? and default)
input :type => "radio", :id => "#{name}_#{value}",
:name => name, :value => value, :checked => true
else
input :type => "radio", :id => "#{name}_#{value}",
:name => name, :value => value
end
end
def _form_input_checkbox(name, value=true)
if @input[name] == value
input :type => "checkbox", :id => "#{name}_#{value}", :name => name,
:value => value, :checked => true
else
input :type => "checkbox", :id => "#{name}_#{value}", :name => name,
:value => value
end
end
def _form_select(name, opts_list)
select :name => name, :id => name do
opts_list.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 # module StopTime::Views