510 lines
14 KiB
Go
510 lines
14 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"codeberg.org/go-pdf/fpdf"
|
|
"zeitkort-web/repository"
|
|
)
|
|
|
|
// ExtraLine is a manual invoice line item (e.g. "1 stk" of something).
|
|
type ExtraLine struct {
|
|
Description string
|
|
Quantity float64
|
|
Unit string // "tímar" or "stk"
|
|
UnitPrice float64
|
|
}
|
|
|
|
type invoiceInput struct {
|
|
InvoiceNumber string
|
|
Date time.Time
|
|
DueDate time.Time
|
|
DueDays int
|
|
Comment string
|
|
MVGPercent float64
|
|
|
|
FromName, FromAddress, FromPhone, FromEmail, FromWeb, FromVAT string
|
|
BankInfo string
|
|
|
|
Company repository.Company
|
|
Assignment repository.Assignment
|
|
HourlyRate float64
|
|
Sessions []repository.WorkSession
|
|
ExtraLines []ExtraLine
|
|
}
|
|
|
|
// ── Handlers ─────────────────────────────────────────────────────────────────
|
|
|
|
type invoiceFormView struct {
|
|
CompanyID int64
|
|
AssignmentID int64
|
|
Company *repository.Company
|
|
Assignment *repository.Assignment
|
|
TotalHours float64
|
|
TotalAmount float64
|
|
InvoiceNumber string
|
|
Today string
|
|
DueDays int
|
|
MVGPercent float64
|
|
Cfg Config
|
|
}
|
|
|
|
func (s *Server) handleInvoiceForm(w http.ResponseWriter, r *http.Request) {
|
|
companyID := qInt64(r, "company_id")
|
|
assignmentID := qInt64(r, "assignment_id")
|
|
if assignmentID == 0 {
|
|
s.renderMainWith(w, companyID, 0, "", "Select an assignment before making an invoice.")
|
|
return
|
|
}
|
|
|
|
assignment, err := s.repo.GetAssignmentByID(assignmentID)
|
|
if err != nil {
|
|
s.renderMainWith(w, companyID, assignmentID, "", err.Error())
|
|
return
|
|
}
|
|
company, err := s.repo.GetCompanyByID(assignment.CompanyID)
|
|
if err != nil {
|
|
s.renderMainWith(w, companyID, assignmentID, "", err.Error())
|
|
return
|
|
}
|
|
hours, _ := s.repo.TotalHoursForAssignment(assignmentID)
|
|
rate := assignment.HourlyRate
|
|
if rate == 0 {
|
|
rate = company.HourlyRate
|
|
}
|
|
|
|
now := time.Now()
|
|
nextNum, _ := s.repo.NextInvoiceNumber(now)
|
|
|
|
s.mu.Lock()
|
|
cfg := s.Cfg
|
|
s.mu.Unlock()
|
|
|
|
s.render(w, "invoice_form", invoiceFormView{
|
|
CompanyID: assignment.CompanyID,
|
|
AssignmentID: assignmentID,
|
|
Company: company,
|
|
Assignment: assignment,
|
|
TotalHours: hours,
|
|
TotalAmount: hours * rate,
|
|
InvoiceNumber: nextNum,
|
|
Today: now.Format("02-01-2006"),
|
|
DueDays: cfg.PaymentTermsDays,
|
|
MVGPercent: cfg.MVGPercent,
|
|
Cfg: cfg,
|
|
})
|
|
}
|
|
|
|
// handleInvoiceLine returns one empty extra-line input row (htmx append).
|
|
func (s *Server) handleInvoiceLine(w http.ResponseWriter, r *http.Request) {
|
|
s.render(w, "invoice_line", nil)
|
|
}
|
|
|
|
func (s *Server) handleInvoiceGenerate(w http.ResponseWriter, r *http.Request) {
|
|
r.ParseForm()
|
|
companyID := formInt64(r, "company_id")
|
|
assignmentID := formInt64(r, "assignment_id")
|
|
|
|
assignment, err := s.repo.GetAssignmentByID(assignmentID)
|
|
if err != nil {
|
|
s.renderMainWith(w, companyID, assignmentID, "", err.Error())
|
|
return
|
|
}
|
|
company, err := s.repo.GetCompanyByID(assignment.CompanyID)
|
|
if err != nil {
|
|
s.renderMainWith(w, companyID, assignmentID, "", err.Error())
|
|
return
|
|
}
|
|
sessions, err := s.repo.WorkSessionsByAssignment(assignmentID, 1000)
|
|
if err != nil {
|
|
s.renderMainWith(w, companyID, assignmentID, "", err.Error())
|
|
return
|
|
}
|
|
|
|
rate := assignment.HourlyRate
|
|
if rate == 0 {
|
|
rate = company.HourlyRate
|
|
}
|
|
|
|
mvg, _ := strconv.ParseFloat(r.FormValue("mvg"), 64)
|
|
dueDays, _ := strconv.Atoi(r.FormValue("due_days"))
|
|
if dueDays <= 0 {
|
|
dueDays = 8
|
|
}
|
|
now := time.Now()
|
|
|
|
in := invoiceInput{
|
|
InvoiceNumber: r.FormValue("invoice_number"),
|
|
Date: now,
|
|
DueDate: now.AddDate(0, 0, dueDays),
|
|
DueDays: dueDays,
|
|
Comment: r.FormValue("comment"),
|
|
MVGPercent: mvg,
|
|
FromName: r.FormValue("from_name"),
|
|
FromAddress: r.FormValue("from_address"),
|
|
FromPhone: r.FormValue("from_phone"),
|
|
FromEmail: r.FormValue("from_email"),
|
|
FromWeb: r.FormValue("from_web"),
|
|
FromVAT: r.FormValue("from_vat"),
|
|
BankInfo: r.FormValue("bank_info"),
|
|
Company: *company,
|
|
Assignment: *assignment,
|
|
HourlyRate: rate,
|
|
Sessions: sessions,
|
|
ExtraLines: parseExtraLines(r),
|
|
}
|
|
|
|
pdfBytes, total, err := generateInvoicePDF(in)
|
|
if err != nil {
|
|
s.renderMainWith(w, companyID, assignmentID, "", "PDF generation failed: "+err.Error())
|
|
return
|
|
}
|
|
|
|
// Record the invoice so numbering keeps incrementing (best effort).
|
|
if _, err := s.repo.InsertInvoice(repository.Invoice{
|
|
AssignmentID: assignmentID,
|
|
InvoiceNumber: in.InvoiceNumber,
|
|
TotalAmount: total,
|
|
}); err != nil {
|
|
s.errl.Printf("could not record invoice %s: %v", in.InvoiceNumber, err)
|
|
}
|
|
|
|
filename := fmt.Sprintf("faktura_%s_%s.pdf", in.InvoiceNumber, sanitize(assignment.Name))
|
|
w.Header().Set("Content-Type", "application/pdf")
|
|
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
|
|
w.Header().Set("Content-Length", strconv.Itoa(len(pdfBytes)))
|
|
w.Write(pdfBytes)
|
|
}
|
|
|
|
func parseExtraLines(r *http.Request) []ExtraLine {
|
|
descs := r.Form["extra_desc"]
|
|
qtys := r.Form["extra_qty"]
|
|
units := r.Form["extra_unit"]
|
|
prices := r.Form["extra_price"]
|
|
|
|
var lines []ExtraLine
|
|
for i := range descs {
|
|
desc := strings.TrimSpace(descs[i])
|
|
if desc == "" {
|
|
continue
|
|
}
|
|
qty, unit, price := 0.0, "stk", 0.0
|
|
if i < len(qtys) {
|
|
qty, _ = strconv.ParseFloat(qtys[i], 64)
|
|
}
|
|
if i < len(units) && units[i] != "" {
|
|
unit = units[i]
|
|
}
|
|
if i < len(prices) {
|
|
price, _ = strconv.ParseFloat(prices[i], 64)
|
|
}
|
|
if qty == 0 || price == 0 {
|
|
continue
|
|
}
|
|
lines = append(lines, ExtraLine{Description: desc, Quantity: qty, Unit: unit, UnitPrice: price})
|
|
}
|
|
return lines
|
|
}
|
|
|
|
// ── PDF generation (ported from the desktop app) ─────────────────────────────
|
|
|
|
// stripTaskNumber removes the "N - " prefix from task descriptions.
|
|
// "1 - Frontend work" → "Frontend work"
|
|
func stripTaskNumber(desc string) string {
|
|
if idx := strings.Index(desc, " - "); idx >= 0 {
|
|
prefix := desc[:idx]
|
|
allDigits := true
|
|
for _, c := range prefix {
|
|
if c < '0' || c > '9' {
|
|
allDigits = false
|
|
break
|
|
}
|
|
}
|
|
if allDigits {
|
|
return desc[idx+3:]
|
|
}
|
|
}
|
|
return desc
|
|
}
|
|
|
|
// generateInvoicePDF builds the invoice and returns the PDF bytes and the
|
|
// grand total (incl. MVG) for record keeping.
|
|
func generateInvoicePDF(d invoiceInput) ([]byte, float64, error) {
|
|
pdf := fpdf.New("P", "mm", "A4", "")
|
|
pdf.SetMargins(10, 15, 10)
|
|
pdf.SetAutoPageBreak(true, 30)
|
|
|
|
fontFamily := "Helvetica"
|
|
txt := pdf.UnicodeTranslatorFromDescriptor("")
|
|
|
|
footer1 := d.FromName
|
|
if d.FromAddress != "" {
|
|
footer1 += " / " + d.FromAddress
|
|
}
|
|
var footer2Parts []string
|
|
if d.FromVAT != "" {
|
|
footer2Parts = append(footer2Parts, "V-tal: "+d.FromVAT)
|
|
}
|
|
if d.FromPhone != "" {
|
|
footer2Parts = append(footer2Parts, "tlf.: "+d.FromPhone)
|
|
}
|
|
if d.FromEmail != "" {
|
|
footer2Parts = append(footer2Parts, "teldupostur: "+d.FromEmail)
|
|
}
|
|
if d.FromWeb != "" {
|
|
footer2Parts = append(footer2Parts, "heimas\u00ed\u00f0a: "+d.FromWeb)
|
|
}
|
|
footer2 := strings.Join(footer2Parts, " / ")
|
|
|
|
pdf.SetFooterFunc(func() {
|
|
pdf.SetY(-20)
|
|
pdf.SetTextColor(100, 100, 100)
|
|
pdf.SetFont(fontFamily, "", 8)
|
|
pdf.SetDrawColor(100, 100, 100)
|
|
pdf.SetLineWidth(0.2)
|
|
pdf.Line(10, pdf.GetY(), 200, pdf.GetY())
|
|
pdf.Ln(2)
|
|
pdf.CellFormat(190, 4, txt(footer1), "", 0, "C", false, 0, "")
|
|
pdf.Ln(-1)
|
|
if footer2 != "" {
|
|
pdf.CellFormat(190, 4, txt(footer2), "", 0, "C", false, 0, "")
|
|
}
|
|
})
|
|
|
|
pdf.AddPage()
|
|
|
|
darkBlue := func() { pdf.SetTextColor(68, 84, 106) }
|
|
black := func() { pdf.SetTextColor(0, 0, 0) }
|
|
setFont := func(style string, size float64) { pdf.SetFont(fontFamily, style, size) }
|
|
|
|
// Header
|
|
darkBlue()
|
|
setFont("B", 20)
|
|
pdf.SetXY(10, 20)
|
|
pdf.CellFormat(190, 10, txt(d.FromName), "", 0, "R", false, 0, "")
|
|
|
|
// Customer info
|
|
pdf.SetXY(10, 35)
|
|
darkBlue()
|
|
setFont("B", 11)
|
|
pdf.Cell(190, 5, txt(d.Company.Name))
|
|
pdf.Ln(5)
|
|
|
|
setFont("", 10)
|
|
darkBlue()
|
|
if d.Company.Address != "" {
|
|
pdf.Cell(190, 5, txt(d.Company.Address))
|
|
pdf.Ln(5)
|
|
}
|
|
if d.Company.ContactEmail != "" {
|
|
pdf.Cell(190, 5, txt(d.Company.ContactEmail))
|
|
pdf.Ln(5)
|
|
}
|
|
if d.Company.ContactPhone != "" {
|
|
pdf.Cell(190, 5, txt(d.Company.ContactPhone))
|
|
pdf.Ln(5)
|
|
}
|
|
pdf.Ln(10)
|
|
|
|
// Invoice meta
|
|
yMeta := pdf.GetY()
|
|
black()
|
|
setFont("", 10)
|
|
pdf.CellFormat(12, 5, txt("Dato: "), "", 0, "L", false, 0, "")
|
|
setFont("B", 10)
|
|
pdf.CellFormat(50, 5, d.Date.Format("02/01/2006"), "", 0, "L", false, 0, "")
|
|
|
|
pdf.SetXY(140, yMeta)
|
|
setFont("", 10)
|
|
pdf.CellFormat(30, 5, txt("Fakturanr. "), "", 0, "R", false, 0, "")
|
|
setFont("B", 10)
|
|
pdf.CellFormat(20, 5, txt(d.InvoiceNumber), "", 0, "R", false, 0, "")
|
|
|
|
pdf.SetXY(10, yMeta+6)
|
|
if d.Comment != "" {
|
|
setFont("", 10)
|
|
pdf.MultiCell(190, 5, txt(d.Comment), "", "L", false)
|
|
}
|
|
pdf.Ln(10)
|
|
|
|
// Title
|
|
darkBlue()
|
|
setFont("B", 16)
|
|
pdf.Cell(190, 8, txt("Faktura"))
|
|
pdf.Ln(10)
|
|
|
|
// Table
|
|
colW := []float64{80, 20, 20, 25, 20, 25}
|
|
|
|
pdf.SetDrawColor(68, 84, 106)
|
|
pdf.SetLineWidth(0.6)
|
|
pdf.Line(10, pdf.GetY(), 200, pdf.GetY())
|
|
pdf.SetY(pdf.GetY() + 1)
|
|
|
|
darkBlue()
|
|
setFont("", 10)
|
|
pdf.CellFormat(colW[0], 7, txt("Lýsing"), "", 0, "L", false, 0, "")
|
|
pdf.CellFormat(colW[1], 7, txt("Nøgd"), "", 0, "R", false, 0, "")
|
|
pdf.CellFormat(colW[2], 7, txt("Eind"), "", 0, "L", false, 0, "")
|
|
pdf.CellFormat(colW[3], 7, txt("Stk. prísur"), "", 0, "R", false, 0, "")
|
|
pdf.CellFormat(colW[4], 7, "", "", 0, "R", false, 0, "")
|
|
pdf.CellFormat(colW[5], 7, txt("Upphædd"), "", 0, "R", false, 0, "")
|
|
pdf.Ln(-1)
|
|
|
|
pdf.Line(10, pdf.GetY(), 200, pdf.GetY())
|
|
pdf.SetY(pdf.GetY() + 1)
|
|
|
|
// group sessions by task
|
|
type taskLine struct {
|
|
desc string
|
|
hours float64
|
|
amount float64
|
|
}
|
|
taskMap := make(map[int64]*taskLine)
|
|
var taskOrder []int64
|
|
|
|
for _, ws := range d.Sessions {
|
|
if ws.EndTime == nil {
|
|
continue
|
|
}
|
|
hours := ws.DurationMinutes / 60.0
|
|
amount := hours * d.HourlyRate
|
|
if tl, ok := taskMap[ws.TaskID]; ok {
|
|
tl.hours += hours
|
|
tl.amount += amount
|
|
} else {
|
|
taskMap[ws.TaskID] = &taskLine{desc: ws.TaskDescription, hours: hours, amount: amount}
|
|
taskOrder = append(taskOrder, ws.TaskID)
|
|
}
|
|
}
|
|
|
|
setFont("", 10)
|
|
pdf.SetTextColor(50, 50, 50)
|
|
|
|
subtotal := 0.0
|
|
for _, tid := range taskOrder {
|
|
tl := taskMap[tid]
|
|
desc := stripTaskNumber(tl.desc)
|
|
if len(desc) > 45 {
|
|
desc = desc[:42] + "..."
|
|
}
|
|
pdf.CellFormat(colW[0], 7, txt(desc), "", 0, "L", false, 0, "")
|
|
pdf.CellFormat(colW[1], 7, fmtNum(tl.hours), "", 0, "R", false, 0, "")
|
|
pdf.CellFormat(colW[2], 7, txt(" tímar"), "", 0, "L", false, 0, "")
|
|
pdf.CellFormat(colW[3], 7, fmtNum(d.HourlyRate), "", 0, "R", false, 0, "")
|
|
pdf.CellFormat(colW[4], 7, "", "", 0, "R", false, 0, "")
|
|
pdf.CellFormat(colW[5], 7, fmtNum(tl.amount), "", 0, "R", false, 0, "")
|
|
pdf.Ln(-1)
|
|
subtotal += tl.amount
|
|
}
|
|
|
|
// Extra lines
|
|
for _, el := range d.ExtraLines {
|
|
desc := el.Description
|
|
if len(desc) > 45 {
|
|
desc = desc[:42] + "..."
|
|
}
|
|
amount := el.Quantity * el.UnitPrice
|
|
pdf.CellFormat(colW[0], 7, txt(desc), "", 0, "L", false, 0, "")
|
|
pdf.CellFormat(colW[1], 7, fmtNum(el.Quantity), "", 0, "R", false, 0, "")
|
|
pdf.CellFormat(colW[2], 7, txt(" "+el.Unit), "", 0, "L", false, 0, "")
|
|
pdf.CellFormat(colW[3], 7, fmtNum(el.UnitPrice), "", 0, "R", false, 0, "")
|
|
pdf.CellFormat(colW[4], 7, "", "", 0, "R", false, 0, "")
|
|
pdf.CellFormat(colW[5], 7, fmtNum(amount), "", 0, "R", false, 0, "")
|
|
pdf.Ln(-1)
|
|
subtotal += amount
|
|
}
|
|
|
|
pdf.Ln(5)
|
|
|
|
// Totals
|
|
offset := colW[0] + colW[1] + colW[2]
|
|
totW := colW[3] + colW[4] + colW[5]
|
|
vat := subtotal * (d.MVGPercent / 100.0)
|
|
total := subtotal + vat
|
|
|
|
darkBlue()
|
|
setFont("", 10)
|
|
pdf.SetX(10 + offset)
|
|
pdf.Line(10+offset, pdf.GetY(), 200, pdf.GetY())
|
|
pdf.SetY(pdf.GetY() + 1)
|
|
|
|
pdf.SetX(10 + offset)
|
|
pdf.CellFormat(totW-colW[5], 6, txt("Í alt DKK uttan MVG"), "", 0, "L", false, 0, "")
|
|
black()
|
|
pdf.CellFormat(colW[5], 6, fmtNum(subtotal), "", 0, "R", false, 0, "")
|
|
pdf.Ln(-1)
|
|
|
|
darkBlue()
|
|
pdf.SetX(10 + offset)
|
|
pdf.CellFormat(totW-colW[5], 6, txt(fmt.Sprintf("%.0f%% MVG", d.MVGPercent)), "", 0, "L", false, 0, "")
|
|
black()
|
|
pdf.CellFormat(colW[5], 6, fmtNum(vat), "", 0, "R", false, 0, "")
|
|
pdf.Ln(-1)
|
|
|
|
darkBlue()
|
|
setFont("B", 10)
|
|
pdf.SetX(10 + offset)
|
|
pdf.Line(10+offset, pdf.GetY(), 200, pdf.GetY())
|
|
pdf.SetY(pdf.GetY() + 1)
|
|
|
|
pdf.SetX(10 + offset)
|
|
pdf.CellFormat(totW-colW[5], 6, txt("Í alt DKK vid MVG"), "", 0, "L", false, 0, "")
|
|
black()
|
|
pdf.CellFormat(colW[5], 6, fmtNum(total), "", 0, "R", false, 0, "")
|
|
pdf.Ln(-1)
|
|
|
|
pdf.SetX(10 + offset)
|
|
pdf.Line(10+offset, pdf.GetY(), 200, pdf.GetY())
|
|
pdf.Ln(20)
|
|
|
|
// Payment terms
|
|
darkBlue()
|
|
setFont("", 9)
|
|
pdf.Cell(190, 5, txt(fmt.Sprintf(
|
|
"Gjaldstreytir: Netto %d dagar - Fellir á dato %s",
|
|
d.DueDays, d.DueDate.Format("02/01/2006"),
|
|
)))
|
|
pdf.Ln(8)
|
|
|
|
if d.BankInfo != "" {
|
|
pdf.Cell(190, 5, txt("Upphæddin skal setast inn á bankakontu:"))
|
|
pdf.Ln(5)
|
|
for _, line := range strings.Split(d.BankInfo, "\n") {
|
|
pdf.Cell(190, 5, txt(strings.TrimRight(line, "\r")))
|
|
pdf.Ln(5)
|
|
}
|
|
}
|
|
|
|
pdf.Cell(190, 5, txt(fmt.Sprintf("Biði verður um Fakturanr. %s við flytingini", d.InvoiceNumber)))
|
|
pdf.Ln(8)
|
|
pdf.Cell(190, 5, txt("Verður ikki goldið innan lokna gjaldsfrest, verður renta tilskriva á 0,70%, umframt eitt gjald uppá 100,00 DKK"))
|
|
|
|
var buf bytes.Buffer
|
|
if err := pdf.Output(&buf); err != nil {
|
|
return nil, 0, err
|
|
}
|
|
return buf.Bytes(), total, nil
|
|
}
|
|
|
|
func fmtNum(v float64) string {
|
|
return fmt.Sprintf("%.2f", v)
|
|
}
|
|
|
|
func sanitize(name string) string {
|
|
var b strings.Builder
|
|
for _, c := range name {
|
|
if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_' {
|
|
b.WriteRune(c)
|
|
} else if c == ' ' {
|
|
b.WriteByte('_')
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|