diff --git a/.gitignore b/.gitignore index a12f568..7763e5a 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,5 @@ db/* public/invoices/*.pdf public/invoices/*.tex public/stylesheets/style.css +templates/*_invoice.tex.erb tmp/* diff --git a/README.rdoc b/README.rdoc index cc27350..c262876 100644 --- a/README.rdoc +++ b/README.rdoc @@ -34,7 +34,7 @@ The following Ruby libraries are required: and the following LaTeX programs: * pdflatex, with: - * isodoc package + * isodoc package (>= 1.00) * rubber == Installation diff --git a/config.yaml.example b/config.yaml.example index 687778f..e2311be 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -8,3 +8,6 @@ # The invoice ID format (see strftime(3) and %N for the sequence number) #invoice_id: %Y%N + +# The invoice template used (without the .tex.erb suffix) +#invoice_template: invoice diff --git a/stoptime.rb b/stoptime.rb index 2ae3e31..278dd3d 100644 --- a/stoptime.rb +++ b/stoptime.rb @@ -137,9 +137,10 @@ module StopTime::Models # The default configuration. Note that the configuration of the root # will be merged with this configuration. - DefaultConfig = { "invoice_id" => "%Y%N", - "hourly_rate" => 20.0, - "vat_rate" => 21.0 } + DefaultConfig = { "invoice_id" => "%Y%N", + "invoice_template" => "invoice", + "hourly_rate" => 20.0, + "vat_rate" => 21.0 } # Creates a new configuration object and loads the configuation. # by reading the file @config.yaml@ on disk, parsing it, and @@ -193,6 +194,7 @@ module StopTime::Models # [email] email address (String) # [phone] phone number (String) # [hourly_rate] default hourly rate (Float) + # [time_specification] whether the customer requires time specifications (TrueClass/FalseClass) # [created_at] time of creation (Time) # [updated_at] time of last update (Time) # @@ -358,6 +360,8 @@ module StopTime::Models # [id] unique identification number (Fixnum) # [number] invoice number (Fixnum) # [paid] flag whether the invoice has been paid (TrueClass/FalseClass) + # [include_specification] flag whether the invoice should include a time + # specification (TrueClass/FalseClass) # [created_at] time of creation (Time) # [updated_at] time of last update (Time) # @@ -694,6 +698,18 @@ module StopTime::Models end end + class TimeSpecificationSupport < V 1.95 # :nodoc: + def self.up + add_column(Customer.table_name, :time_specification, :boolean) + add_column(Invoice.table_name, :include_specification, :boolean) + 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! controllers @@ -795,6 +811,28 @@ module StopTime::Controllers @customer = Customer.find(customer_id) @input = @customer.attributes @tasks = @customer.tasks.all(:order => "name, 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 + @invoices = @customer.invoices @invoices.each do |i| @input["paid_#{i.number}"] = true if i.paid? @@ -822,6 +860,7 @@ module StopTime::Controllers 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 @@ -1026,6 +1065,7 @@ module StopTime::Controllers 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. @@ -1113,6 +1153,7 @@ module StopTime::Controllers 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) @@ -1125,7 +1166,7 @@ module StopTime::Controllers # Generates a LaTex document for the invoice with the given _number_. def _generate_invoice_tex(number) - template = TEMPLATE_DIR + "invoice.tex.erb" + template = TEMPLATE_DIR + "#{@config["invoice_template"]}.tex.erb" tex_file = PUBLIC_DIR + "invoices/#{number}.tex" I18n.with_locale :nl do @@ -1133,7 +1174,7 @@ module StopTime::Controllers File.open(tex_file, "w") { |f| f.write(erb.result(binding)) } end rescue Exception => err - tex_file.delete + tex_file.delete if File.exist? tex_file raise err end @@ -1247,7 +1288,10 @@ module StopTime::Controllers # registering time. def get @customer_list = Customer.all.map { |c| [c.id, c.shortest_name] } - @task_list = Task.all.reject { |t| t.billed? }.map { |t| [t.id, t.name] } + @task_list = Hash.new { |h, k| h[k] = Array.new } + Task.all.reject { |t| t.billed? }.each do |t| + @task_list[t.customer.shortest_name] << [t.id, t.name] + end @input["bill"] = true @input["date"] = DateTime.now.to_date @input["start"] = Time.now.to_formatted_s(:time_only) @@ -1276,9 +1320,10 @@ module StopTime::Controllers @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 = Task.all(:order => "name, invoice_id ASC").map do |t| + @task_list = Hash.new { |h, k| h[k] = Array.new } + Task.all(:order => "name, invoice_id ASC").each do |t| name = t.billed? ? t.name + " (#{t.invoice.number})" : t.name - [t.id, name] + @task_list[t.customer.shortest_name] << [t.id, name] end @target = [TimelineN, entry_id] @@ -1610,11 +1655,11 @@ module StopTime::Views td { entry.date.to_date } td { entry.start.to_formatted_s(:time_only) } td { entry.end.to_formatted_s(:time_only)} - if entry.comment.nil? or entry.comment.empty? - td { a(:href => R(TimelineN, entry.id)){ i "None" } } - else + if entry.comment.present? td { a entry.comment, :href => R(TimelineN, entry.id), :title => entry.comment } + else + td { a(:href => R(TimelineN, entry.id)){ i "None" } } end td { "%.2fh" % entry.hours_total } td do @@ -1655,7 +1700,7 @@ module StopTime::Views div.control_group do label.control_label "Task", :for => "task" div.controls do - _form_select("task", @task_list) + _form_select_nested("task", @task_list) end end if @time_entry.present? and @time_entry.task.billed? @@ -1691,7 +1736,12 @@ module StopTime::Views # The main overview of the list of customers. def customers header.page_header do - h1 "Customers" + h1 do + text! "Customers" + div.btn_group.pull_right do + a.btn.btn_small "» Add a new customer", :href=> R(CustomersNew) + end + end end if @customers.empty? p do @@ -1755,7 +1805,6 @@ module StopTime::Views end end end - a.btn "» Add a new customer", :href=> R(CustomersNew) end end @@ -1782,6 +1831,12 @@ module StopTime::Views _form_input_with_label("Phone number", "phone", :tel) _form_input_with_label("Financial contact", "financial_contact", :text) _form_input_with_label("Default hourly rate", "hourly_rate", :text) + div.control_group do + label.control_label "Time specifications?" + div.controls do + _form_input_checkbox("time_specification") + end + end div.form_actions do button.btn.btn_primary @button.capitalize, :type => "submit", :name => @button, :value => @button.capitalize @@ -1793,27 +1848,75 @@ module StopTime::Views div.span6 do if @edit_task - h2 "Projects & Tasks" - # FXIME: the following is not very RESTful! - form :action => R(CustomersNTasks, @customer.id), :method => :post do - select.input_xlarge :name => "task_id", :size => 10 do - @tasks.each do |task| - if task.billed? - option(:value => task.id) { task.name + " (#{task.invoice.number})" } - else - option(:value => task.id) { task.name } + h2 do + text! "Projects & Tasks" + div.btn_group.pull_right do + a.btn.btn_small "» Add a new project/task", + :href => R(CustomersNTasksNew, @customer.id) + end + end + div.accordion.task_list! do + @billed_tasks.keys.sort_by { |task| task.name }.each do |task| + div.accordion_group do + div.accordion_heading do + span.accordion_toggle do + a task.name, "data-toggle" => "collapse", + "data-parent" => "#task_list", + :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_mini "Edit", :href => R(CustomersNTasksN, @customer.id, task.id) + input :type => :hidden, :name => "task_id", :value => task.id + button.btn.btn_danger.btn_mini "Delete", :type => :submit, + :name => "delete", :value => "Delete" + end + end + end + div.accordion_body.collapse :id => "collapse#{task.id}" do + div.accordion_inner do + if @billed_tasks[task].empty? + i { "No billed projects/tasks found" } + else + table.table.table_condensed do + col.task_list + @billed_tasks[task].sort_by { |t| t.invoice.number }.each do |billed_task| + tr do + td 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 do + # FXIME: the following is not very RESTful! + form.form_inline.pull_right :action => R(CustomersNTasks, @customer.id), + :method => :post do + a.btn.btn_mini "Edit", + :href => R(CustomersNTasksN, @customer.id, + billed_task.id) + input :type => :hidden, :name => "task_id", + :value => billed_task.id + button.btn.btn_danger.btn_mini "Delete", :type => :submit, + :name => "delete", :value => "Delete" + end + end + end + end + end + end + end end end end - div.form_actions do - button.btn.btn_primary "Edit", :type => :submit, - :name => "edit", :value => "Edit" - button.btn.btn_danger "Delete", :type => :submit, - :name => "delete", :value => "Delete" - a.btn "» Add a new project/task", - :href => R(CustomersNTasksNew, @customer.id) - end end + h2 "Invoices" _invoice_list(@invoices) a.btn "» Create a new invoice", @@ -1948,6 +2051,12 @@ module StopTime::Views _form_input_checkbox("paid") end end + div.control_group do + label.control_label "Include specification?" + div.controls do + _form_input_checkbox("include_specification") + end + end div.form_actions do button.btn.btn_primary "Update", :type => :submit, :name => "update", :value => "Update" @@ -1995,9 +2104,10 @@ module StopTime::Views tr do td.indent do if entry.comment.present? - "• #{entry.comment}" + a "• #{entry.comment}", :href => R(TimelineN, entry.id), + :title => entry.comment else - em.light "• no comment" + a :href => R(TimelineN, entry.id) { i "• None" } end end td.text_right { "%.2fh" % entry.hours_total } @@ -2417,12 +2527,13 @@ module StopTime::Views html_options.merge!(:name => name, :id => name) select(html_options) 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}) + optgroup :label => key do + opts[key].sort_by { |o| o.last }.each do |opt_val, opt_str| + if @input[name] == opt_val + option(opt_str, {:value => opt_val, :selected => true}) + else + option(opt_str, {:value => opt_val}) + end end end end diff --git a/templates/invoice.tex.erb b/templates/invoice.tex.erb index 7da04d2..7c7b515 100644 --- a/templates/invoice.tex.erb +++ b/templates/invoice.tex.erb @@ -1,4 +1,4 @@ -\documentclass[a4paper,12pt,oneside,dutch]{isodoc} +\documentclass[a4paper,oneside,dutch]{isodoc} % rubber: clean <%= @number %>.out @@ -54,13 +54,14 @@ yourref=, %% Payment data. term=30, - accountno=<%= @company.accountno %>, accountname=<%= @company.accountname %>, <% unless @company.accountiban.blank? %> iban=<%= @company.accountiban %>, <% end %><% unless @company.bank_bic.blank? %> bic=<%= @company.bank_bic %>, <% end %><% unless @company.vatno.blank? %> vatno=<%= @company.vatno %>, <% end %><% unless @company.chamber.blank? %> chamber=<%= @company.chamber %> <% end %>} +\setlength{\parindent}{0pt} +\setlength{\parskip}{\medskipamount} <% if @company.bank_name.present? %> \renewcommand{\accountnotext}{<%= @company.bank_name %> rekeningnr} @@ -88,6 +89,13 @@ \newcommand{\ihtotal}[1]{\cmidrule[.05em]{4-4}% \textbf{\totaltext}&&&\textbf{\currency~#1}} +\newenvironment{istable}% + {\vskip1em\tabularx{\linewidth}{@{}X@{\quad}l@{\qquad}r@{}} + \descriptiontext&Datum&Aantal uur\ML}% + {\endtabularx} +\newcommand{\istask}[1]{\textbf{#1}\\} +\newcommand{\isitem}[3]{\quad #1\\} + \begin{document} \invoice{ @@ -122,10 +130,33 @@ \end{ihtable} \vspace{2em} +<% if @invoice.include_specification? +%> Zie bijlage op de volgende pagina voor een nadere specificatie.\\[1em]<%end %> Ik verzoek u vriendelijk het verschuldigde bedrag binnen 30 dagen na factuurdatum over te maken onder vermelding van het factuurnummer. \\ \accountdata } +<% if @invoice.include_specification? %>{ + \newpage + + {\bfseries\scshape\Large Specificatie} + + Hieronder volgt een specificatie van gemaakte uren per taak per + uitgevoerde activiteit. + + \begin{istable} + <% @invoice.tasks.each do |task| +%> \istask{<%= task.comment_or_name %>}<% + task.time_entries.each do |time_entry| %> + \isitem{<%= time_entry.comment || "Geen opmerking" %>}% + {<%= time_entry.date.to_date %>}% + {<%= number_with_precision(time_entry.hours_total) %>}<% + end %>\\[\medskipamount]<% + end %> + \end{istable} + \label{LastPageOf\thelettercount} +}<% end %> + \end{document} diff --git a/templates/sass/style.sass b/templates/sass/style.sass index c837c1a..862547f 100644 --- a/templates/sass/style.sass +++ b/templates/sass/style.sass @@ -48,6 +48,8 @@ table width: 250px col.email, col.period width: 170px + col.task_list + width: 330px col.number width: 70px