Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@ How to use this file:

## [Unreleased]

### Added

- A person with payslips of several years can see one year at a time, or
all: a choice of years under their name narrows the whole report, its
months, charts and totals, and keeps the tab shown. A month is still
compared with the one before it in the year before.

## [0.9.0] - 2026-10-22

### Added
Expand Down
2 changes: 2 additions & 0 deletions cmd/cedolo/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,7 @@ func runSynth(args []string) error {
password := fs.String("password", "", "protect the PDF with this password")
rotate := fs.Int("rotate", 0, "turn the page by 90, 180 or 270 degrees")
month := fs.Int("month", 10, "month of the payslip, 1 to 12")
year := fs.Int("year", 2026, "year of the payslip")
person := fs.String("person", "", "worker's name, surname first, printed with an invented codice fiscale (classic style)")
company := fs.String("employer", "", "employer's name, printed with an invented VAT number (classic style)")
if err := fs.Parse(args); err != nil {
Expand All @@ -258,6 +259,7 @@ func runSynth(args []string) error {
opts := synth.Options{Password: *password, Rotate: *rotate}
p := synth.ExampleFor(style)
p.Month = time.Month(*month)
p.Year = *year
if *person != "" {
p.Employee, p.EmployeeCode = strings.ToUpper(*person), synth.CodiceFiscale(*person)
}
Expand Down
4 changes: 4 additions & 0 deletions internal/i18n/i18n.go
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,8 @@ var catalog = map[Lang]map[string]string{
"report.aside": "Set aside for you",
"report.aside.withheld": "Of this, %s is withheld from your pay: you also find it among the other deductions.",
"report.people": "Whose payslips",
"report.years.all": "All",
"report.years.pick": "Year",
"report.person.unknown": "Without fiscal code",
"report.info": "Other information",
"report.info.text": "They do not change this month's pay: amounts paid by the employer, or values for statistics.",
Expand Down Expand Up @@ -409,6 +411,8 @@ var catalog = map[Lang]map[string]string{
"report.aside": "Messo da parte per te",
"report.aside.withheld": "Di questi, %s sono trattenuti dalla tua busta: li trovi anche tra le altre trattenute.",
"report.people": "Di chi sono i cedolini",
"report.years.all": "Tutti",
"report.years.pick": "Anno",
"report.person.unknown": "Senza codice fiscale",
"report.info": "Altre informazioni",
"report.info.text": "Non cambiano la paga del mese: importi a carico dell'azienda o dati statistici.",
Expand Down
105 changes: 80 additions & 25 deletions internal/report/report.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,10 @@ type Page struct {
// %s for the month, filled in by the page.
OpenMonth string
People []Data
Version string
// Views are every report the page can show: each person's, and each of
// their years when there are several.
Views []Data
Version string
}

// Data is everything the page shows of one person; BuildPage builds it
Expand All @@ -135,6 +138,11 @@ type Data struct {
// several; Person names them at the top of their report when there is
// one.
Who, Person string
// Index is the person's place among People; Year the year shown, 0 for
// all, and Choices the years to choose from, when there are several.
Index int
Year int
Choices []Choice
// OpenMonth is the label of the button under a touched column, with a
// %s for the month, filled in by the page.
OpenMonth string
Expand Down Expand Up @@ -193,27 +201,58 @@ func BuildPage(l i18n.Lang, slips []payslip.Payslip, version string) Page {
insights.Sort(slips)
pg := Page{Lang: string(l), T: texts(l), OpenMonth: i18n.Template(l, "report.chart.open"), Version: version}
groups := people(slips)
for _, g := range groups {
d := Build(l, g, version)
for n, g := range groups {
who := g[0].Person
for _, p := range g {
if p.Person.ID != "" {
who = p.Person
break
}
}
d.Who = PersonLabel(l, who)
if len(groups) == 1 && who.ID != "" {
d.Person = d.Who
// The person's report, and one for each year when there are
// several: the same choice of years tops each of them.
var ys []int
for _, p := range g {
if p.Year != 0 && !slices.Contains(ys, p.Year) {
ys = append(ys, p.Year)
}
}
if len(ys) < 2 {
ys = nil
}
for _, y := range append([]int{0}, ys...) {
d := build(l, g, version, y)
d.Index = n
d.Who = PersonLabel(l, who)
if len(groups) == 1 && who.ID != "" {
d.Person = d.Who
}
if ys != nil {
d.Choices = append(d.Choices, Choice{Label: i18n.T(l, "report.years.all"), On: y == 0})
for _, c := range ys {
d.Choices = append(d.Choices, Choice{Label: strconv.Itoa(c), Year: strconv.Itoa(c), On: c == y})
}
}
if y == 0 {
pg.People = append(pg.People, d)
}
pg.Views = append(pg.Views, d)
}
pg.People = append(pg.People, d)
}
if len(pg.People) == 0 {
pg.People = []Data{Build(l, nil, version)}
pg.Views = pg.People
}
return pg
}

// Choice is a button of the choice of years.
type Choice struct {
Label string
Year string // empty for all of them
On bool
}

// PersonLabel names a person: by the name read, or else by the masked
// codice fiscale, or as one without it.
func PersonLabel(l i18n.Lang, p payslip.Party) string {
Expand Down Expand Up @@ -315,19 +354,35 @@ func shortEmployer(e payslip.Party) string {

// Build computes the page content. The slice is sorted in place.
func Build(l i18n.Lang, slips []payslip.Payslip, version string) Data {
return build(l, slips, version, 0)
}

// build computes the page content of the payslips of one year, or of all
// of them for year 0. A month is still compared with the one before it in
// another year. The slice is sorted in place.
func build(l i18n.Lang, slips []payslip.Payslip, version string, year int) Data {
periods := insights.Compare(l, slips) // sorts slips
d := Data{Lang: string(l), T: texts(l), OpenMonth: i18n.Template(l, "report.chart.open"), Version: version}
if len(slips) == 0 {
d := Data{Lang: string(l), T: texts(l), OpenMonth: i18n.Template(l, "report.chart.open"), Version: version, Year: year}
var kept []int
var shown []payslip.Payslip
for i, p := range slips {
if year == 0 || p.Year == year {
kept = append(kept, i)
shown = append(shown, p)
}
}
if len(kept) == 0 {
return d
}
if len(slips) == 1 {
d.Range = i18n.T(l, "report.range.one", periods[0].Label)
if len(kept) == 1 {
d.Range = i18n.T(l, "report.range.one", periods[kept[0]].Label)
} else {
d.Range = i18n.T(l, "report.range", len(slips), periods[0].Label, periods[len(periods)-1].Label)
d.Range = i18n.T(l, "report.range", len(kept), periods[kept[0]].Label, periods[kept[len(kept)-1]].Label)
}

names := employers(slips)
for i, p := range slips {
for k, i := range kept {
p := slips[i]
st := status(p)
d.Rows = append(d.Rows, Row{
Period: periods[i].Label,
Expand All @@ -343,7 +398,7 @@ func Build(l i18n.Lang, slips []payslip.Payslip, version string) Data {
m := month(l, p, periods[i])
if name, ok := names[p.Employer.ID]; ok {
m.Label += " · " + name
if sameShort(slips, i) {
if sameShort(shown, k) {
m.Short += " · " + name
}
}
Expand Down Expand Up @@ -378,19 +433,19 @@ func Build(l i18n.Lang, slips []payslip.Payslip, version string) Data {
}
}
// The latest payslip opens first, a thirteenth or fourteenth month too.
d.Selected = len(slips) - 1

if len(slips) > 1 {
d.Trend = template.HTML(trend(l, slips))
d.Variable = template.HTML(variablePay(l, slips))
d.Years = years(l, slips)
d.TFR, d.TFRTotal, d.TFRSpan = tfrChart(l, slips)
d.Fund, d.FundTotal, d.FundSpan = fundChart(l, slips)
d.Selected = len(shown) - 1

if len(shown) > 1 {
d.Trend = template.HTML(trend(l, shown))
d.Variable = template.HTML(variablePay(l, shown))
d.Years = years(l, shown)
d.TFR, d.TFRTotal, d.TFRSpan = tfrChart(l, shown)
d.Fund, d.FundTotal, d.FundSpan = fundChart(l, shown)
if d.Fund != "" {
d.FundYears = fundYears(l, slips)
d.FundYears = fundYears(l, shown)
}
}
d.LeaveCharts = leaveCharts(l, slips)
d.LeaveCharts = leaveCharts(l, shown)
d.Time = d.Years != nil || d.Trend != "" || d.Variable != ""
d.Accrued = len(d.LeaveCharts) > 0 || d.TFR != "" || d.Fund != ""
d.Tabs = d.Time || d.Accrued
Expand All @@ -401,7 +456,7 @@ func Build(l i18n.Lang, slips []payslip.Payslip, version string) Data {
}
}

d.Contract = contract(l, slips)
d.Contract = contract(l, shown)
for _, term := range i18n.Terms {
d.Terms = append(d.Terms, Term{Label: i18n.T(l, "term."+term), Text: i18n.T(l, "term."+term+".text")})
}
Expand Down
41 changes: 29 additions & 12 deletions internal/report/report.html.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,9 @@ p { margin: 0 0 12px; }
.people button { font: inherit; font-size: 15px; font-weight: 600; padding: 8px 14px; border-radius: 999px; border: 1px solid var(--grid); background: var(--raised); color: var(--ink); cursor: pointer; }
.people button[aria-pressed="true"] { background: var(--ink); color: var(--surface); border-color: var(--ink); }
.who .person { font-size: 17px; font-weight: 600; margin: 4px 0 0; }
.yearpick { display: flex; gap: 2px; margin: 10px 0 2px; padding: 3px; width: fit-content; max-width: 100%; overflow-x: auto; scrollbar-width: none; border-radius: 10px; background: var(--grid); }
.yearpick button { flex: none; font: inherit; font-size: 14px; padding: 5px 12px; border: 0; border-radius: 8px; background: none; color: var(--ink-2); cursor: pointer; font-variant-numeric: tabular-nums; }
.yearpick button[aria-pressed="true"] { background: var(--raised); color: var(--ink); font-weight: 600; box-shadow: 0 1px 2px rgb(0 0 0 / .12); }
.month { margin-top: 24px; }
.when { color: var(--ink-2); margin: 0; }
.when .status { font-size: 14px; margin-left: 8px; }
Expand Down Expand Up @@ -230,7 +233,7 @@ footer { margin-top: 32px; font-size: 13px; color: var(--muted); }
.chart.narrow { display: block; }
}
@media print {
.tooltip, .picker, .hint, .tabs, .steps, .people { display: none; }
.tooltip, .picker, .hint, .tabs, .steps, .people, .yearpick { display: none; }
.panel[hidden] { display: block !important; }
.tabs ~ .panel h2.group { display: block; }
.month[hidden] { display: block !important; break-before: page; }
Expand All @@ -246,7 +249,7 @@ footer { margin-top: 32px; font-size: 13px; color: var(--muted); }
{{end}}</nav>
{{end}}</header>
<div id="person">{{template "person" index .People 0}}</div>
{{if gt (len .People) 1}}{{range $i, $p := .People}}<template data-person="{{$i}}">{{template "person" $p}}</template>
{{if gt (len .Views) 1}}{{range .Views}}<template data-person="{{.Index}}" data-year="{{if .Year}}{{.Year}}{{end}}">{{template "person" .}}</template>
{{end}}{{end}}
<footer>
<p>{{index .T "report.privacy"}}</p>
Expand Down Expand Up @@ -557,25 +560,38 @@ footer { margin-top: 32px; font-size: 13px; color: var(--muted); }
}, { signal: signal });
}
}
// The report of one person at a time: the others wait in templates, and
// a person chosen replaces the one shown. Listeners of the page as a
// whole end with the person they were added for.
// The report of one person at a time, and of one year or all: the others
// wait in templates, and the one chosen replaces the one shown, on the
// same tab. Listeners of the page as a whole end with the report they
// were added for.
var embedded = window.parent !== window;
var person = document.getElementById("person");
var shown = new AbortController();
init(shown.signal);
var who = document.querySelectorAll(".people button");
function view(p, year) {
var t = document.querySelector('template[data-person="' + p + '"][data-year="' + year + '"]');
if (!t) return;
var tab = (document.querySelector('[role="tab"][aria-selected="true"]') || {}).dataset;
shown.abort();
person.replaceChildren(t.content.cloneNode(true));
shown = new AbortController();
init(shown.signal);
var again = tab && document.querySelector('[role="tab"][data-tab="' + tab.tab + '"]');
if (again) again.click();
}
who.forEach(function (b) {
b.addEventListener("click", function () {
if (b.getAttribute("aria-pressed") === "true") return;
shown.abort();
who.forEach(function (o) { o.setAttribute("aria-pressed", String(o === b)); });
var t = document.querySelector('template[data-person="' + b.dataset.person + '"]');
person.replaceChildren(t.content.cloneNode(true));
shown = new AbortController();
init(shown.signal);
view(b.dataset.person, "");
});
});
person.addEventListener("click", function (ev) {
var b = ev.target.closest(".yearpick button");
if (!b || b.getAttribute("aria-pressed") === "true") return;
view(person.querySelector(".who").dataset.person, b.dataset.year);
});
// Printing shows everything.
window.addEventListener("beforeprint", function () {
document.querySelectorAll("details").forEach(function (d) { d.open = true; });
Expand All @@ -597,9 +613,10 @@ footer { margin-top: 32px; font-size: 13px; color: var(--muted); }
</body>
</html>
{{define "line"}}<li><details><summary><span class="desc">{{.Description}}</span><span class="amount">{{.Amount}}</span></summary><p class="explain"><strong>{{.Label}}</strong> · {{.Text}}</p></details></li>{{end}}
{{define "person"}}<div class="who">
{{define "person"}}<div class="who" data-person="{{.Index}}">
{{with .Person}}<p class="person">{{.}}</p>{{end}}
{{with .Range}}<p class="sub">{{.}}</p>{{end}}
{{with .Choices}}<nav class="yearpick" aria-label="{{index $.T "report.years.pick"}}">{{range .}}<button type="button" data-year="{{.Year}}" aria-pressed="{{if .On}}true{{else}}false{{end}}">{{.Label}}</button>{{end}}</nav>
{{end}}{{with .Range}}<p class="sub">{{.}}</p>{{end}}
</div>

{{if .Tabs}}
Expand Down
31 changes: 31 additions & 0 deletions internal/report/report_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bytes"
"crypto/sha256"
"encoding/base64"
"fmt"
"html"
"slices"
"strings"
Expand Down Expand Up @@ -611,3 +612,33 @@ func TestPeople(t *testing.T) {
t.Errorf("one person: %d, %q", len(one.People), one.People[0].Person)
}
}

// TestYears gives a person with payslips of several years a report for
// each year besides the whole, all with the same choice of years; a month
// is still compared with the one before it in another year.
func TestYears(t *testing.T) {
a := func(c int64) payslip.Amount { return payslip.Amount{Cents: c, Status: payslip.Verified} }
slip := func(year, month int, net int64) payslip.Payslip {
return payslip.Payslip{Year: year, Month: month, Kind: payslip.Monthly,
Items: []payslip.Item{{Key: "earnings.base_salary", Side: payslip.Earning, Cents: 200000}},
Gross: a(200000), Net: a(net), Deductions: a(200000 - net)}
}
pg := BuildPage(i18n.EN, []payslip.Payslip{slip(2025, 12, 150000), slip(2026, 1, 140000), slip(2026, 2, 140000)}, "test")
if len(pg.People) != 1 || len(pg.Views) != 3 {
t.Fatalf("people %d, views %d", len(pg.People), len(pg.Views))
}
var labels []string
for _, c := range pg.Views[2].Choices {
labels = append(labels, fmt.Sprintf("%s:%v", c.Label, c.On))
}
if strings.Join(labels, " ") != "All:false 2025:false 2026:true" {
t.Errorf("choices = %v", labels)
}
y := pg.Views[2]
if y.Year != 2026 || len(y.Months) != 2 || !strings.Contains(y.Months[0].Summary, "December 2025") {
t.Errorf("2026: year %d, %d months, summary %q", y.Year, len(y.Months), y.Months[0].Summary)
}
if one := BuildPage(i18n.EN, []payslip.Payslip{slip(2026, 1, 140000), slip(2026, 2, 140000)}, "test"); len(one.Views) != 1 || one.People[0].Choices != nil {
t.Errorf("one year: %d views", len(one.Views))
}
}
26 changes: 26 additions & 0 deletions web/test/e2e.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,32 @@ test('shows one person at a time', async () => {
await page.close();
});

test('shows one year at a time, or all', async () => {
const files = [
synth('y-2025-11.pdf', '-year', '2025', '-month', '11'),
synth('y-2025-12.pdf', '-year', '2025', '-month', '12'),
synth('y-2026-01.pdf', '-year', '2026', '-month', '1'),
];
const { page, seen } = await open({ viewport: { width: 390, height: 800 } });
await page.setInputFiles('#files', files);
await statuses(page);
await page.waitForSelector('#report:not(.pending)');
const frame = page.frames().find((f) => f !== page.mainFrame());
assert.deepEqual(await frame.$$eval('.yearpick button', (bs) => bs.map((b) => b.textContent)), ['All', '2025', '2026']);
// A year keeps the tab shown, and its months only.
await press(page, '#tabs [data-tab="time"]');
await frame.waitForSelector('#panel-time:not([hidden])');
await press(frame, '.yearpick button[data-year="2025"]');
await frame.waitForSelector('.yearpick button[data-year="2025"][aria-pressed="true"]');
assert.ok(!(await frame.$eval('#panel-time', (p) => p.hidden)), 'still over time');
assert.equal(await frame.$$eval('section.month', (ms) => ms.length), 2);
await press(frame, '.yearpick button[data-year=""]');
await frame.waitForSelector('.yearpick button[data-year=""][aria-pressed="true"]');
assert.equal(await frame.$$eval('section.month', (ms) => ms.length), 3);
assert.deepEqual(seen.errors, []);
await page.close();
});

test('adds payslips to a report', async () => {
const [one, two, three] = [1, 2, 3].map((m) => synth(`add-${m}.pdf`, '-month', String(m)));
const { page, seen } = await open();
Expand Down
Loading