All checks were successful
Deploy to K8s / deploy (push) Successful in 12s
Instead of hiding older months entirely, show all months in the from/to selectors but default the from-select to the last MONTHS_TO_SHOW months on page load. The "All" button resets to full history as before. Python: passes months_to_show to render_template, IIFE sets fromSelect.value. Go: adds MonthsToShow to response structs, data-months-to-show attr in templates, filters.js reads it and defaults fromSelect after hideFutureMonths. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
278 lines
7.9 KiB
Go
278 lines
7.9 KiB
Go
package api
|
|
|
|
import (
|
|
"fmt"
|
|
"fuj-management/go/internal/config"
|
|
"fuj-management/go/internal/services/membership"
|
|
"sort"
|
|
"strconv"
|
|
|
|
domreconcile "fuj-management/go/internal/domain/reconcile"
|
|
)
|
|
|
|
// buildJuniorsResponse constructs the JuniorsResponse wire type from reconcile output.
|
|
// Mirrors scripts/views.py:build_juniors_view_model.
|
|
func buildJuniorsResponse(
|
|
members []domreconcile.Member,
|
|
sortedMonths []string,
|
|
result domreconcile.Result,
|
|
txns []domreconcile.Transaction,
|
|
cfg config.Config,
|
|
currentMonth string,
|
|
) JuniorsResponse {
|
|
monthLabels := getMonthLabels(sortedMonths, membership.JuniorMergedMonths)
|
|
|
|
allNames := make([]string, 0, len(members))
|
|
juniorNames := make([]string, 0, len(members))
|
|
for _, m := range members {
|
|
allNames = append(allNames, m.Name)
|
|
juniorNames = append(juniorNames, m.Name)
|
|
}
|
|
sort.Strings(juniorNames)
|
|
|
|
monthlyTotals := make(map[string]*monthSums, len(sortedMonths))
|
|
for _, m := range sortedMonths {
|
|
monthlyTotals[m] = &monthSums{}
|
|
}
|
|
|
|
var results []MemberRow
|
|
for _, name := range juniorNames {
|
|
mr := result.Members[name]
|
|
row, unpaidMonths, rawUnpaidMonths := buildJuniorMemberRow(name, mr, sortedMonths, monthLabels, currentMonth, monthlyTotals)
|
|
row.UnpaidPeriods = joinComma(unpaidMonths)
|
|
row.RawUnpaidPeriods = joinPlus(rawUnpaidMonths)
|
|
row.Balance = settledBalance(mr, currentMonth)
|
|
row.PayableAmount = max(0, -row.Balance)
|
|
results = append(results, row)
|
|
}
|
|
|
|
// Totals row.
|
|
totalsCells := make([]TotalCell, len(sortedMonths))
|
|
for i, m := range sortedMonths {
|
|
t := monthlyTotals[m] // *monthSums, never nil (initialised above)
|
|
status := "empty"
|
|
if t.expected > 0 || t.paid > 0 {
|
|
switch {
|
|
case t.paid == t.expected:
|
|
status = "ok"
|
|
case t.paid < t.expected:
|
|
status = "unpaid"
|
|
default:
|
|
status = "surplus"
|
|
}
|
|
}
|
|
totalsCells[i] = TotalCell{
|
|
Text: fmt.Sprintf("%d / %d CZK", t.paid, t.expected),
|
|
Status: status,
|
|
}
|
|
}
|
|
|
|
var credits, debts []Credit
|
|
for _, name := range juniorNames {
|
|
bal := settledBalance(result.Members[name], currentMonth)
|
|
if bal > 0 {
|
|
credits = append(credits, Credit{Name: name, Amount: bal})
|
|
} else if bal < 0 {
|
|
debts = append(debts, Credit{Name: name, Amount: -bal})
|
|
}
|
|
}
|
|
sort.Slice(credits, func(i, j int) bool { return credits[i].Name < credits[j].Name })
|
|
sort.Slice(debts, func(i, j int) bool { return debts[i].Name < debts[j].Name })
|
|
|
|
// member_data: full reconcile output for all junior members.
|
|
memberData := make(map[string]JuniorsMemberData, len(result.Members))
|
|
for name, mr := range result.Members {
|
|
months := make(map[string]JuniorsMonthData, len(mr.Months))
|
|
for m, md := range mr.Months {
|
|
var exc *ExceptionData
|
|
if md.Exception != nil {
|
|
exc = &ExceptionData{Amount: md.Exception.Amount, Note: md.Exception.Note}
|
|
}
|
|
txEntries := make([]MemberTxEntry, len(md.Transactions))
|
|
for i, te := range md.Transactions {
|
|
txEntries[i] = memberTxFromDomain(te)
|
|
}
|
|
months[m] = JuniorsMonthData{
|
|
Expected: juniorExpected(md),
|
|
OriginalExpected: juniorOriginalExpected(md),
|
|
AttendanceCount: md.AttendanceCount,
|
|
Exception: exc,
|
|
Paid: md.Paid,
|
|
Transactions: txEntries,
|
|
}
|
|
}
|
|
otherTxs := make([]MemberOtherEntry, len(mr.OtherTransactions))
|
|
for i, oe := range mr.OtherTransactions {
|
|
otherTxs[i] = memberOtherFromDomain(oe)
|
|
}
|
|
memberData[name] = JuniorsMemberData{
|
|
Tier: mr.Tier,
|
|
Months: months,
|
|
OtherTransactions: otherTxs,
|
|
TotalBalance: mr.TotalBalance,
|
|
}
|
|
}
|
|
|
|
unmatched := make([]RawTransaction, len(result.Unmatched))
|
|
for i, tx := range result.Unmatched {
|
|
unmatched[i] = rawTxFromDomain(tx)
|
|
}
|
|
|
|
juniorURL := "https://docs.google.com/spreadsheets/d/" + config.AttendanceSheetID +
|
|
"/edit#gid=" + config.JuniorSheetGID
|
|
|
|
return JuniorsResponse{
|
|
Months: labelsForMonths(sortedMonths, monthLabels),
|
|
RawMonths: sortedMonths,
|
|
Results: ensureSlice(results),
|
|
Totals: totalsCells,
|
|
MemberData: memberData,
|
|
MonthLabels: monthLabels,
|
|
RawPayments: groupRawPaymentsByPerson(txns, allNames),
|
|
Credits: ensureSlice(credits),
|
|
Debts: ensureSlice(debts),
|
|
Unmatched: unmatched,
|
|
AttendanceURL: juniorURL,
|
|
PaymentsURL: "https://docs.google.com/spreadsheets/d/" + config.PaymentsSheetID + "/edit",
|
|
BankAccount: cfg.QRAccount,
|
|
CurrentMonth: currentMonth,
|
|
MonthsToShow: cfg.MonthsToShow,
|
|
}
|
|
}
|
|
|
|
func buildJuniorMemberRow(
|
|
name string,
|
|
mr domreconcile.MemberResult,
|
|
sortedMonths []string,
|
|
monthLabels map[string]string,
|
|
currentMonth string,
|
|
monthlyTotals map[string]*monthSums,
|
|
) (row MemberRow, unpaidMonths, rawUnpaidMonths []string) {
|
|
row = MemberRow{Name: name}
|
|
for _, m := range sortedMonths {
|
|
md, ok := mr.Months[m]
|
|
if !ok {
|
|
md = domreconcile.MonthData{}
|
|
}
|
|
paid := int(md.Paid)
|
|
|
|
// Update monthly totals (skip "?" months for expected).
|
|
if t := monthlyTotals[m]; t != nil {
|
|
if !md.IsUnknown {
|
|
t.expected += md.Expected
|
|
}
|
|
t.paid += paid
|
|
}
|
|
|
|
// Attendance breakdown string e.g. ":3J,2A".
|
|
var breakdown string
|
|
jc, ac := md.JuniorAttendance, md.AdultAttendance
|
|
switch {
|
|
case jc > 0 && ac > 0:
|
|
breakdown = fmt.Sprintf(":%dJ,%dA", jc, ac)
|
|
case jc > 0:
|
|
breakdown = fmt.Sprintf(":%dJ", jc)
|
|
case ac > 0:
|
|
breakdown = fmt.Sprintf(":%dA", ac)
|
|
}
|
|
countStr := ""
|
|
if md.AttendanceCount > 0 {
|
|
countStr = fmt.Sprintf(" (%d%s)", md.AttendanceCount, breakdown)
|
|
}
|
|
|
|
// Fee display string.
|
|
var feeDisplay string
|
|
var isOverridden bool
|
|
if md.Exception != nil {
|
|
overrideAmount := md.Exception.Amount
|
|
var origStr string
|
|
if md.IsUnknown {
|
|
origStr = "?"
|
|
isOverridden = true
|
|
} else {
|
|
origStr = strconv.Itoa(md.OriginalExpected)
|
|
isOverridden = overrideAmount != md.OriginalExpected
|
|
}
|
|
if isOverridden {
|
|
feeDisplay = fmt.Sprintf("%d (%s) CZK%s", overrideAmount, origStr, countStr)
|
|
} else {
|
|
feeDisplay = fmt.Sprintf("%d CZK%s", md.Expected, countStr)
|
|
}
|
|
} else {
|
|
if md.IsUnknown {
|
|
feeDisplay = "? CZK" + countStr
|
|
} else {
|
|
feeDisplay = fmt.Sprintf("%d CZK%s", md.Expected, countStr)
|
|
}
|
|
}
|
|
|
|
status := "empty"
|
|
cellText := "-"
|
|
amountToPay := 0
|
|
|
|
switch {
|
|
case md.IsUnknown:
|
|
cellText = "?" + countStr
|
|
case md.Expected > 0:
|
|
switch {
|
|
case paid >= md.Expected:
|
|
status = "ok"
|
|
cellText = fmt.Sprintf("%d/%s", paid, feeDisplay)
|
|
case paid > 0:
|
|
status = "partial"
|
|
cellText = fmt.Sprintf("%d/%s", paid, feeDisplay)
|
|
amountToPay = md.Expected - paid
|
|
if m < currentMonth {
|
|
unpaidMonths = append(unpaidMonths, monthLabels[m])
|
|
rawUnpaidMonths = append(rawUnpaidMonths, rawMonthLabel(m))
|
|
}
|
|
default:
|
|
status = "unpaid"
|
|
cellText = fmt.Sprintf("0/%s", feeDisplay)
|
|
amountToPay = md.Expected
|
|
if m < currentMonth {
|
|
unpaidMonths = append(unpaidMonths, monthLabels[m])
|
|
rawUnpaidMonths = append(rawUnpaidMonths, rawMonthLabel(m))
|
|
}
|
|
}
|
|
case paid > 0:
|
|
status = "surplus"
|
|
cellText = fmt.Sprintf("PAID %d", paid)
|
|
}
|
|
|
|
tooltip := ""
|
|
if (!md.IsUnknown && md.Expected > 0) || paid > 0 {
|
|
tooltip = fmt.Sprintf("Received: %d, Expected: %d", paid, md.Expected)
|
|
}
|
|
|
|
row.Months = append(row.Months, MonthCell{
|
|
Text: cellText,
|
|
Overridden: isOverridden,
|
|
Status: status,
|
|
Amount: amountToPay,
|
|
Month: monthLabels[m],
|
|
RawMonth: m,
|
|
Tooltip: tooltip,
|
|
})
|
|
}
|
|
return row, unpaidMonths, rawUnpaidMonths
|
|
}
|
|
|
|
// juniorExpected converts domain MonthData to the Expected wire type.
|
|
// When an exception exists it always produces a concrete int; otherwise
|
|
// the "?" sentinel is used when IsUnknown=true.
|
|
func juniorExpected(md domreconcile.MonthData) Expected {
|
|
if md.Exception == nil && md.IsUnknown {
|
|
return Expected{Unknown: true}
|
|
}
|
|
return Expected{Value: md.Expected}
|
|
}
|
|
|
|
// juniorOriginalExpected converts the original (pre-exception) expected fee.
|
|
func juniorOriginalExpected(md domreconcile.MonthData) Expected {
|
|
if md.IsUnknown {
|
|
return Expected{Unknown: true}
|
|
}
|
|
return Expected{Value: md.OriginalExpected}
|
|
}
|