From e8bfcf26f374a4f9817892054adcb9b2a46f10a3 Mon Sep 17 00:00:00 2001 From: Ruidy Date: Fri, 27 Mar 2026 14:48:41 +0100 Subject: [PATCH] feat(pdf): improve invoice PDF layout and style Redesigned the invoice PDF with a more modern, modular layout. Updated colors, spacing, and section organization for better readability and visual appeal. Added a summary block, improved table formatting, and refined header/footer presentation. Also updated dependencies in go.mod and go.sum to support new features. --- go.mod | 19 +- go.sum | 39 ++-- internal/driver/pdf/html.go | 366 +++++++++++++++++++++++++----------- 3 files changed, 287 insertions(+), 137 deletions(-) diff --git a/go.mod b/go.mod index 2de363b..52dcb85 100644 --- a/go.mod +++ b/go.mod @@ -4,11 +4,13 @@ go 1.26 require ( github.com/a-h/templ v0.3.1001 - github.com/getsentry/sentry-go v0.43.0 + github.com/getsentry/sentry-go v0.44.1 github.com/go-chi/chi/v5 v5.2.5 github.com/go-chi/cors v1.2.2 github.com/gorilla/sessions v1.4.0 github.com/joho/godotenv v1.5.1 + github.com/jung-kurt/gofpdf/v2 v2.17.3 + github.com/minio/minio-go/v7 v7.0.99 github.com/nicksnyder/go-i18n/v2 v2.6.1 github.com/rjNemo/underscore v0.10.0 github.com/stripe/stripe-go/v83 v83.2.1 @@ -21,30 +23,29 @@ require ( github.com/go-ini/ini v1.67.0 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/jung-kurt/gofpdf/v2 v2.17.3 // indirect - github.com/klauspost/compress v1.18.2 // indirect - github.com/klauspost/cpuid/v2 v2.2.11 // indirect + github.com/klauspost/compress v1.18.5 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/klauspost/crc32 v1.3.0 // indirect + github.com/kr/text v0.2.0 // indirect github.com/minio/crc64nvme v1.1.1 // indirect github.com/minio/md5-simd v1.1.2 // indirect - github.com/minio/minio-go/v7 v7.0.99 // indirect github.com/philhofer/fwd v1.2.0 // indirect github.com/rs/xid v1.6.0 // indirect github.com/tidwall/gjson v1.18.0 // indirect github.com/tidwall/match v1.2.0 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/sjson v1.2.5 // indirect - github.com/tinylib/msgp v1.6.1 // indirect + github.com/tinylib/msgp v1.6.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.46.0 // indirect - golang.org/x/net v0.48.0 // indirect + golang.org/x/crypto v0.49.0 // indirect + golang.org/x/net v0.52.0 // indirect ) require ( github.com/gorilla/securecookie v1.1.2 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect - github.com/jackc/pgx/v5 v5.8.0 // indirect + github.com/jackc/pgx/v5 v5.9.1 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect diff --git a/go.sum b/go.sum index 8fd026b..3818e0a 100644 --- a/go.sum +++ b/go.sum @@ -2,13 +2,14 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/a-h/templ v0.3.1001 h1:yHDTgexACdJttyiyamcTHXr2QkIeVF1MukLy44EAhMY= github.com/a-h/templ v0.3.1001/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/getsentry/sentry-go v0.43.0 h1:XbXLpFicpo8HmBDaInk7dum18G9KSLcjZiyUKS+hLW4= -github.com/getsentry/sentry-go v0.43.0/go.mod h1:XDotiNZbgf5U8bPDUAfvcFmOnMQQceESxyKaObSssW0= +github.com/getsentry/sentry-go v0.44.1 h1:/cPtrA5qB7uMRrhgSn9TYtcEF36auGP3Y6+ThvD/yaI= +github.com/getsentry/sentry-go v0.44.1/go.mod h1:XDotiNZbgf5U8bPDUAfvcFmOnMQQceESxyKaObSssW0= github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE= @@ -31,8 +32,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo= -github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw= +github.com/jackc/pgx/v5 v5.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc= +github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= @@ -43,13 +44,17 @@ github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/jung-kurt/gofpdf/v2 v2.17.3 h1:otZXZby2gXJ7uU6pzprXHq/R57lsHLi0WtH79VabWxY= github.com/jung-kurt/gofpdf/v2 v2.17.3/go.mod h1:Qx8ZNg4cNsO5i6uLDiBngnm+ii/FjtAqjRNO6drsoYU= -github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= -github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= +github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= -github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU= -github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM= github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI= github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg= github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34= @@ -70,6 +75,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rjNemo/underscore v0.10.0 h1:f0WTiHXujG9mgbEt51VH06TqLMS5n4EUKtp5wzhBqQM= github.com/rjNemo/underscore v0.10.0/go.mod h1:g2nURJw5INpBuh8ie0AjK1KLnsHSA+Tzc+oXiooq7ms= +github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8= +github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/sethvargo/go-envconfig v1.3.0 h1:gJs+Fuv8+f05omTpwWIu6KmuseFAXKrIaOZSh8RMt0U= @@ -92,18 +99,16 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= -github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY= -github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= +github.com/tinylib/msgp v1.6.3 h1:bCSxiTz386UTgyT1i0MSCvdbWjVW+8sG3PjkGsZQt4s= +github.com/tinylib/msgp v1.6.3/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= -golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= -golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= -golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= -golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= -golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= @@ -111,6 +116,8 @@ golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/driver/pdf/html.go b/internal/driver/pdf/html.go index a7d6543..dbeb9c0 100644 --- a/internal/driver/pdf/html.go +++ b/internal/driver/pdf/html.go @@ -6,6 +6,7 @@ import ( "log" "os" "text/template" + "time" "github.com/jung-kurt/gofpdf/v2" @@ -16,12 +17,14 @@ import ( type HTMLPdfClient struct{} const ( - invoiceMarginX = 10.0 - invoiceContentWidth = 190.0 - invoiceHeaderHeight = 28.0 - invoiceTableRowH = 8.5 - invoiceSummaryLineH = 10.0 - invoiceNotesPadding = 3.0 + invoiceMarginX = 10.0 + invoiceContentWidth = 190.0 + invoiceHeaderHeight = 32.0 + invoiceTableRowH = 9.0 + invoiceSummaryLineH = 9.0 + invoiceNotesPadding = 4.0 + invoiceColGap = 6.0 + invoiceColWidth = (invoiceContentWidth - invoiceColGap) / 2.0 ) type rgbColor struct { @@ -32,10 +35,13 @@ type rgbColor struct { var ( invoiceTeal = rgbColor{0, 123, 143} - invoiceLightGray = rgbColor{244, 244, 244} - invoiceMidGray = rgbColor{221, 221, 221} - invoiceTextGray = rgbColor{85, 85, 85} + invoiceTealDark = rgbColor{0, 98, 114} + invoiceLightGray = rgbColor{248, 249, 250} + invoiceMidGray = rgbColor{222, 226, 230} + invoiceTextGray = rgbColor{73, 80, 87} + invoiceTextMuted = rgbColor{108, 117, 125} invoiceNoteBg = rgbColor{237, 247, 249} + invoiceWhite = rgbColor{255, 255, 255} ) func NewPdfClient() (*HTMLPdfClient, error) { @@ -44,7 +50,7 @@ func NewPdfClient() (*HTMLPdfClient, error) { func (pc *HTMLPdfClient) BuildInvoice(data booking.Invoice) (*booking.GeneratedFile, error) { pdf := gofpdf.New("P", "mm", "A4", "") - pdf.SetMargins(invoiceMarginX, 12, invoiceMarginX) + pdf.SetMargins(invoiceMarginX, 10, invoiceMarginX) pdf.SetAutoPageBreak(true, 14) pdf.AddPage() @@ -55,6 +61,7 @@ func (pc *HTMLPdfClient) BuildInvoice(data booking.Invoice) (*booking.GeneratedF drawInvoicePayments(pdf, tr, data) drawInvoiceSummary(pdf, tr, data) drawInvoiceNotes(pdf, tr, data) + drawInvoiceFooter(pdf, tr, data) var buf bytes.Buffer if err := pdf.Output(&buf); err != nil { @@ -90,9 +97,17 @@ func (pc *HTMLPdfClient) BuildReport(report booking.ReportData, period string, m } func drawInvoiceHeader(pdf *gofpdf.Fpdf, tr func(string) string, data booking.Invoice) { - pdf.SetFillColor(invoiceTeal.r, invoiceTeal.g, invoiceTeal.b) - pdf.Rect(invoiceMarginX, 12, invoiceContentWidth, invoiceHeaderHeight, "F") + topY := 10.0 + // Full-width teal banner + pdf.SetFillColor(invoiceTeal.r, invoiceTeal.g, invoiceTeal.b) + pdf.Rect(invoiceMarginX, topY, invoiceContentWidth, invoiceHeaderHeight, "F") + + // Subtle darker teal accent strip at very top + pdf.SetFillColor(invoiceTealDark.r, invoiceTealDark.g, invoiceTealDark.b) + pdf.Rect(invoiceMarginX, topY, invoiceContentWidth, 1.2, "F") + + // Logo in a white rounded rectangle if logo, err := assets.Static.ReadFile("assets/img/logo.png"); err == nil { info := pdf.RegisterImageOptionsReader( "invoice-logo", @@ -100,15 +115,28 @@ func drawInvoiceHeader(pdf *gofpdf.Fpdf, tr func(string) string, data booking.In bytes.NewReader(logo), ) if info != nil { - pdf.ImageOptions("invoice-logo", 14, 15.5, 17, 17, false, gofpdf.ImageOptions{ImageType: "PNG"}, 0, "") + logoX := invoiceMarginX + 5 + logoY := topY + 4 + logoSize := 24.0 + // white background circle behind logo + pdf.SetFillColor(255, 255, 255) + pdf.SetDrawColor(255, 255, 255) + radius := logoSize/2 + 1.5 + pdf.Circle(logoX+logoSize/2, logoY+logoSize/2, radius, "F") + pdf.ImageOptions("invoice-logo", logoX+1.5, logoY+1.5, logoSize-3, logoSize-3, false, gofpdf.ImageOptions{ImageType: "PNG"}, 0, "") } } + // Host info on the right side of banner pdf.SetTextColor(255, 255, 255) - pdf.SetXY(110, 14.5) - pdf.SetFont("Helvetica", "B", 11) - pdf.CellFormat(86, 4.8, tr(data.Host.Name), "", 1, "R", false, 0, "") - pdf.SetFont("Helvetica", "", 8.8) + rightBlockX := 100.0 + rightBlockW := invoiceMarginX + invoiceContentWidth - rightBlockX - 4 + + pdf.SetXY(rightBlockX, topY+5) + pdf.SetFont("Helvetica", "B", 13) + pdf.CellFormat(rightBlockW, 5.5, tr(data.Host.Name), "", 1, "R", false, 0, "") + + pdf.SetFont("Helvetica", "", 8.5) hostLines := []string{ data.Host.Address, fmt.Sprintf("%s %s", data.Host.ZipCode, data.Host.City), @@ -116,170 +144,267 @@ func drawInvoiceHeader(pdf *gofpdf.Fpdf, tr func(string) string, data booking.In fmt.Sprintf("Mail : %s", data.Host.Email), } for _, line := range hostLines { - pdf.SetX(110) - pdf.CellFormat(86, 4.0, tr(line), "", 1, "R", false, 0, "") + pdf.SetX(rightBlockX) + pdf.CellFormat(rightBlockW, 4.2, tr(line), "", 1, "R", false, 0, "") } - pdf.SetTextColor(0, 0, 0) - pdf.SetDrawColor(invoiceMidGray.r, invoiceMidGray.g, invoiceMidGray.b) - pdf.Line(invoiceMarginX, 45, invoiceMarginX+invoiceContentWidth, 45) - pdf.SetY(49) + // Invoice number badge below banner + pdf.Ln(5) + badgeY := pdf.GetY() + pdf.SetFont("Helvetica", "", 9) + pdf.SetTextColor(invoiceTextMuted.r, invoiceTextMuted.g, invoiceTextMuted.b) + pdf.SetX(invoiceMarginX) + pdf.CellFormat(80, 6, tr("FACTURE"), "", 0, "L", false, 0, "") + + pdf.SetFont("Helvetica", "B", 14) + pdf.SetTextColor(invoiceTeal.r, invoiceTeal.g, invoiceTeal.b) + pdf.CellFormat(invoiceContentWidth-80, 6, tr(data.ID), "", 1, "R", false, 0, "") + + pdf.SetDrawColor(invoiceTeal.r, invoiceTeal.g, invoiceTeal.b) + pdf.SetLineWidth(0.4) + pdf.Line(invoiceMarginX, badgeY+8, invoiceMarginX+invoiceContentWidth, badgeY+8) + pdf.SetLineWidth(0.2) + + pdf.SetY(badgeY + 12) } func drawInvoiceDetails(pdf *gofpdf.Fpdf, tr func(string) string, data booking.Invoice) { - leftX := invoiceMarginX + 4 - rightX := 103.0 - labelColor := invoiceTextGray + startY := pdf.GetY() + leftX := invoiceMarginX + rightX := invoiceMarginX + invoiceColWidth + invoiceColGap + cardPad := 5.0 + cardH := 36.0 - writeDetail := func(x float64, label, value string, boldValue bool) { - pdf.SetX(x) - pdf.SetFont("Helvetica", "B", 10.5) - pdf.SetTextColor(labelColor.r, labelColor.g, labelColor.b) - pdf.CellFormat(38, 7, tr(label), "", 0, "", false, 0, "") - pdf.SetTextColor(0, 0, 0) - style := "" - if boldValue { - style = "B" - } - pdf.SetFont("Helvetica", style, 10.5) - pdf.CellFormat(48, 7, tr(value), "", 1, "", false, 0, "") - } + // Left card: Customer info - light gray background + pdf.SetFillColor(invoiceLightGray.r, invoiceLightGray.g, invoiceLightGray.b) + pdf.RoundedRect(leftX, startY, invoiceColWidth, cardH, 2, "1234", "F") + // Section label + pdf.SetXY(leftX+cardPad, startY+cardPad) + pdf.SetFont("Helvetica", "", 7.5) + pdf.SetTextColor(invoiceTextMuted.r, invoiceTextMuted.g, invoiceTextMuted.b) + pdf.CellFormat(invoiceColWidth-cardPad*2, 4, tr("CLIENT"), "", 1, "L", false, 0, "") + + // Customer name + pdf.SetX(leftX + cardPad) pdf.SetFont("Helvetica", "B", 12) pdf.SetTextColor(invoiceTextGray.r, invoiceTextGray.g, invoiceTextGray.b) - pdf.SetX(leftX) - pdf.CellFormat(86, 7, tr(data.Name), "", 0, "", false, 0, "") - pdf.SetX(rightX) - pdf.CellFormat(86, 7, tr("N° de facture :"), "", 0, "", false, 0, "") - pdf.SetTextColor(0, 0, 0) - pdf.SetFont("Helvetica", "", 11) - pdf.CellFormat(20, 7, tr(data.ID), "", 1, "R", false, 0, "") + pdf.CellFormat(invoiceColWidth-cardPad*2, 7, tr(data.Name), "", 1, "L", false, 0, "") - writeDetail(leftX, "Tel :", emptyFallback(data.PhoneNumber), false) - writeDetail(rightX, "Du :", data.From, false) + // Customer details + detailLineH := 5.5 + writeLeftDetail := func(label, value string) { + pdf.SetX(leftX + cardPad) + pdf.SetFont("Helvetica", "", 8.5) + pdf.SetTextColor(invoiceTextMuted.r, invoiceTextMuted.g, invoiceTextMuted.b) + pdf.CellFormat(28, detailLineH, tr(label), "", 0, "L", false, 0, "") + pdf.SetFont("Helvetica", "", 9) + pdf.SetTextColor(invoiceTextGray.r, invoiceTextGray.g, invoiceTextGray.b) + pdf.CellFormat(invoiceColWidth-cardPad*2-28, detailLineH, tr(value), "", 1, "L", false, 0, "") + } - writeDetail(leftX, "Nombre de clients :", fmt.Sprintf("%d", data.CustomersNumber), false) - writeDetail(rightX, "Au :", data.To, false) + writeLeftDetail("Tel", emptyFallback(data.PhoneNumber)) + writeLeftDetail("Clients", fmt.Sprintf("%d", data.CustomersNumber)) + writeLeftDetail("Plateforme", data.Platform) - writeDetail(leftX, "Plateforme :", data.Platform, false) - writeDetail(rightX, "Montant Total :", euroAmount(data.Total), false) + // Right card: Booking info - light gray background + pdf.SetFillColor(invoiceLightGray.r, invoiceLightGray.g, invoiceLightGray.b) + pdf.RoundedRect(rightX, startY, invoiceColWidth, cardH, 2, "1234", "F") - pdf.Ln(4) - pdf.SetDrawColor(invoiceMidGray.r, invoiceMidGray.g, invoiceMidGray.b) - pdf.Line(invoiceMarginX, pdf.GetY(), invoiceMarginX+invoiceContentWidth, pdf.GetY()) - pdf.Ln(5) + pdf.SetXY(rightX+cardPad, startY+cardPad) + pdf.SetFont("Helvetica", "", 7.5) + pdf.SetTextColor(invoiceTextMuted.r, invoiceTextMuted.g, invoiceTextMuted.b) + pdf.CellFormat(invoiceColWidth-cardPad*2, 4, tr("SÉJOUR"), "", 1, "L", false, 0, "") + + writeRightDetail := func(label, value string, bold bool) { + pdf.SetX(rightX + cardPad) + pdf.SetFont("Helvetica", "", 8.5) + pdf.SetTextColor(invoiceTextMuted.r, invoiceTextMuted.g, invoiceTextMuted.b) + pdf.CellFormat(28, detailLineH, tr(label), "", 0, "L", false, 0, "") + style := "" + if bold { + style = "B" + } + pdf.SetFont("Helvetica", style, 9.5) + pdf.SetTextColor(invoiceTextGray.r, invoiceTextGray.g, invoiceTextGray.b) + pdf.CellFormat(invoiceColWidth-cardPad*2-28, detailLineH, tr(value), "", 1, "L", false, 0, "") + } + + // Date range as main heading + pdf.SetX(rightX + cardPad) + pdf.SetFont("Helvetica", "B", 12) + pdf.SetTextColor(invoiceTextGray.r, invoiceTextGray.g, invoiceTextGray.b) + pdf.CellFormat(invoiceColWidth-cardPad*2, 7, tr(fmt.Sprintf("%s — %s", data.From, data.To)), "", 1, "L", false, 0, "") + + writeRightDetail("Nuits", nightsCount(data.From, data.To), false) + writeRightDetail("Montant", euroAmount(data.Total), true) + + pdf.SetY(startY + cardH + 8) } func drawInvoiceItemsTable(pdf *gofpdf.Fpdf, tr func(string) string, data booking.Invoice) { - widths := []float64{70, 28, 39, 53} + // Section title + pdf.SetFont("Helvetica", "B", 11) + pdf.SetTextColor(invoiceTeal.r, invoiceTeal.g, invoiceTeal.b) + pdf.CellFormat(0, 7, tr("Prestations"), "", 1, "L", false, 0, "") + pdf.Ln(1) + + widths := []float64{72, 30, 40, 48} headers := []string{"Objet", "Quantité", "Prix (€)", "Total (€)"} writeTableHeader(pdf, tr, widths, headers) - pdf.SetFont("Helvetica", "", 10.5) - pdf.SetTextColor(0, 0, 0) + pdf.SetFont("Helvetica", "", 10) for i, line := range data.Lines { fill := i%2 == 0 if fill { pdf.SetFillColor(invoiceLightGray.r, invoiceLightGray.g, invoiceLightGray.b) } - pdf.CellFormat(widths[0], invoiceTableRowH, tr(line.Name), "1", 0, "", fill, 0, "") - pdf.CellFormat(widths[1], invoiceTableRowH, fmt.Sprintf("%d", line.Quantity), "1", 0, "C", fill, 0, "") - pdf.CellFormat(widths[2], invoiceTableRowH, line.Price, "1", 0, "R", fill, 0, "") - pdf.CellFormat(widths[3], invoiceTableRowH, line.Total, "1", 1, "R", fill, 0, "") + pdf.SetTextColor(invoiceTextGray.r, invoiceTextGray.g, invoiceTextGray.b) + pdf.CellFormat(widths[0], invoiceTableRowH, " "+tr(line.Name), "", 0, "", fill, 0, "") + pdf.CellFormat(widths[1], invoiceTableRowH, fmt.Sprintf("%d", line.Quantity), "", 0, "C", fill, 0, "") + pdf.CellFormat(widths[2], invoiceTableRowH, line.Price, "", 0, "R", fill, 0, "") + pdf.SetTextColor(invoiceTextGray.r, invoiceTextGray.g, invoiceTextGray.b) + pdf.SetFont("Helvetica", "B", 10) + pdf.CellFormat(widths[3], invoiceTableRowH, line.Total+" ", "", 1, "R", fill, 0, "") + pdf.SetFont("Helvetica", "", 10) + + // Subtle separator line + lineY := pdf.GetY() + pdf.SetDrawColor(invoiceMidGray.r, invoiceMidGray.g, invoiceMidGray.b) + pdf.Line(invoiceMarginX, lineY, invoiceMarginX+invoiceContentWidth, lineY) } - pdf.Ln(6) + pdf.Ln(8) } func drawInvoicePayments(pdf *gofpdf.Fpdf, tr func(string) string, data booking.Invoice) { - pdf.SetFont("Helvetica", "B", 13) + pdf.SetFont("Helvetica", "B", 11) pdf.SetTextColor(invoiceTeal.r, invoiceTeal.g, invoiceTeal.b) - pdf.CellFormat(0, 7, tr("Historique des Paiements"), "", 1, "", false, 0, "") + pdf.CellFormat(0, 7, tr("Historique des Paiements"), "", 1, "L", false, 0, "") pdf.Ln(1) - widths := []float64{54, 84, 52} + widths := []float64{54, 88, 48} headers := []string{"Date", "Mode de Paiement", "Montant (€)"} writeTableHeader(pdf, tr, widths, headers) - pdf.SetFont("Helvetica", "", 10.5) - pdf.SetTextColor(0, 0, 0) + pdf.SetFont("Helvetica", "", 10) if len(data.Payments) == 0 { pdf.SetFillColor(invoiceLightGray.r, invoiceLightGray.g, invoiceLightGray.b) - pdf.CellFormat(widths[0], invoiceTableRowH, tr("-"), "1", 0, "", true, 0, "") - pdf.CellFormat(widths[1], invoiceTableRowH, tr("Aucun paiement enregistré"), "1", 0, "", true, 0, "") - pdf.CellFormat(widths[2], invoiceTableRowH, tr("0.00"), "1", 1, "R", true, 0, "") + pdf.SetTextColor(invoiceTextMuted.r, invoiceTextMuted.g, invoiceTextMuted.b) + pdf.CellFormat(widths[0], invoiceTableRowH, " "+tr("-"), "", 0, "", true, 0, "") + pdf.CellFormat(widths[1], invoiceTableRowH, tr("Aucun paiement enregistré"), "", 0, "", true, 0, "") + pdf.CellFormat(widths[2], invoiceTableRowH, tr("0.00")+" ", "", 1, "R", true, 0, "") } else { for i, payment := range data.Payments { fill := i%2 == 0 if fill { pdf.SetFillColor(invoiceLightGray.r, invoiceLightGray.g, invoiceLightGray.b) } - pdf.CellFormat(widths[0], invoiceTableRowH, tr(payment.Date), "1", 0, "", fill, 0, "") - pdf.CellFormat(widths[1], invoiceTableRowH, tr(payment.Method), "1", 0, "", fill, 0, "") - pdf.CellFormat(widths[2], invoiceTableRowH, payment.Amount, "1", 1, "R", fill, 0, "") + pdf.SetTextColor(invoiceTextGray.r, invoiceTextGray.g, invoiceTextGray.b) + pdf.CellFormat(widths[0], invoiceTableRowH, " "+tr(payment.Date), "", 0, "", fill, 0, "") + pdf.CellFormat(widths[1], invoiceTableRowH, tr(payment.Method), "", 0, "", fill, 0, "") + pdf.SetFont("Helvetica", "B", 10) + pdf.CellFormat(widths[2], invoiceTableRowH, payment.Amount+" ", "", 1, "R", fill, 0, "") + pdf.SetFont("Helvetica", "", 10) + + lineY := pdf.GetY() + pdf.SetDrawColor(invoiceMidGray.r, invoiceMidGray.g, invoiceMidGray.b) + pdf.Line(invoiceMarginX, lineY, invoiceMarginX+invoiceContentWidth, lineY) } } - pdf.Ln(6) + pdf.Ln(8) } func drawInvoiceSummary(pdf *gofpdf.Fpdf, tr func(string) string, data booking.Invoice) { - labels := []string{"Montant Total :", "Montant Payé :", "Solde Restant :"} - values := []string{data.Total, data.AmountPaid, data.BalanceDue} + // Right-aligned summary block + summaryX := invoiceMarginX + invoiceContentWidth - 80 + summaryW := 80.0 + + labels := []string{"Montant Total", "Montant Payé", "Solde Restant"} + values := []string{euroAmount(data.Total), euroAmount(data.AmountPaid), euroAmount(data.BalanceDue)} for i := range labels { - pdf.SetX(invoiceMarginX + 3) - pdf.SetFont("Helvetica", "B", 11) + pdf.SetX(summaryX) + pdf.SetFont("Helvetica", "", 9.5) + pdf.SetTextColor(invoiceTextMuted.r, invoiceTextMuted.g, invoiceTextMuted.b) + pdf.CellFormat(summaryW/2, invoiceSummaryLineH, tr(labels[i]), "", 0, "L", false, 0, "") + pdf.SetFont("Helvetica", "B", 10) pdf.SetTextColor(invoiceTextGray.r, invoiceTextGray.g, invoiceTextGray.b) - pdf.CellFormat(60, invoiceSummaryLineH, tr(labels[i]), "", 0, "", false, 0, "") - pdf.SetFont("Helvetica", "", 11) - pdf.SetTextColor(0, 0, 0) - pdf.CellFormat(95, invoiceSummaryLineH, "", "", 0, "", false, 0, "") - pdf.CellFormat(32, invoiceSummaryLineH, values[i], "", 1, "R", false, 0, "") + pdf.CellFormat(summaryW/2, invoiceSummaryLineH, tr(values[i]), "", 1, "R", false, 0, "") + + if i < len(labels)-1 { + lineY := pdf.GetY() + pdf.SetDrawColor(invoiceMidGray.r, invoiceMidGray.g, invoiceMidGray.b) + pdf.Line(summaryX, lineY, summaryX+summaryW, lineY) + } } - pdf.Ln(2) - pdf.SetDrawColor(invoiceMidGray.r, invoiceMidGray.g, invoiceMidGray.b) - pdf.Line(invoiceMarginX, pdf.GetY(), invoiceMarginX+invoiceContentWidth, pdf.GetY()) - pdf.Ln(6) + // Highlight the balance due + balanceY := pdf.GetY() - invoiceSummaryLineH + pdf.SetFillColor(invoiceNoteBg.r, invoiceNoteBg.g, invoiceNoteBg.b) + pdf.RoundedRect(summaryX-2, balanceY-0.5, summaryW+4, invoiceSummaryLineH+1, 1.5, "1234", "F") + pdf.SetXY(summaryX, balanceY) + pdf.SetFont("Helvetica", "B", 9.5) + pdf.SetTextColor(invoiceTeal.r, invoiceTeal.g, invoiceTeal.b) + pdf.CellFormat(summaryW/2, invoiceSummaryLineH, tr("Solde Restant"), "", 0, "L", false, 0, "") + pdf.SetFont("Helvetica", "B", 11) + pdf.CellFormat(summaryW/2, invoiceSummaryLineH, tr(euroAmount(data.BalanceDue)), "", 1, "R", false, 0, "") + + pdf.Ln(8) } -func drawInvoiceNotes(pdf *gofpdf.Fpdf, tr func(string) string, data booking.Invoice) { - notes := "Notes\nTVA non applicable, art. 293 B du CGI\nDispensé d'immatriculation au registre du commerce et des sociétés (RCS) et au répertoire des métiers.\nConditions de paiement : paiement à réception de facture. Aucun escompte consenti pour règlement anticipé ou désistement. Tout incident de paiement est passible d'intérêts de retard. Le montant des pénalités résulte de l'application aux sommes restant dues d'un taux d'intérêt légal en vigueur au moment de l'incident.\nIl sera demandé 15€ par jour par personne supplémentaire." +func drawInvoiceNotes(pdf *gofpdf.Fpdf, tr func(string) string, _ booking.Invoice) { + notes := "TVA non applicable, art. 293 B du CGI\nDispensé d'immatriculation au registre du commerce et des sociétés (RCS) et au répertoire des métiers.\nConditions de paiement : paiement à réception de facture. Aucun escompte consenti pour règlement anticipé ou désistement. Tout incident de paiement est passible d'intérêts de retard. Le montant des pénalités résulte de l'application aux sommes restant dues d'un taux d'intérêt légal en vigueur au moment de l'incident.\nIl sera demandé 15€ par jour par personne supplémentaire." - pdf.SetFont("Helvetica", "", 7.5) - lines := pdf.SplitLines([]byte(tr(notes)), invoiceContentWidth-(invoiceNotesPadding*2)) - noteBoxHeight := float64(len(lines))*3.2 + invoiceNotesPadding*2 - totalBlockHeight := noteBoxHeight + 18 - if pdf.GetY()+totalBlockHeight > 282 { + pdf.SetFont("Helvetica", "", 7) + lines := pdf.SplitLines([]byte(tr(notes)), invoiceContentWidth-(invoiceNotesPadding*2)-8) + noteBoxHeight := float64(len(lines))*3.0 + invoiceNotesPadding*2 + 6 + + if pdf.GetY()+noteBoxHeight+30 > 282 { pdf.AddPage() } y := pdf.GetY() - pdf.SetFillColor(invoiceNoteBg.r, invoiceNoteBg.g, invoiceNoteBg.b) - pdf.Rect(invoiceMarginX, y, invoiceContentWidth, noteBoxHeight, "F") + pdf.SetFillColor(invoiceLightGray.r, invoiceLightGray.g, invoiceLightGray.b) + pdf.RoundedRect(invoiceMarginX, y, invoiceContentWidth, noteBoxHeight, 2, "1234", "F") + + // Notes title pdf.SetXY(invoiceMarginX+invoiceNotesPadding, y+invoiceNotesPadding) - pdf.SetTextColor(invoiceTextGray.r, invoiceTextGray.g, invoiceTextGray.b) - pdf.MultiCell(invoiceContentWidth-(invoiceNotesPadding*2), 3.2, tr(notes), "", "L", false) + pdf.SetFont("Helvetica", "B", 7.5) + pdf.SetTextColor(invoiceTextMuted.r, invoiceTextMuted.g, invoiceTextMuted.b) + pdf.CellFormat(invoiceContentWidth-(invoiceNotesPadding*2), 4, tr("MENTIONS LÉGALES"), "", 1, "L", false, 0, "") - pdf.Ln(4) - pdf.SetDrawColor(invoiceMidGray.r, invoiceMidGray.g, invoiceMidGray.b) - pdf.Line(invoiceMarginX, pdf.GetY(), invoiceMarginX+invoiceContentWidth, pdf.GetY()) - pdf.Ln(4) + pdf.SetX(invoiceMarginX + invoiceNotesPadding) + pdf.SetFont("Helvetica", "", 7) + pdf.SetTextColor(invoiceTextMuted.r, invoiceTextMuted.g, invoiceTextMuted.b) + pdf.MultiCell(invoiceContentWidth-(invoiceNotesPadding*2), 3.0, tr(notes), "", "L", false) - pdf.SetTextColor(invoiceTextGray.r, invoiceTextGray.g, invoiceTextGray.b) - pdf.SetFont("Helvetica", "B", 11) - pdf.CellFormat(0, 5, tr("Total"), "", 1, "R", false, 0, "") - pdf.SetTextColor(invoiceTeal.r, invoiceTeal.g, invoiceTeal.b) - pdf.SetFont("Helvetica", "B", 15) - pdf.CellFormat(0, 7, tr(euroAmount(data.Total)), "", 0, "R", false, 0, "") + pdf.SetY(y + noteBoxHeight + 6) +} + +func drawInvoiceFooter(pdf *gofpdf.Fpdf, tr func(string) string, data booking.Invoice) { + // Total banner at the bottom + footerH := 14.0 + y := pdf.GetY() + + pdf.SetFillColor(invoiceTeal.r, invoiceTeal.g, invoiceTeal.b) + pdf.RoundedRect(invoiceMarginX, y, invoiceContentWidth, footerH, 2, "1234", "F") + + pdf.SetXY(invoiceMarginX+6, y+2) + pdf.SetFont("Helvetica", "", 9) + pdf.SetTextColor(255, 255, 255) + pdf.CellFormat(60, 10, tr("TOTAL À PAYER"), "", 0, "L", false, 0, "") + + pdf.SetFont("Helvetica", "B", 16) + pdf.CellFormat(invoiceContentWidth-72, 10, tr(euroAmount(data.Total)), "", 0, "R", false, 0, "") } func writeTableHeader(pdf *gofpdf.Fpdf, tr func(string) string, widths []float64, headers []string) { pdf.SetFillColor(invoiceTeal.r, invoiceTeal.g, invoiceTeal.b) pdf.SetTextColor(255, 255, 255) - pdf.SetFont("Helvetica", "B", 10) + pdf.SetFont("Helvetica", "B", 9) alignments := []string{"L", "C", "R", "R"} if len(widths) == 3 { alignments = []string{"L", "L", "R"} @@ -289,10 +414,27 @@ func writeTableHeader(pdf *gofpdf.Fpdf, tr func(string) string, widths []float64 if i == len(headers)-1 { end = 1 } - pdf.CellFormat(widths[i], 8, tr(header), "1", end, alignments[i], true, 0, "") + pad := "" + if i == 0 { + pad = " " + } + pdf.CellFormat(widths[i], 8, pad+tr(header), "", end, alignments[i], true, 0, "") } } +func nightsCount(from, to string) string { + f, err1 := time.Parse("02/01/2006", from) + t, err2 := time.Parse("02/01/2006", to) + if err1 != nil || err2 != nil { + return "-" + } + days := int(t.Sub(f).Hours() / 24) + if days < 0 { + days = 0 + } + return fmt.Sprintf("%d", days) +} + func euroAmount(value string) string { return fmt.Sprintf("%s €", value) }