feat(finance): org Phases 2-5 — events, requests, ledger, analysis, report (#20)
Phase 2 — Event planning: - Create/edit/delete/submit events per fiscal year - Budget lines (income + expense) with planned amounts - Admin review flow: approve / reject / comment - Post-mortem feedback comments on closed years Phase 3 — Transaction requests: - 5 types: reimbursement, purchase_order, cash_advance, income, budget_transfer - Full status machine with append-only StatusLog audit trail - PO delivery sub-form (actual vendor, amount, invoice note) - Cash advance settlement sub-form (spent + returned) - Ledger view per fiscal year with income/expense summary - Bank CSV import: parse → preview → confirm → ledger entries Phase 4 — Plan vs actual analysis: - Variance table by event (planned vs actual income/expense) - Variance table by team Phase 5 — Year-end report: - Executive summary with net variance - Per-event section: budget lines + team feedback comments - Feedback form available on closed fiscal years Supporting changes: - New store methods: getLedgerEntries, createLedgerEntry, updateLedgerEntry, getAttachments, createAttachment - New template funcs: dateInput, statusColor, varColor - parseEuroAmount + parseBankCSV helpers in handler_org.go Co-authored-by: Gonçalo Rodrigues <guga@Goncalos-MacBook-Pro.local> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
1a935aa8ff
commit
0f58a51c6d
@ -98,6 +98,35 @@ func parseTmpl(files ...string) *template.Template {
|
|||||||
"isOver": func(spent, budget int64) bool {
|
"isOver": func(spent, budget int64) bool {
|
||||||
return budget > 0 && spent > budget
|
return budget > 0 && spent > budget
|
||||||
},
|
},
|
||||||
|
"dateInput": func(t time.Time) string {
|
||||||
|
if t.IsZero() {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return t.Format("2006-01-02")
|
||||||
|
},
|
||||||
|
"statusColor": func(s string) string {
|
||||||
|
switch s {
|
||||||
|
case "approved", "paid", "delivered", "settled", "received", "reconciled", "done":
|
||||||
|
return "rgba(74,222,128,0.12); color:var(--green)"
|
||||||
|
case "submitted", "under_review", "review", "ordered", "disbursed", "pending_payment":
|
||||||
|
return "rgba(99,179,237,0.12); color:#63b3ed"
|
||||||
|
case "rejected", "cancelled", "disputed":
|
||||||
|
return "rgba(239,68,68,0.12); color:var(--red)"
|
||||||
|
case "info_requested", "settlement_due", "partial_settlement":
|
||||||
|
return "rgba(251,191,36,0.1); color:#fbbf24"
|
||||||
|
default:
|
||||||
|
return "var(--bg3); color:var(--text3)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"varColor": func(planned, actual int64) string {
|
||||||
|
if planned == 0 {
|
||||||
|
return "var(--text2)"
|
||||||
|
}
|
||||||
|
if actual > planned {
|
||||||
|
return "var(--red)"
|
||||||
|
}
|
||||||
|
return "var(--green)"
|
||||||
|
},
|
||||||
"jsonVals": func(m map[string]int64) template.JS {
|
"jsonVals": func(m map[string]int64) template.JS {
|
||||||
var vals []string
|
var vals []string
|
||||||
for _, v := range m {
|
for _, v := range m {
|
||||||
@ -136,6 +165,14 @@ var (
|
|||||||
orgMembersTmpl = parseTmpl("templates/base.html", "templates/org_members.html")
|
orgMembersTmpl = parseTmpl("templates/base.html", "templates/org_members.html")
|
||||||
orgInviteTmpl = parseTmpl("templates/base.html", "templates/org_invite.html")
|
orgInviteTmpl = parseTmpl("templates/base.html", "templates/org_invite.html")
|
||||||
orgJoinTmpl = parseTmpl("templates/base.html", "templates/org_join.html")
|
orgJoinTmpl = parseTmpl("templates/base.html", "templates/org_join.html")
|
||||||
|
orgEventsTmpl = parseTmpl("templates/base.html", "templates/org_events.html")
|
||||||
|
orgEventDetailTmpl = parseTmpl("templates/base.html", "templates/org_event_detail.html")
|
||||||
|
orgRequestsTmpl = parseTmpl("templates/base.html", "templates/org_requests.html")
|
||||||
|
orgRequestDetailTmpl = parseTmpl("templates/base.html", "templates/org_request_detail.html")
|
||||||
|
orgLedgerTmpl = parseTmpl("templates/base.html", "templates/org_ledger.html")
|
||||||
|
orgBankImportTmpl = parseTmpl("templates/base.html", "templates/org_bank_import.html")
|
||||||
|
orgAnalysisTmpl = parseTmpl("templates/base.html", "templates/org_analysis.html")
|
||||||
|
orgReportTmpl = parseTmpl("templates/base.html", "templates/org_report.html")
|
||||||
)
|
)
|
||||||
|
|
||||||
type authInfo struct {
|
type authInfo struct {
|
||||||
@ -244,6 +281,11 @@ type storeIface interface {
|
|||||||
createTxRequest(ctx context.Context, r *TxRequest) error
|
createTxRequest(ctx context.Context, r *TxRequest) error
|
||||||
appendStatusLog(ctx context.Context, reqID, orgID string, entry StatusLogEntry) error
|
appendStatusLog(ctx context.Context, reqID, orgID string, entry StatusLogEntry) error
|
||||||
updateTxRequest(ctx context.Context, reqID, orgID string, update bson.M) error
|
updateTxRequest(ctx context.Context, reqID, orgID string, update bson.M) error
|
||||||
|
getLedgerEntries(ctx context.Context, orgID, fiscalYearID string, extra bson.M) ([]OrgLedgerEntry, error)
|
||||||
|
createLedgerEntry(ctx context.Context, e *OrgLedgerEntry) error
|
||||||
|
updateLedgerEntry(ctx context.Context, id, orgID string, update bson.M) error
|
||||||
|
getAttachments(ctx context.Context, requestID, orgID string) ([]OrgAttachment, error)
|
||||||
|
createAttachment(ctx context.Context, a *OrgAttachment) error
|
||||||
}
|
}
|
||||||
|
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -217,6 +217,15 @@ func (m *mockStore) getTxRequest(_ context.Context, _, _ string) (*TxRequest, er
|
|||||||
func (m *mockStore) createTxRequest(_ context.Context, _ *TxRequest) error { return nil }
|
func (m *mockStore) createTxRequest(_ context.Context, _ *TxRequest) error { return nil }
|
||||||
func (m *mockStore) appendStatusLog(_ context.Context, _, _ string, _ StatusLogEntry) error { return nil }
|
func (m *mockStore) appendStatusLog(_ context.Context, _, _ string, _ StatusLogEntry) error { return nil }
|
||||||
func (m *mockStore) updateTxRequest(_ context.Context, _, _ string, _ bson.M) error { return nil }
|
func (m *mockStore) updateTxRequest(_ context.Context, _, _ string, _ bson.M) error { return nil }
|
||||||
|
func (m *mockStore) getLedgerEntries(_ context.Context, _, _ string, _ bson.M) ([]OrgLedgerEntry, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (m *mockStore) createLedgerEntry(_ context.Context, _ *OrgLedgerEntry) error { return nil }
|
||||||
|
func (m *mockStore) updateLedgerEntry(_ context.Context, _, _ string, _ bson.M) error { return nil }
|
||||||
|
func (m *mockStore) getAttachments(_ context.Context, _, _ string) ([]OrgAttachment, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (m *mockStore) createAttachment(_ context.Context, _ *OrgAttachment) error { return nil }
|
||||||
|
|
||||||
// ── helpers ───────────────────────────────────────────────────────────────────
|
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@ -354,3 +354,173 @@ type OrgInviteData struct {
|
|||||||
Error string
|
Error string
|
||||||
Link string // generated invite link shown after creation
|
Link string // generated invite link shown after creation
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type OrgEventsData struct {
|
||||||
|
UserID string
|
||||||
|
Email string
|
||||||
|
Title string
|
||||||
|
Route string
|
||||||
|
Org Org
|
||||||
|
MyRole OrgRole
|
||||||
|
FiscalYear FiscalYear
|
||||||
|
Events []OrgEventSummary
|
||||||
|
Teams []OrgTeam
|
||||||
|
}
|
||||||
|
|
||||||
|
type OrgEventSummary struct {
|
||||||
|
Event OrgEvent
|
||||||
|
TotalIncome int64
|
||||||
|
TotalExpense int64
|
||||||
|
Teams []OrgTeam
|
||||||
|
}
|
||||||
|
|
||||||
|
type OrgEventDetailData struct {
|
||||||
|
UserID string
|
||||||
|
Email string
|
||||||
|
Title string
|
||||||
|
Route string
|
||||||
|
Org Org
|
||||||
|
MyRole OrgRole
|
||||||
|
FiscalYear FiscalYear
|
||||||
|
Event OrgEvent
|
||||||
|
BudgetLines []BudgetLine
|
||||||
|
Comments []EventComment
|
||||||
|
Teams []OrgTeam
|
||||||
|
EventTeams []OrgTeam
|
||||||
|
TotalIncome int64
|
||||||
|
TotalExpense int64
|
||||||
|
Error string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Phase 3 page data ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type OrgRequestsData struct {
|
||||||
|
UserID string
|
||||||
|
Email string
|
||||||
|
Title string
|
||||||
|
Route string
|
||||||
|
Org Org
|
||||||
|
MyRole OrgRole
|
||||||
|
Requests []TxRequest
|
||||||
|
Events []OrgEvent
|
||||||
|
Teams []OrgTeam
|
||||||
|
StatusFilter string
|
||||||
|
}
|
||||||
|
|
||||||
|
type OrgRequestDetailData struct {
|
||||||
|
UserID string
|
||||||
|
Email string
|
||||||
|
Title string
|
||||||
|
Route string
|
||||||
|
Org Org
|
||||||
|
MyRole OrgRole
|
||||||
|
Request TxRequest
|
||||||
|
Event *OrgEvent
|
||||||
|
BudgetLine *BudgetLine
|
||||||
|
Team *OrgTeam
|
||||||
|
FiscalYear *FiscalYear
|
||||||
|
Attachments []OrgAttachment
|
||||||
|
Error string
|
||||||
|
}
|
||||||
|
|
||||||
|
type OrgLedgerData struct {
|
||||||
|
UserID string
|
||||||
|
Email string
|
||||||
|
Title string
|
||||||
|
Route string
|
||||||
|
Org Org
|
||||||
|
MyRole OrgRole
|
||||||
|
FiscalYear *FiscalYear
|
||||||
|
FiscalYears []FiscalYear
|
||||||
|
Entries []OrgLedgerEntry
|
||||||
|
Events map[string]OrgEvent
|
||||||
|
Teams map[string]OrgTeam
|
||||||
|
TotalIncome int64
|
||||||
|
TotalExpense int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type OrgBankImportData struct {
|
||||||
|
UserID string
|
||||||
|
Email string
|
||||||
|
Title string
|
||||||
|
Route string
|
||||||
|
Org Org
|
||||||
|
MyRole OrgRole
|
||||||
|
FiscalYear *FiscalYear
|
||||||
|
Rows []BankImportRow
|
||||||
|
Error string
|
||||||
|
Imported int
|
||||||
|
}
|
||||||
|
|
||||||
|
type BankImportRow struct {
|
||||||
|
Date string
|
||||||
|
Description string
|
||||||
|
AmountCents int64
|
||||||
|
Reference string
|
||||||
|
Matched bool
|
||||||
|
MatchedID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Phase 4 page data ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type OrgAnalysisData struct {
|
||||||
|
UserID string
|
||||||
|
Email string
|
||||||
|
Title string
|
||||||
|
Route string
|
||||||
|
Org Org
|
||||||
|
MyRole OrgRole
|
||||||
|
FiscalYear FiscalYear
|
||||||
|
FiscalYears []FiscalYear
|
||||||
|
EventRows []AnalysisEventRow
|
||||||
|
TeamRows []AnalysisTeamRow
|
||||||
|
TotalPlannedIncome int64
|
||||||
|
TotalActualIncome int64
|
||||||
|
TotalPlannedExpense int64
|
||||||
|
TotalActualExpense int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type AnalysisEventRow struct {
|
||||||
|
Event OrgEvent
|
||||||
|
PlannedIncome int64
|
||||||
|
ActualIncome int64
|
||||||
|
PlannedExpense int64
|
||||||
|
ActualExpense int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type AnalysisTeamRow struct {
|
||||||
|
Team OrgTeam
|
||||||
|
PlannedIncome int64
|
||||||
|
ActualIncome int64
|
||||||
|
PlannedExpense int64
|
||||||
|
ActualExpense int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Phase 5 page data ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type OrgReportData struct {
|
||||||
|
UserID string
|
||||||
|
Email string
|
||||||
|
Title string
|
||||||
|
Route string
|
||||||
|
Org Org
|
||||||
|
MyRole OrgRole
|
||||||
|
FiscalYear FiscalYear
|
||||||
|
FiscalYears []FiscalYear
|
||||||
|
EventReports []EventReport
|
||||||
|
TotalPlannedIncome int64
|
||||||
|
TotalActualIncome int64
|
||||||
|
TotalPlannedExpense int64
|
||||||
|
TotalActualExpense int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type EventReport struct {
|
||||||
|
Event OrgEvent
|
||||||
|
BudgetLines []BudgetLine
|
||||||
|
Comments []EventComment // kind=feedback only
|
||||||
|
PlannedIncome int64
|
||||||
|
ActualIncome int64
|
||||||
|
PlannedExpense int64
|
||||||
|
ActualExpense int64
|
||||||
|
Teams []OrgTeam
|
||||||
|
}
|
||||||
|
|||||||
@ -527,3 +527,64 @@ func (s *Store) updateTxRequest(ctx context.Context, reqID, orgID string, update
|
|||||||
)
|
)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Ledger ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (s *Store) getLedgerEntries(ctx context.Context, orgID, fiscalYearID string, extra bson.M) ([]OrgLedgerEntry, error) {
|
||||||
|
ctx, span := mongo.StartSpan(ctx, "Store.getLedgerEntries")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
filter := bson.M{"org_id": orgID}
|
||||||
|
if fiscalYearID != "" {
|
||||||
|
filter["fiscal_year_id"] = fiscalYearID
|
||||||
|
}
|
||||||
|
for k, v := range extra {
|
||||||
|
filter[k] = v
|
||||||
|
}
|
||||||
|
cur, err := s.orgLedger().Find(ctx, filter, options.Find().SetSort(bson.D{{Key: "date", Value: -1}}))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var entries []OrgLedgerEntry
|
||||||
|
if err := cur.All(ctx, &entries); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return entries, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) createLedgerEntry(ctx context.Context, e *OrgLedgerEntry) error {
|
||||||
|
ctx, span := mongo.StartSpan(ctx, "Store.createLedgerEntry")
|
||||||
|
defer span.End()
|
||||||
|
_, err := s.orgLedger().InsertOne(ctx, e)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) updateLedgerEntry(ctx context.Context, id, orgID string, update bson.M) error {
|
||||||
|
ctx, span := mongo.StartSpan(ctx, "Store.updateLedgerEntry")
|
||||||
|
defer span.End()
|
||||||
|
_, err := s.orgLedger().UpdateOne(ctx, bson.M{"_id": id, "org_id": orgID}, bson.M{"$set": update})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Attachments ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (s *Store) getAttachments(ctx context.Context, requestID, orgID string) ([]OrgAttachment, error) {
|
||||||
|
ctx, span := mongo.StartSpan(ctx, "Store.getAttachments")
|
||||||
|
defer span.End()
|
||||||
|
cur, err := s.orgAttachments().Find(ctx, bson.M{"request_id": requestID, "org_id": orgID})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var attachments []OrgAttachment
|
||||||
|
if err := cur.All(ctx, &attachments); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return attachments, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) createAttachment(ctx context.Context, a *OrgAttachment) error {
|
||||||
|
ctx, span := mongo.StartSpan(ctx, "Store.createAttachment")
|
||||||
|
defer span.End()
|
||||||
|
_, err := s.orgAttachments().InsertOne(ctx, a)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|||||||
123
apps/finance/services/api/main/templates/org_analysis.html
Normal file
123
apps/finance/services/api/main/templates/org_analysis.html
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
{{$d := .}}
|
||||||
|
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:24px; flex-wrap:wrap; gap:12px;">
|
||||||
|
<div>
|
||||||
|
<div style="font-size:12px; color:var(--text3); margin-bottom:4px;">
|
||||||
|
<a href="/orgs" style="color:var(--text3); text-decoration:none;">Orgs</a> /
|
||||||
|
<a href="/orgs/{{$d.Org.Slug}}" style="color:var(--text3); text-decoration:none;">{{$d.Org.Name}}</a> /
|
||||||
|
<a href="/orgs/{{$d.Org.Slug}}/years/{{$d.FiscalYear.ID}}/events" style="color:var(--text3); text-decoration:none;">{{$d.FiscalYear.Label}}</a> / Analysis
|
||||||
|
</div>
|
||||||
|
<h1>Plan vs Actual</h1>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; gap:8px;">
|
||||||
|
<a href="/orgs/{{$d.Org.Slug}}/years/{{$d.FiscalYear.ID}}/report" class="btn btn-outline btn-sm">Year-end report</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Year selector -->
|
||||||
|
<div style="display:flex; gap:6px; flex-wrap:wrap; margin-bottom:20px;">
|
||||||
|
{{range $d.FiscalYears}}
|
||||||
|
<a href="/orgs/{{$d.Org.Slug}}/years/{{.ID}}/analysis" style="padding:5px 12px; border-radius:20px; text-decoration:none; font-size:12px; font-weight:600;
|
||||||
|
{{if eq $d.FiscalYear.ID .ID}}background:var(--accent);color:#000;{{else}}background:var(--bg3);color:var(--text3);{{end}}">
|
||||||
|
{{.Label}}
|
||||||
|
</a>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Summary totals -->
|
||||||
|
<div style="display:grid; grid-template-columns:repeat(4,1fr); gap:16px; margin-bottom:28px;">
|
||||||
|
<div class="card value-card animate-on-scroll">
|
||||||
|
<h2>Planned income</h2>
|
||||||
|
<div class="value" style="color:var(--green); font-size:22px;">{{cents $d.TotalPlannedIncome}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="card value-card animate-on-scroll">
|
||||||
|
<h2>Actual income</h2>
|
||||||
|
<div class="value" style="font-size:22px; color:{{varColor $d.TotalPlannedIncome $d.TotalActualIncome}};">{{cents $d.TotalActualIncome}}</div>
|
||||||
|
<div style="font-size:11px; color:var(--text3); margin-top:4px;">
|
||||||
|
{{if $d.TotalPlannedIncome}}{{cents (sub $d.TotalActualIncome $d.TotalPlannedIncome)}} vs plan{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card value-card animate-on-scroll">
|
||||||
|
<h2>Planned expense</h2>
|
||||||
|
<div class="value" style="color:var(--red); font-size:22px;">{{cents $d.TotalPlannedExpense}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="card value-card animate-on-scroll">
|
||||||
|
<h2>Actual expense</h2>
|
||||||
|
<div class="value" style="font-size:22px; color:{{varColor $d.TotalPlannedExpense $d.TotalActualExpense}};">{{cents $d.TotalActualExpense}}</div>
|
||||||
|
<div style="font-size:11px; color:var(--text3); margin-top:4px;">
|
||||||
|
{{if $d.TotalPlannedExpense}}{{cents (sub $d.TotalActualExpense $d.TotalPlannedExpense)}} vs plan{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- By event -->
|
||||||
|
<h2 style="margin-bottom:12px;">By event</h2>
|
||||||
|
{{if $d.EventRows}}
|
||||||
|
<div class="card animate-on-scroll" style="padding:0; overflow:hidden; margin-bottom:28px;">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Event</th>
|
||||||
|
<th style="text-align:right;">Planned income</th>
|
||||||
|
<th style="text-align:right;">Actual income</th>
|
||||||
|
<th style="text-align:right;">Planned expense</th>
|
||||||
|
<th style="text-align:right;">Actual expense</th>
|
||||||
|
<th style="text-align:right;">Variance</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range $d.EventRows}}
|
||||||
|
{{$netPlan := sub .PlannedIncome .PlannedExpense}}
|
||||||
|
{{$netActual := sub .ActualIncome .ActualExpense}}
|
||||||
|
<tr>
|
||||||
|
<td style="font-weight:600;">
|
||||||
|
<a href="/orgs/{{$d.Org.Slug}}/years/{{$d.FiscalYear.ID}}/events/{{.Event.ID}}" style="color:var(--text); text-decoration:none;">{{.Event.Name}}</a>
|
||||||
|
</td>
|
||||||
|
<td style="text-align:right; color:var(--green); font-size:13px;">{{cents .PlannedIncome}}</td>
|
||||||
|
<td style="text-align:right; font-size:13px; color:{{varColor .PlannedIncome .ActualIncome}};">{{cents .ActualIncome}}</td>
|
||||||
|
<td style="text-align:right; color:var(--red); font-size:13px;">{{cents .PlannedExpense}}</td>
|
||||||
|
<td style="text-align:right; font-size:13px; color:{{varColor .PlannedExpense .ActualExpense}};">{{cents .ActualExpense}}</td>
|
||||||
|
<td style="text-align:right; font-size:13px; font-weight:600; color:{{if ge $netActual $netPlan}}var(--green){{else}}var(--red){{end}};">
|
||||||
|
{{cents (sub $netActual $netPlan)}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<p style="color:var(--text3); font-size:13px; margin-bottom:28px;">No events in this fiscal year.</p>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<!-- By team -->
|
||||||
|
<h2 style="margin-bottom:12px;">By team</h2>
|
||||||
|
{{if $d.TeamRows}}
|
||||||
|
<div class="card animate-on-scroll" style="padding:0; overflow:hidden;">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Team</th>
|
||||||
|
<th style="text-align:right;">Planned income</th>
|
||||||
|
<th style="text-align:right;">Actual income</th>
|
||||||
|
<th style="text-align:right;">Planned expense</th>
|
||||||
|
<th style="text-align:right;">Actual expense</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range $d.TeamRows}}
|
||||||
|
<tr>
|
||||||
|
<td style="font-weight:600;">{{.Team.Name}}</td>
|
||||||
|
<td style="text-align:right; color:var(--green); font-size:13px;">{{cents .PlannedIncome}}</td>
|
||||||
|
<td style="text-align:right; font-size:13px; color:{{varColor .PlannedIncome .ActualIncome}};">{{cents .ActualIncome}}</td>
|
||||||
|
<td style="text-align:right; color:var(--red); font-size:13px;">{{cents .PlannedExpense}}</td>
|
||||||
|
<td style="text-align:right; font-size:13px; color:{{varColor .PlannedExpense .ActualExpense}};">{{cents .ActualExpense}}</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<p style="color:var(--text3); font-size:13px;">No teams configured.</p>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
@ -0,0 +1,89 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
{{$d := .}}
|
||||||
|
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:24px; flex-wrap:wrap; gap:12px;">
|
||||||
|
<div>
|
||||||
|
<div style="font-size:12px; color:var(--text3); margin-bottom:4px;">
|
||||||
|
<a href="/orgs" style="color:var(--text3); text-decoration:none;">Orgs</a> /
|
||||||
|
<a href="/orgs/{{$d.Org.Slug}}" style="color:var(--text3); text-decoration:none;">{{$d.Org.Name}}</a> /
|
||||||
|
<a href="/orgs/{{$d.Org.Slug}}/ledger" style="color:var(--text3); text-decoration:none;">Ledger</a> / Import
|
||||||
|
</div>
|
||||||
|
<h1>Bank CSV Import</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if $d.Imported}}
|
||||||
|
<div class="card animate-on-scroll" style="border-color:var(--green); max-width:480px;">
|
||||||
|
<div style="font-size:36px; margin-bottom:12px;">✅</div>
|
||||||
|
<h2 style="color:var(--green); margin-bottom:8px;">Import complete</h2>
|
||||||
|
<p style="color:var(--text2); margin-bottom:16px;">{{$d.Imported}} entries imported into the ledger.</p>
|
||||||
|
<a href="/orgs/{{$d.Org.Slug}}/ledger" class="btn btn-primary">View ledger</a>
|
||||||
|
</div>
|
||||||
|
{{else if $d.Rows}}
|
||||||
|
<!-- Preview before confirming -->
|
||||||
|
<div class="card animate-on-scroll" style="margin-bottom:20px;">
|
||||||
|
<h2 style="margin-bottom:4px;">Preview — {{len $d.Rows}} rows</h2>
|
||||||
|
<p style="font-size:13px; color:var(--text3); margin-bottom:16px;">Review the parsed rows below, then confirm import.</p>
|
||||||
|
<div style="overflow-x:auto;">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Description</th>
|
||||||
|
<th style="text-align:right;">Amount</th>
|
||||||
|
<th>Reference</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range $d.Rows}}
|
||||||
|
<tr>
|
||||||
|
<td style="font-size:12px; white-space:nowrap;">{{.Date}}</td>
|
||||||
|
<td style="font-size:13px; max-width:260px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">{{.Description}}</td>
|
||||||
|
<td style="text-align:right; font-size:13px; font-weight:600; {{if ge .AmountCents 0}}color:var(--green);{{else}}color:var(--red);{{end}}">{{cents .AmountCents}}</td>
|
||||||
|
<td style="font-size:11px; color:var(--text3); font-family:monospace;">{{if .Reference}}{{.Reference}}{{else}}—{{end}}</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="/orgs/{{$d.Org.Slug}}/ledger/import" enctype="multipart/form-data" style="display:flex; gap:10px;">
|
||||||
|
<input type="hidden" name="confirm" value="1">
|
||||||
|
<button type="submit" class="btn btn-primary">Confirm import</button>
|
||||||
|
<a href="/orgs/{{$d.Org.Slug}}/ledger/import" class="btn btn-outline">Start over</a>
|
||||||
|
</form>
|
||||||
|
{{else}}
|
||||||
|
<!-- Upload form -->
|
||||||
|
{{if not $d.FiscalYear}}
|
||||||
|
<div class="card animate-on-scroll" style="border-color:#fbbf24; max-width:480px;">
|
||||||
|
<p style="color:#fbbf24; font-size:13px;">⚠ No active fiscal year. Activate a fiscal year before importing bank transactions.</p>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<div class="card animate-on-scroll" style="max-width:480px; {{if not $d.FiscalYear}}opacity:0.5; pointer-events:none;{{end}}">
|
||||||
|
<h2 style="margin-bottom:6px;">Upload bank statement</h2>
|
||||||
|
<p style="font-size:13px; color:var(--text3); margin-bottom:20px;">
|
||||||
|
CSV must have columns: <code>date</code>, <code>description</code>, <code>amount</code>.
|
||||||
|
Optionally: <code>reference</code>. Amounts use decimal notation (e.g. <code>-50.00</code>).
|
||||||
|
</p>
|
||||||
|
{{if $d.Error}}
|
||||||
|
<div style="padding:10px 14px; border-radius:var(--radius-sm); background:rgba(239,68,68,0.1); color:var(--red); font-size:13px; margin-bottom:16px;">{{$d.Error}}</div>
|
||||||
|
{{end}}
|
||||||
|
<form method="post" action="/orgs/{{$d.Org.Slug}}/ledger/import" enctype="multipart/form-data">
|
||||||
|
<div style="margin-bottom:20px;">
|
||||||
|
<label class="form-label">CSV file</label>
|
||||||
|
<input class="form-input" type="file" name="csv" accept=".csv,text/csv" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary" style="width:100%;">Parse & preview</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card animate-on-scroll" style="max-width:480px; margin-top:16px; background:var(--bg3);">
|
||||||
|
<h3 style="font-size:13px; margin-bottom:8px; color:var(--text2);">Expected CSV format</h3>
|
||||||
|
<pre style="font-size:11px; color:var(--text3); line-height:1.6; overflow-x:auto;">date,description,amount,reference
|
||||||
|
2025-01-15,Supermarket purchase,-45.20,REF001
|
||||||
|
2025-01-16,Client payment received,1200.00,REF002
|
||||||
|
2025-01-17,Office supplies,-89.99,REF003</pre>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
332
apps/finance/services/api/main/templates/org_event_detail.html
Normal file
332
apps/finance/services/api/main/templates/org_event_detail.html
Normal file
@ -0,0 +1,332 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
{{$d := .}}
|
||||||
|
|
||||||
|
{{$isNew := eq $d.Event.ID ""}}
|
||||||
|
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:24px; flex-wrap:wrap; gap:12px;">
|
||||||
|
<div>
|
||||||
|
<div style="font-size:12px; color:var(--text3); margin-bottom:4px;">
|
||||||
|
<a href="/orgs" style="color:var(--text3); text-decoration:none;">Orgs</a> /
|
||||||
|
<a href="/orgs/{{$d.Org.Slug}}" style="color:var(--text3); text-decoration:none;">{{$d.Org.Name}}</a> /
|
||||||
|
<a href="/orgs/{{$d.Org.Slug}}/years/{{$d.FiscalYear.ID}}/events" style="color:var(--text3); text-decoration:none;">Events</a> /
|
||||||
|
{{if $isNew}}New event{{else}}{{$d.Event.Name}}{{end}}
|
||||||
|
</div>
|
||||||
|
<h1>{{if $isNew}}New event{{else}}{{$d.Event.Name}}{{end}}</h1>
|
||||||
|
</div>
|
||||||
|
{{if not $isNew}}
|
||||||
|
<div style="display:flex; gap:8px; flex-wrap:wrap; align-items:center;">
|
||||||
|
{{if eq (print $d.Event.Status) "approved"}}
|
||||||
|
<span style="font-size:12px; font-weight:700; padding:4px 12px; border-radius:20px; background:rgba(74,222,128,0.12); color:var(--green);">✓ Approved</span>
|
||||||
|
{{else if eq (print $d.Event.Status) "review"}}
|
||||||
|
<span style="font-size:12px; font-weight:700; padding:4px 12px; border-radius:20px; background:rgba(251,191,36,0.1); color:#fbbf24;">Under review</span>
|
||||||
|
{{else if eq (print $d.Event.Status) "rejected"}}
|
||||||
|
<span style="font-size:12px; font-weight:700; padding:4px 12px; border-radius:20px; background:rgba(239,68,68,0.12); color:var(--red);">Rejected</span>
|
||||||
|
{{else}}
|
||||||
|
<span style="font-size:12px; font-weight:700; padding:4px 12px; border-radius:20px; background:var(--bg3); color:var(--text3);">Draft</span>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if eq (print $d.Event.Status) "draft"}}
|
||||||
|
<button onclick="document.getElementById('edit-modal').style.display='flex'" class="btn btn-outline btn-sm">Edit</button>
|
||||||
|
<form method="post" action="/orgs/{{$d.Org.Slug}}/years/{{$d.FiscalYear.ID}}/events/{{$d.Event.ID}}/submit" style="display:inline;">
|
||||||
|
<button class="btn btn-primary btn-sm" onclick="return confirm('Submit this event for review?')">Submit for review</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="/orgs/{{$d.Org.Slug}}/years/{{$d.FiscalYear.ID}}/events/{{$d.Event.ID}}/delete" style="display:inline;">
|
||||||
|
<button class="btn btn-sm" style="color:var(--red); background:transparent; border:1px solid var(--red);" onclick="return confirm('Delete this event?')">Delete</button>
|
||||||
|
</form>
|
||||||
|
{{else if eq (print $d.Event.Status) "review"}}
|
||||||
|
<button onclick="document.getElementById('edit-modal').style.display='flex'" class="btn btn-outline btn-sm">Edit</button>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if and (or (eq (print $d.MyRole) "admin") (eq (print $d.MyRole) "finance")) (eq (print $d.Event.Status) "review")}}
|
||||||
|
<button onclick="document.getElementById('review-modal').style.display='flex'" class="btn btn-primary btn-sm">Review</button>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if $isNew}}
|
||||||
|
<!-- New event form -->
|
||||||
|
<div class="card animate-on-scroll" style="max-width:640px;">
|
||||||
|
<form method="post" action="/orgs/{{$d.Org.Slug}}/years/{{$d.FiscalYear.ID}}/events/new">
|
||||||
|
<div style="margin-bottom:16px;">
|
||||||
|
<label class="form-label">Event name</label>
|
||||||
|
<input class="form-input" type="text" name="name" placeholder="Annual conference" required autofocus>
|
||||||
|
</div>
|
||||||
|
<div style="display:grid; grid-template-columns:1fr 1fr; gap:12px; margin-bottom:16px;">
|
||||||
|
<div>
|
||||||
|
<label class="form-label">Start date</label>
|
||||||
|
<input class="form-input" type="date" name="date_start" required>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label">End date</label>
|
||||||
|
<input class="form-input" type="date" name="date_end" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-bottom:16px;">
|
||||||
|
<label class="form-label">Description</label>
|
||||||
|
<textarea class="form-input" name="description" rows="3" placeholder="What is this event about?"></textarea>
|
||||||
|
</div>
|
||||||
|
<div style="margin-bottom:16px;">
|
||||||
|
<label class="form-label">Goals</label>
|
||||||
|
<textarea class="form-input" name="goals" rows="2" placeholder="What should we achieve?"></textarea>
|
||||||
|
</div>
|
||||||
|
{{if $d.Teams}}
|
||||||
|
<div style="margin-bottom:20px;">
|
||||||
|
<label class="form-label">Teams</label>
|
||||||
|
<div style="display:flex; flex-wrap:wrap; gap:8px;">
|
||||||
|
{{range $d.Teams}}
|
||||||
|
<label style="display:flex; align-items:center; gap:6px; cursor:pointer; padding:6px 10px; border-radius:var(--radius-sm); border:1px solid var(--border); font-size:13px;">
|
||||||
|
<input type="checkbox" name="team_ids" value="{{.ID}}"> {{.Name}}
|
||||||
|
</label>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
<div style="display:flex; gap:10px;">
|
||||||
|
<button type="submit" class="btn btn-primary">Create event</button>
|
||||||
|
<a href="/orgs/{{$d.Org.Slug}}/years/{{$d.FiscalYear.ID}}/events" class="btn btn-outline">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{else}}
|
||||||
|
<!-- Event detail -->
|
||||||
|
<div style="display:grid; grid-template-columns:1fr 1fr 1fr; gap:16px; margin-bottom:24px;">
|
||||||
|
<div class="card value-card animate-on-scroll">
|
||||||
|
<h2>Planned income</h2>
|
||||||
|
<div class="value" style="color:var(--green);">{{cents $d.TotalIncome}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="card value-card animate-on-scroll">
|
||||||
|
<h2>Planned expense</h2>
|
||||||
|
<div class="value" style="color:var(--red);">{{cents $d.TotalExpense}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="card value-card animate-on-scroll">
|
||||||
|
<h2>Net</h2>
|
||||||
|
<div class="value" style="color:var(--accent2);">{{cents (sub $d.TotalIncome $d.TotalExpense)}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:grid; grid-template-columns:2fr 1fr; gap:20px;">
|
||||||
|
<div>
|
||||||
|
<!-- Description card -->
|
||||||
|
<div class="card animate-on-scroll" style="margin-bottom:20px;">
|
||||||
|
<h2 style="margin-bottom:12px;">Details</h2>
|
||||||
|
<div style="display:grid; grid-template-columns:1fr 1fr; gap:12px; font-size:13px; margin-bottom:14px;">
|
||||||
|
<div>
|
||||||
|
<div style="color:var(--text3); font-size:11px; font-weight:700; letter-spacing:.06em; margin-bottom:4px;">PERIOD</div>
|
||||||
|
<div>{{dateShort $d.Event.DateStart}} — {{dateShort $d.Event.DateEnd}}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style="color:var(--text3); font-size:11px; font-weight:700; letter-spacing:.06em; margin-bottom:4px;">TEAMS</div>
|
||||||
|
<div>{{if $d.EventTeams}}{{range $d.EventTeams}}<span style="display:inline-block; padding:1px 6px; border-radius:4px; background:var(--bg3); margin-right:3px; font-size:11px;">{{.Name}}</span>{{end}}{{else}}—{{end}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{if $d.Event.Description}}
|
||||||
|
<div style="margin-bottom:10px;">
|
||||||
|
<div style="color:var(--text3); font-size:11px; font-weight:700; letter-spacing:.06em; margin-bottom:6px;">DESCRIPTION</div>
|
||||||
|
<p style="font-size:13px; color:var(--text2); line-height:1.6;">{{$d.Event.Description}}</p>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{if $d.Event.Goals}}
|
||||||
|
<div>
|
||||||
|
<div style="color:var(--text3); font-size:11px; font-weight:700; letter-spacing:.06em; margin-bottom:6px;">GOALS</div>
|
||||||
|
<p style="font-size:13px; color:var(--text2); line-height:1.6;">{{$d.Event.Goals}}</p>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Budget lines -->
|
||||||
|
<div class="card animate-on-scroll" style="margin-bottom:20px;">
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:16px;">
|
||||||
|
<h2>Budget lines</h2>
|
||||||
|
{{if ne (print $d.Event.Status) "approved"}}
|
||||||
|
<button onclick="document.getElementById('budget-modal').style.display='flex'" class="btn btn-outline btn-sm">+ Add line</button>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{if $d.BudgetLines}}
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Category</th>
|
||||||
|
<th>Description</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th style="text-align:right;">Amount</th>
|
||||||
|
{{if ne (print $d.Event.Status) "approved"}}<th></th>{{end}}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range $d.BudgetLines}}
|
||||||
|
<tr>
|
||||||
|
<td style="font-size:13px; font-weight:600;">{{.Category}}</td>
|
||||||
|
<td style="font-size:12px; color:var(--text2);">{{.Description}}</td>
|
||||||
|
<td>
|
||||||
|
{{if eq (print .Type) "income"}}
|
||||||
|
<span style="font-size:11px; font-weight:700; color:var(--green);">income</span>
|
||||||
|
{{else}}
|
||||||
|
<span style="font-size:11px; font-weight:700; color:var(--red);">expense</span>
|
||||||
|
{{end}}
|
||||||
|
</td>
|
||||||
|
<td style="text-align:right; font-size:13px;">{{cents .PlannedCents}}</td>
|
||||||
|
{{if ne (print $d.Event.Status) "approved"}}
|
||||||
|
<td style="text-align:right;">
|
||||||
|
<form method="post" action="/orgs/{{$d.Org.Slug}}/years/{{$d.FiscalYear.ID}}/events/{{$d.Event.ID}}/budget/{{.ID}}/delete" style="display:inline;">
|
||||||
|
<button class="btn btn-sm" style="color:var(--red); background:transparent; border:none; padding:2px 6px;" onclick="return confirm('Remove this budget line?')">×</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
{{end}}
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{{else}}
|
||||||
|
<p style="color:var(--text3); font-size:13px;">No budget lines yet. Add income and expense lines to plan this event's finances.</p>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Comments -->
|
||||||
|
<div class="card animate-on-scroll">
|
||||||
|
<h2 style="margin-bottom:16px;">Comments</h2>
|
||||||
|
{{if $d.Comments}}
|
||||||
|
<div style="display:flex; flex-direction:column; gap:12px; margin-bottom:16px;">
|
||||||
|
{{range $d.Comments}}
|
||||||
|
<div style="padding:12px 14px; border-radius:var(--radius-sm); background:var(--bg3); border-left:3px solid
|
||||||
|
{{if eq (print .Kind) "feedback"}}var(--accent2){{else}}var(--accent){{end}};">
|
||||||
|
<div style="display:flex; justify-content:space-between; margin-bottom:6px;">
|
||||||
|
<span style="font-size:12px; font-weight:600;">{{.UserEmail}}</span>
|
||||||
|
<span style="font-size:11px; color:var(--text3);">{{dateShort .CreatedAt}} · {{.Kind}}</span>
|
||||||
|
</div>
|
||||||
|
<p style="font-size:13px; color:var(--text2); line-height:1.5; margin:0;">{{.Body}}</p>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if and (eq (print $d.FiscalYear.Status) "closed") (ne (print $d.MyRole) "viewer")}}
|
||||||
|
<form method="post" action="/orgs/{{$d.Org.Slug}}/years/{{$d.FiscalYear.ID}}/events/{{$d.Event.ID}}/feedback">
|
||||||
|
<div style="margin-bottom:10px;">
|
||||||
|
<label class="form-label">Post-mortem feedback</label>
|
||||||
|
<textarea class="form-input" name="comment" rows="3" placeholder="What went well? What could improve?" required></textarea>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-outline btn-sm">Add feedback</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<div>
|
||||||
|
<div class="card animate-on-scroll" style="margin-bottom:16px;">
|
||||||
|
<h2 style="margin-bottom:12px; font-size:14px;">Quick actions</h2>
|
||||||
|
<div style="display:flex; flex-direction:column; gap:8px;">
|
||||||
|
<a href="/orgs/{{$d.Org.Slug}}/requests/new?event_id={{$d.Event.ID}}" class="btn btn-outline btn-sm" style="text-align:center;">
|
||||||
|
+ New request
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit modal -->
|
||||||
|
<div id="edit-modal" style="display:none; position:fixed; inset:0; background:rgba(0,0,0,0.6); z-index:500; align-items:center; justify-content:center; padding:16px;">
|
||||||
|
<div class="card" style="width:100%; max-width:520px; max-height:80vh; overflow-y:auto;">
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:20px;">
|
||||||
|
<h2>Edit event</h2>
|
||||||
|
<button onclick="document.getElementById('edit-modal').style.display='none'" style="background:none; border:none; color:var(--text3); font-size:20px; cursor:pointer;">×</button>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="/orgs/{{$d.Org.Slug}}/years/{{$d.FiscalYear.ID}}/events/{{$d.Event.ID}}/edit">
|
||||||
|
<div style="margin-bottom:14px;">
|
||||||
|
<label class="form-label">Name</label>
|
||||||
|
<input class="form-input" type="text" name="name" value="{{$d.Event.Name}}" required>
|
||||||
|
</div>
|
||||||
|
<div style="display:grid; grid-template-columns:1fr 1fr; gap:10px; margin-bottom:14px;">
|
||||||
|
<div>
|
||||||
|
<label class="form-label">Start date</label>
|
||||||
|
<input class="form-input" type="date" name="date_start" value="{{$d.Event.DateStart | dateInput}}" required>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label">End date</label>
|
||||||
|
<input class="form-input" type="date" name="date_end" value="{{$d.Event.DateEnd | dateInput}}" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-bottom:14px;">
|
||||||
|
<label class="form-label">Description</label>
|
||||||
|
<textarea class="form-input" name="description" rows="3">{{$d.Event.Description}}</textarea>
|
||||||
|
</div>
|
||||||
|
<div style="margin-bottom:14px;">
|
||||||
|
<label class="form-label">Goals</label>
|
||||||
|
<textarea class="form-input" name="goals" rows="2">{{$d.Event.Goals}}</textarea>
|
||||||
|
</div>
|
||||||
|
{{if $d.Teams}}
|
||||||
|
<div style="margin-bottom:20px;">
|
||||||
|
<label class="form-label">Teams</label>
|
||||||
|
<div style="display:flex; flex-wrap:wrap; gap:8px;">
|
||||||
|
{{range $d.Teams}}
|
||||||
|
<label style="display:flex; align-items:center; gap:6px; cursor:pointer; padding:6px 10px; border-radius:var(--radius-sm); border:1px solid var(--border); font-size:13px;">
|
||||||
|
<input type="checkbox" name="team_ids" value="{{.ID}}"
|
||||||
|
{{range $d.EventTeams}}{{if eq .ID $.ID}}checked{{end}}{{end}}> {{.Name}}
|
||||||
|
</label>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
<button type="submit" class="btn btn-primary" style="width:100%;">Save changes</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add budget line modal -->
|
||||||
|
<div id="budget-modal" style="display:none; position:fixed; inset:0; background:rgba(0,0,0,0.6); z-index:500; align-items:center; justify-content:center; padding:16px;">
|
||||||
|
<div class="card" style="width:100%; max-width:400px;">
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:20px;">
|
||||||
|
<h2>Add budget line</h2>
|
||||||
|
<button onclick="document.getElementById('budget-modal').style.display='none'" style="background:none; border:none; color:var(--text3); font-size:20px; cursor:pointer;">×</button>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="/orgs/{{$d.Org.Slug}}/years/{{$d.FiscalYear.ID}}/events/{{$d.Event.ID}}/budget">
|
||||||
|
<div style="margin-bottom:14px;">
|
||||||
|
<label class="form-label">Type</label>
|
||||||
|
<select class="form-input" name="type">
|
||||||
|
<option value="expense">Expense</option>
|
||||||
|
<option value="income">Income</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div style="margin-bottom:14px;">
|
||||||
|
<label class="form-label">Category</label>
|
||||||
|
<input class="form-input" type="text" name="category" placeholder="Catering, Transport, Tickets…" required>
|
||||||
|
</div>
|
||||||
|
<div style="margin-bottom:14px;">
|
||||||
|
<label class="form-label">Planned amount (€)</label>
|
||||||
|
<input class="form-input" type="text" name="amount" placeholder="500.00" required>
|
||||||
|
</div>
|
||||||
|
<div style="margin-bottom:20px;">
|
||||||
|
<label class="form-label">Description</label>
|
||||||
|
<input class="form-input" type="text" name="description" placeholder="Optional details">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary" style="width:100%;">Add line</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Admin review modal -->
|
||||||
|
{{if and (or (eq (print $d.MyRole) "admin") (eq (print $d.MyRole) "finance")) (eq (print $d.Event.Status) "review")}}
|
||||||
|
<div id="review-modal" style="display:none; position:fixed; inset:0; background:rgba(0,0,0,0.6); z-index:500; align-items:center; justify-content:center; padding:16px;">
|
||||||
|
<div class="card" style="width:100%; max-width:440px;">
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:20px;">
|
||||||
|
<h2>Review event</h2>
|
||||||
|
<button onclick="document.getElementById('review-modal').style.display='none'" style="background:none; border:none; color:var(--text3); font-size:20px; cursor:pointer;">×</button>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="/orgs/{{$d.Org.Slug}}/years/{{$d.FiscalYear.ID}}/events/{{$d.Event.ID}}/review">
|
||||||
|
<div style="margin-bottom:16px;">
|
||||||
|
<label class="form-label">Comment (optional)</label>
|
||||||
|
<textarea class="form-input" name="comment" rows="3" placeholder="Leave feedback or request changes…"></textarea>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; gap:8px;">
|
||||||
|
<button type="submit" name="action" value="approve" class="btn btn-primary" style="flex:1;" onclick="return confirm('Approve this event?')">Approve</button>
|
||||||
|
<button type="submit" name="action" value="comment" class="btn btn-outline" style="flex:1;">Comment only</button>
|
||||||
|
<button type="submit" name="action" value="reject" class="btn btn-sm" style="flex:1; color:var(--red); background:transparent; border:1px solid var(--red);" onclick="return confirm('Reject this event?')">Reject</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{end}}{{end}}
|
||||||
85
apps/finance/services/api/main/templates/org_events.html
Normal file
85
apps/finance/services/api/main/templates/org_events.html
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
{{$d := .}}
|
||||||
|
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:24px; flex-wrap:wrap; gap:12px;">
|
||||||
|
<div>
|
||||||
|
<div style="font-size:12px; color:var(--text3); margin-bottom:4px;">
|
||||||
|
<a href="/orgs" style="color:var(--text3); text-decoration:none;">Orgs</a> /
|
||||||
|
<a href="/orgs/{{$d.Org.Slug}}" style="color:var(--text3); text-decoration:none;">{{$d.Org.Name}}</a> /
|
||||||
|
{{$d.FiscalYear.Label}} — Events
|
||||||
|
</div>
|
||||||
|
<h1>Events</h1>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; gap:8px; flex-wrap:wrap;">
|
||||||
|
<a href="/orgs/{{$d.Org.Slug}}/years/{{$d.FiscalYear.ID}}/analysis" class="btn btn-outline btn-sm">Analysis</a>
|
||||||
|
{{if ne (print $d.FiscalYear.Status) "closed"}}
|
||||||
|
<a href="/orgs/{{$d.Org.Slug}}/years/{{$d.FiscalYear.ID}}/events/new" class="btn btn-primary btn-sm">+ New event</a>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Year status banner -->
|
||||||
|
<div style="display:flex; gap:8px; align-items:center; margin-bottom:20px;">
|
||||||
|
<span style="font-size:12px; color:var(--text3);">Fiscal year:</span>
|
||||||
|
<span style="font-weight:600;">{{$d.FiscalYear.Label}}</span>
|
||||||
|
<span style="font-size:11px; font-weight:700; padding:2px 8px; border-radius:20px;
|
||||||
|
{{if eq (print $d.FiscalYear.Status) "active"}}background:rgba(74,222,128,0.12); color:var(--green);
|
||||||
|
{{else if eq (print $d.FiscalYear.Status) "closed"}}background:var(--bg3); color:var(--text3);
|
||||||
|
{{else}}background:rgba(251,191,36,0.1); color:#fbbf24;{{end}}">
|
||||||
|
{{$d.FiscalYear.Status}}
|
||||||
|
</span>
|
||||||
|
<span style="font-size:12px; color:var(--text3);">{{dateShort $d.FiscalYear.StartDate}} — {{dateShort $d.FiscalYear.EndDate}}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if $d.Events}}
|
||||||
|
<div class="card animate-on-scroll" style="padding:0; overflow:hidden;">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Event</th>
|
||||||
|
<th>Teams</th>
|
||||||
|
<th>Dates</th>
|
||||||
|
<th style="text-align:right;">Planned income</th>
|
||||||
|
<th style="text-align:right;">Planned expense</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range $d.Events}}
|
||||||
|
<tr>
|
||||||
|
<td style="font-weight:600;">{{.Event.Name}}</td>
|
||||||
|
<td style="font-size:12px; color:var(--text3);">
|
||||||
|
{{if .Teams}}{{range .Teams}}<span style="display:inline-block; padding:1px 6px; border-radius:4px; background:var(--bg3); margin-right:3px; font-size:11px;">{{.Name}}</span>{{end}}{{else}}—{{end}}
|
||||||
|
</td>
|
||||||
|
<td style="font-size:12px; color:var(--text2);">{{dateShort .Event.DateStart}} — {{dateShort .Event.DateEnd}}</td>
|
||||||
|
<td style="text-align:right; color:var(--green); font-size:13px;">{{cents .TotalIncome}}</td>
|
||||||
|
<td style="text-align:right; color:var(--red); font-size:13px;">{{cents .TotalExpense}}</td>
|
||||||
|
<td>
|
||||||
|
{{if eq (print .Event.Status) "approved"}}
|
||||||
|
<span style="font-size:11px; font-weight:700; padding:2px 8px; border-radius:20px; background:rgba(74,222,128,0.12); color:var(--green);">approved</span>
|
||||||
|
{{else if eq (print .Event.Status) "review"}}
|
||||||
|
<span style="font-size:11px; font-weight:700; padding:2px 8px; border-radius:20px; background:rgba(251,191,36,0.1); color:#fbbf24;">review</span>
|
||||||
|
{{else if eq (print .Event.Status) "rejected"}}
|
||||||
|
<span style="font-size:11px; font-weight:700; padding:2px 8px; border-radius:20px; background:rgba(239,68,68,0.12); color:var(--red);">rejected</span>
|
||||||
|
{{else}}
|
||||||
|
<span style="font-size:11px; font-weight:700; padding:2px 8px; border-radius:20px; background:var(--bg3); color:var(--text3);">draft</span>
|
||||||
|
{{end}}
|
||||||
|
</td>
|
||||||
|
<td style="text-align:right;">
|
||||||
|
<a href="/orgs/{{$d.Org.Slug}}/years/{{$d.FiscalYear.ID}}/events/{{.Event.ID}}" class="btn btn-outline btn-sm">View</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<div class="card empty-state animate-on-scroll">
|
||||||
|
<div style="font-size:36px; margin-bottom:12px;">🗓️</div>
|
||||||
|
<h3>No events yet</h3>
|
||||||
|
<p style="margin-bottom:16px;">Plan events with budgets for teams to execute this fiscal year.</p>
|
||||||
|
<a href="/orgs/{{$d.Org.Slug}}/years/{{$d.FiscalYear.ID}}/events/new" class="btn btn-primary">Create first event</a>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
89
apps/finance/services/api/main/templates/org_ledger.html
Normal file
89
apps/finance/services/api/main/templates/org_ledger.html
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
{{$d := .}}
|
||||||
|
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:24px; flex-wrap:wrap; gap:12px;">
|
||||||
|
<div>
|
||||||
|
<div style="font-size:12px; color:var(--text3); margin-bottom:4px;">
|
||||||
|
<a href="/orgs" style="color:var(--text3); text-decoration:none;">Orgs</a> /
|
||||||
|
<a href="/orgs/{{$d.Org.Slug}}" style="color:var(--text3); text-decoration:none;">{{$d.Org.Name}}</a> / Ledger
|
||||||
|
</div>
|
||||||
|
<h1>Ledger</h1>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; gap:8px; flex-wrap:wrap;">
|
||||||
|
<a href="/orgs/{{$d.Org.Slug}}/ledger/import" class="btn btn-outline btn-sm">Import bank CSV</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Year selector -->
|
||||||
|
<div style="display:flex; gap:6px; flex-wrap:wrap; margin-bottom:20px;">
|
||||||
|
{{range $d.FiscalYears}}
|
||||||
|
<a href="?year_id={{.ID}}" style="padding:5px 12px; border-radius:20px; text-decoration:none; font-size:12px; font-weight:600;
|
||||||
|
{{if and $d.FiscalYear (eq $d.FiscalYear.ID .ID)}}background:var(--accent);color:#000;{{else}}background:var(--bg3);color:var(--text3);{{end}}">
|
||||||
|
{{.Label}}
|
||||||
|
</a>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Summary cards -->
|
||||||
|
<div style="display:grid; grid-template-columns:repeat(3,1fr); gap:16px; margin-bottom:24px;">
|
||||||
|
<div class="card value-card animate-on-scroll">
|
||||||
|
<h2>Income</h2>
|
||||||
|
<div class="value" style="color:var(--green);">{{cents $d.TotalIncome}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="card value-card animate-on-scroll">
|
||||||
|
<h2>Expenses</h2>
|
||||||
|
<div class="value" style="color:var(--red);">{{cents $d.TotalExpense}}</div>
|
||||||
|
</div>
|
||||||
|
<div class="card value-card animate-on-scroll">
|
||||||
|
<h2>Net</h2>
|
||||||
|
<div class="value" style="color:var(--accent2);">{{cents (sub $d.TotalIncome $d.TotalExpense)}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if $d.Entries}}
|
||||||
|
<div class="card animate-on-scroll" style="padding:0; overflow:hidden;">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Description</th>
|
||||||
|
<th>Event</th>
|
||||||
|
<th>Team</th>
|
||||||
|
<th style="text-align:right;">Amount</th>
|
||||||
|
<th>Bank ref</th>
|
||||||
|
<th>Reconciled</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range $d.Entries}}
|
||||||
|
<tr>
|
||||||
|
<td style="font-size:12px; color:var(--text3); white-space:nowrap;">{{dateShort .Date}}</td>
|
||||||
|
<td style="font-size:13px; max-width:220px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">{{.Description}}</td>
|
||||||
|
<td style="font-size:12px; color:var(--text3);">
|
||||||
|
{{if .EventID}}{{with index $d.Events .EventID}}{{.Name}}{{end}}{{else}}—{{end}}
|
||||||
|
</td>
|
||||||
|
<td style="font-size:12px; color:var(--text3);">
|
||||||
|
{{if .TeamID}}{{with index $d.Teams .TeamID}}{{.Name}}{{end}}{{else}}—{{end}}
|
||||||
|
</td>
|
||||||
|
<td style="text-align:right; font-size:13px; font-weight:600;
|
||||||
|
{{if ge .AmountCents 0}}color:var(--green);{{else}}color:var(--red);{{end}}">
|
||||||
|
{{cents .AmountCents}}
|
||||||
|
</td>
|
||||||
|
<td style="font-size:11px; color:var(--text3); font-family:monospace;">{{if .BankRef}}{{.BankRef}}{{else}}—{{end}}</td>
|
||||||
|
<td style="text-align:center;">
|
||||||
|
{{if .Reconciled}}<span style="color:var(--green);">✓</span>{{else}}<span style="color:var(--text3);">—</span>{{end}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<div class="card empty-state animate-on-scroll">
|
||||||
|
<div style="font-size:36px; margin-bottom:12px;">📒</div>
|
||||||
|
<h3>No ledger entries</h3>
|
||||||
|
<p style="margin-bottom:16px;">Entries are created automatically when requests are approved, or via bank CSV import.</p>
|
||||||
|
<a href="/orgs/{{$d.Org.Slug}}/ledger/import" class="btn btn-primary">Import bank CSV</a>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
167
apps/finance/services/api/main/templates/org_report.html
Normal file
167
apps/finance/services/api/main/templates/org_report.html
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
{{$d := .}}
|
||||||
|
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:24px; flex-wrap:wrap; gap:12px;">
|
||||||
|
<div>
|
||||||
|
<div style="font-size:12px; color:var(--text3); margin-bottom:4px;">
|
||||||
|
<a href="/orgs" style="color:var(--text3); text-decoration:none;">Orgs</a> /
|
||||||
|
<a href="/orgs/{{$d.Org.Slug}}" style="color:var(--text3); text-decoration:none;">{{$d.Org.Name}}</a> /
|
||||||
|
<a href="/orgs/{{$d.Org.Slug}}/years/{{$d.FiscalYear.ID}}/events" style="color:var(--text3); text-decoration:none;">{{$d.FiscalYear.Label}}</a> / Report
|
||||||
|
</div>
|
||||||
|
<h1>{{$d.FiscalYear.Label}} Year-End Report</h1>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; gap:8px;">
|
||||||
|
<a href="/orgs/{{$d.Org.Slug}}/years/{{$d.FiscalYear.ID}}/analysis" class="btn btn-outline btn-sm">Analysis</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Year selector -->
|
||||||
|
<div style="display:flex; gap:6px; flex-wrap:wrap; margin-bottom:20px;">
|
||||||
|
{{range $d.FiscalYears}}
|
||||||
|
<a href="/orgs/{{$d.Org.Slug}}/years/{{.ID}}/report" style="padding:5px 12px; border-radius:20px; text-decoration:none; font-size:12px; font-weight:600;
|
||||||
|
{{if eq $d.FiscalYear.ID .ID}}background:var(--accent);color:#000;{{else}}background:var(--bg3);color:var(--text3);{{end}}">
|
||||||
|
{{.Label}}
|
||||||
|
</a>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Executive summary -->
|
||||||
|
<div class="card animate-on-scroll" style="margin-bottom:28px;">
|
||||||
|
<h2 style="margin-bottom:16px;">Executive Summary</h2>
|
||||||
|
<div style="display:grid; grid-template-columns:repeat(4,1fr); gap:16px;">
|
||||||
|
<div>
|
||||||
|
<div style="font-size:11px; font-weight:700; letter-spacing:.06em; color:var(--text3); margin-bottom:6px;">PLANNED INCOME</div>
|
||||||
|
<div style="font-size:20px; font-weight:700; color:var(--green);">{{cents $d.TotalPlannedIncome}}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style="font-size:11px; font-weight:700; letter-spacing:.06em; color:var(--text3); margin-bottom:6px;">ACTUAL INCOME</div>
|
||||||
|
<div style="font-size:20px; font-weight:700; color:{{varColor $d.TotalPlannedIncome $d.TotalActualIncome}};">{{cents $d.TotalActualIncome}}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style="font-size:11px; font-weight:700; letter-spacing:.06em; color:var(--text3); margin-bottom:6px;">PLANNED EXPENSE</div>
|
||||||
|
<div style="font-size:20px; font-weight:700; color:var(--red);">{{cents $d.TotalPlannedExpense}}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style="font-size:11px; font-weight:700; letter-spacing:.06em; color:var(--text3); margin-bottom:6px;">ACTUAL EXPENSE</div>
|
||||||
|
<div style="font-size:20px; font-weight:700; color:{{varColor $d.TotalPlannedExpense $d.TotalActualExpense}};">{{cents $d.TotalActualExpense}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:16px; padding-top:16px; border-top:1px solid var(--border); display:flex; gap:32px;">
|
||||||
|
<div>
|
||||||
|
<span style="font-size:12px; color:var(--text3);">Planned net: </span>
|
||||||
|
<span style="font-weight:600;">{{cents (sub $d.TotalPlannedIncome $d.TotalPlannedExpense)}}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span style="font-size:12px; color:var(--text3);">Actual net: </span>
|
||||||
|
<span style="font-weight:600; color:{{varColor (sub $d.TotalPlannedIncome $d.TotalPlannedExpense) (sub $d.TotalActualIncome $d.TotalActualExpense)}};">
|
||||||
|
{{cents (sub $d.TotalActualIncome $d.TotalActualExpense)}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span style="font-size:12px; color:var(--text3);">Events: </span>
|
||||||
|
<span style="font-weight:600;">{{len $d.EventReports}}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span style="font-size:12px; color:var(--text3);">Period: </span>
|
||||||
|
<span style="font-weight:600;">{{dateShort $d.FiscalYear.StartDate}} — {{dateShort $d.FiscalYear.EndDate}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Event reports -->
|
||||||
|
{{range $d.EventReports}}
|
||||||
|
{{$ev := .Event}}
|
||||||
|
<div class="card animate-on-scroll" style="margin-bottom:20px;">
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:flex-start; flex-wrap:wrap; gap:12px; margin-bottom:16px;">
|
||||||
|
<div>
|
||||||
|
<h2 style="margin-bottom:4px;">{{$ev.Name}}</h2>
|
||||||
|
<div style="font-size:12px; color:var(--text3);">{{dateShort $ev.DateStart}} — {{dateShort $ev.DateEnd}}</div>
|
||||||
|
{{if .Teams}}
|
||||||
|
<div style="margin-top:6px;">
|
||||||
|
{{range .Teams}}<span style="display:inline-block; padding:1px 6px; border-radius:4px; background:var(--bg3); margin-right:3px; font-size:11px; color:var(--text2);">{{.Name}}</span>{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
<div style="display:grid; grid-template-columns:repeat(4,auto); gap:16px; text-align:right;">
|
||||||
|
<div>
|
||||||
|
<div style="font-size:10px; font-weight:700; letter-spacing:.06em; color:var(--text3); margin-bottom:3px;">PLAN INC</div>
|
||||||
|
<div style="font-size:14px; font-weight:600; color:var(--green);">{{cents .PlannedIncome}}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style="font-size:10px; font-weight:700; letter-spacing:.06em; color:var(--text3); margin-bottom:3px;">ACT INC</div>
|
||||||
|
<div style="font-size:14px; font-weight:600; color:{{varColor .PlannedIncome .ActualIncome}};">{{cents .ActualIncome}}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style="font-size:10px; font-weight:700; letter-spacing:.06em; color:var(--text3); margin-bottom:3px;">PLAN EXP</div>
|
||||||
|
<div style="font-size:14px; font-weight:600; color:var(--red);">{{cents .PlannedExpense}}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style="font-size:10px; font-weight:700; letter-spacing:.06em; color:var(--text3); margin-bottom:3px;">ACT EXP</div>
|
||||||
|
<div style="font-size:14px; font-weight:600; color:{{varColor .PlannedExpense .ActualExpense}};">{{cents .ActualExpense}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if $ev.Description}}
|
||||||
|
<p style="font-size:13px; color:var(--text2); line-height:1.6; margin-bottom:14px;">{{$ev.Description}}</p>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .BudgetLines}}
|
||||||
|
<details style="margin-bottom:14px;">
|
||||||
|
<summary style="font-size:12px; font-weight:700; color:var(--text3); cursor:pointer; letter-spacing:.04em;">BUDGET LINES ({{len .BudgetLines}})</summary>
|
||||||
|
<table style="margin-top:10px;">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Category</th>
|
||||||
|
<th>Description</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th style="text-align:right;">Planned</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .BudgetLines}}
|
||||||
|
<tr>
|
||||||
|
<td style="font-size:13px; font-weight:600;">{{.Category}}</td>
|
||||||
|
<td style="font-size:12px; color:var(--text3);">{{.Description}}</td>
|
||||||
|
<td style="font-size:11px; font-weight:700; {{if eq (print .Type) "income"}}color:var(--green);{{else}}color:var(--red);{{end}}">{{.Type}}</td>
|
||||||
|
<td style="text-align:right; font-size:13px;">{{cents .PlannedCents}}</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</details>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .Comments}}
|
||||||
|
<div>
|
||||||
|
<div style="font-size:11px; font-weight:700; letter-spacing:.06em; color:var(--text3); margin-bottom:10px;">TEAM FEEDBACK</div>
|
||||||
|
<div style="display:flex; flex-direction:column; gap:10px;">
|
||||||
|
{{range .Comments}}
|
||||||
|
<div style="padding:10px 14px; border-radius:var(--radius-sm); background:var(--bg3); border-left:3px solid var(--accent2);">
|
||||||
|
<div style="display:flex; justify-content:space-between; margin-bottom:5px;">
|
||||||
|
<span style="font-size:12px; font-weight:600;">{{.UserEmail}}</span>
|
||||||
|
<span style="font-size:11px; color:var(--text3);">{{dateShort .CreatedAt}}</span>
|
||||||
|
</div>
|
||||||
|
<p style="font-size:13px; color:var(--text2); line-height:1.5; margin:0;">{{.Body}}</p>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{else if eq (print $d.FiscalYear.Status) "closed"}}
|
||||||
|
<p style="font-size:12px; color:var(--text3); font-style:italic;">No team feedback submitted for this event.</p>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<div class="card empty-state animate-on-scroll">
|
||||||
|
<div style="font-size:36px; margin-bottom:12px;">📊</div>
|
||||||
|
<h3>No events in this fiscal year</h3>
|
||||||
|
<p>Events and their outcomes will appear here once created.</p>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if eq (print $d.FiscalYear.Status) "active"}}
|
||||||
|
<div style="margin-top:24px; padding:16px 20px; border-radius:var(--radius); background:rgba(251,191,36,0.08); border:1px solid rgba(251,191,36,0.3);">
|
||||||
|
<p style="font-size:13px; color:#fbbf24; margin:0;">⚠ This fiscal year is still active. The report will be final once the year is closed. Team feedback can be added after closing.</p>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
311
apps/finance/services/api/main/templates/org_request_detail.html
Normal file
311
apps/finance/services/api/main/templates/org_request_detail.html
Normal file
@ -0,0 +1,311 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
{{$d := .}}
|
||||||
|
{{$req := $d.Request}}
|
||||||
|
{{$isNew := eq $req.ID ""}}
|
||||||
|
{{$status := $req.CurrentStatus}}
|
||||||
|
{{$isManager := or (eq (print $d.MyRole) "admin") (eq (print $d.MyRole) "finance")}}
|
||||||
|
{{$isOwner := eq $req.SubmittedBy $d.UserID}}
|
||||||
|
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:24px; flex-wrap:wrap; gap:12px;">
|
||||||
|
<div>
|
||||||
|
<div style="font-size:12px; color:var(--text3); margin-bottom:4px;">
|
||||||
|
<a href="/orgs" style="color:var(--text3); text-decoration:none;">Orgs</a> /
|
||||||
|
<a href="/orgs/{{$d.Org.Slug}}" style="color:var(--text3); text-decoration:none;">{{$d.Org.Name}}</a> /
|
||||||
|
<a href="/orgs/{{$d.Org.Slug}}/requests" style="color:var(--text3); text-decoration:none;">Requests</a> /
|
||||||
|
{{if $isNew}}New{{else}}{{$req.Type}}{{end}}
|
||||||
|
</div>
|
||||||
|
<h1>{{if $isNew}}New Request{{else}}{{$req.Type}} Request{{end}}</h1>
|
||||||
|
</div>
|
||||||
|
{{if not $isNew}}
|
||||||
|
<span style="font-size:12px; font-weight:700; padding:4px 14px; border-radius:20px; background:{{statusColor (print $status)}};">{{$status}}</span>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if $isNew}}
|
||||||
|
<!-- ── New request form ──────────────────────────────────────────────────── -->
|
||||||
|
<div class="card animate-on-scroll" style="max-width:600px;">
|
||||||
|
<form method="post" action="/orgs/{{$d.Org.Slug}}/requests/new" id="req-form">
|
||||||
|
<div style="margin-bottom:16px;">
|
||||||
|
<label class="form-label">Request type</label>
|
||||||
|
<select class="form-input" name="type" id="req-type" onchange="showFields(this.value)" required>
|
||||||
|
<option value="">— choose —</option>
|
||||||
|
<option value="reimbursement">Reimbursement — you already paid, want money back</option>
|
||||||
|
<option value="purchase_order">Purchase Order — request to buy something</option>
|
||||||
|
<option value="cash_advance">Cash Advance — get cash upfront to spend</option>
|
||||||
|
<option value="income">Income — record an expected or received payment</option>
|
||||||
|
<option value="budget_transfer">Budget Transfer — move budget between lines</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div style="margin-bottom:16px;">
|
||||||
|
<label class="form-label">Description</label>
|
||||||
|
<textarea class="form-input" name="description" rows="2" placeholder="Brief description of this request" required></textarea>
|
||||||
|
</div>
|
||||||
|
<div style="display:grid; grid-template-columns:1fr 1fr; gap:12px; margin-bottom:16px;">
|
||||||
|
<div>
|
||||||
|
<label class="form-label">Amount (€)</label>
|
||||||
|
<input class="form-input" type="text" name="amount" placeholder="0.00" required>
|
||||||
|
</div>
|
||||||
|
<div id="field-due-date" style="display:none;">
|
||||||
|
<label class="form-label">Due date</label>
|
||||||
|
<input class="form-input" type="date" name="due_date">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="field-vendor" style="display:none; margin-bottom:16px;">
|
||||||
|
<label class="form-label">Vendor / supplier</label>
|
||||||
|
<input class="form-input" type="text" name="vendor" placeholder="Company or person name">
|
||||||
|
</div>
|
||||||
|
<div id="field-payer" style="display:none; margin-bottom:16px;">
|
||||||
|
<label class="form-label">Payer name</label>
|
||||||
|
<input class="form-input" type="text" name="payer_name" placeholder="Who is expected to pay">
|
||||||
|
</div>
|
||||||
|
<div id="field-payment-method" style="display:none; margin-bottom:16px;">
|
||||||
|
<label class="form-label">Payment method</label>
|
||||||
|
<select class="form-input" name="payment_method">
|
||||||
|
<option value="bank_transfer">Bank transfer</option>
|
||||||
|
<option value="cash">Cash</option>
|
||||||
|
<option value="card">Card</option>
|
||||||
|
<option value="mbway">MBWay</option>
|
||||||
|
<option value="other">Other</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div style="display:grid; grid-template-columns:1fr 1fr; gap:12px; margin-bottom:20px;">
|
||||||
|
<div>
|
||||||
|
<label class="form-label">Event (optional)</label>
|
||||||
|
<select class="form-input" name="event_id">
|
||||||
|
<option value="">— none —</option>
|
||||||
|
{{if $d.FiscalYear}}{{end}}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label">Team (optional)</label>
|
||||||
|
<select class="form-input" name="team_id">
|
||||||
|
<option value="">— none —</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; gap:10px;">
|
||||||
|
<button type="submit" class="btn btn-primary">Create request</button>
|
||||||
|
<a href="/orgs/{{$d.Org.Slug}}/requests" class="btn btn-outline">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="module">
|
||||||
|
function showFields(type) {
|
||||||
|
const show = (id, val) => document.getElementById(id).style.display = val ? 'block' : 'none';
|
||||||
|
show('field-vendor', ['reimbursement','purchase_order'].includes(type));
|
||||||
|
show('field-payer', type === 'income');
|
||||||
|
show('field-payment-method', ['reimbursement','cash_advance'].includes(type));
|
||||||
|
show('field-due-date', ['purchase_order','income'].includes(type));
|
||||||
|
}
|
||||||
|
window.showFields = showFields;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{{else}}
|
||||||
|
<!-- ── Request detail ────────────────────────────────────────────────────── -->
|
||||||
|
<div style="display:grid; grid-template-columns:2fr 1fr; gap:20px;">
|
||||||
|
<div>
|
||||||
|
<!-- Details card -->
|
||||||
|
<div class="card animate-on-scroll" style="margin-bottom:20px;">
|
||||||
|
<h2 style="margin-bottom:16px;">Details</h2>
|
||||||
|
<div style="display:grid; grid-template-columns:1fr 1fr; gap:12px; font-size:13px;">
|
||||||
|
<div>
|
||||||
|
<div style="color:var(--text3); font-size:11px; font-weight:700; letter-spacing:.06em; margin-bottom:4px;">AMOUNT</div>
|
||||||
|
<div style="font-size:20px; font-weight:700; color:var(--accent2);">{{cents $req.AmountCents}}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style="color:var(--text3); font-size:11px; font-weight:700; letter-spacing:.06em; margin-bottom:4px;">SUBMITTED BY</div>
|
||||||
|
<div>{{$req.SubmitterEmail}}</div>
|
||||||
|
</div>
|
||||||
|
{{if $req.Vendor}}
|
||||||
|
<div>
|
||||||
|
<div style="color:var(--text3); font-size:11px; font-weight:700; letter-spacing:.06em; margin-bottom:4px;">VENDOR</div>
|
||||||
|
<div>{{$req.Vendor}}</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{if $req.PaymentMethod}}
|
||||||
|
<div>
|
||||||
|
<div style="color:var(--text3); font-size:11px; font-weight:700; letter-spacing:.06em; margin-bottom:4px;">PAYMENT METHOD</div>
|
||||||
|
<div>{{$req.PaymentMethod}}</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{if $d.Event}}
|
||||||
|
<div>
|
||||||
|
<div style="color:var(--text3); font-size:11px; font-weight:700; letter-spacing:.06em; margin-bottom:4px;">EVENT</div>
|
||||||
|
<div><a href="/orgs/{{$d.Org.Slug}}/years/{{$req.FiscalYearID}}/events/{{$req.EventID}}" style="color:var(--accent2); text-decoration:none;">{{$d.Event.Name}}</a></div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{if $d.Team}}
|
||||||
|
<div>
|
||||||
|
<div style="color:var(--text3); font-size:11px; font-weight:700; letter-spacing:.06em; margin-bottom:4px;">TEAM</div>
|
||||||
|
<div>{{$d.Team.Name}}</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
<div style="grid-column:1/-1;">
|
||||||
|
<div style="color:var(--text3); font-size:11px; font-weight:700; letter-spacing:.06em; margin-bottom:4px;">DESCRIPTION</div>
|
||||||
|
<div style="color:var(--text2); line-height:1.6;">{{$req.Description}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- PO Delivery -->
|
||||||
|
{{if and (eq (print $req.Type) "purchase_order") (eq (print $status) "ordered")}}
|
||||||
|
<div class="card animate-on-scroll" style="margin-bottom:20px; border-color:#fbbf24;">
|
||||||
|
<h2 style="margin-bottom:14px;">Record Delivery</h2>
|
||||||
|
<form method="post" action="/orgs/{{$d.Org.Slug}}/requests/{{$req.ID}}/delivery">
|
||||||
|
<div style="display:grid; grid-template-columns:1fr 1fr; gap:12px; margin-bottom:14px;">
|
||||||
|
<div>
|
||||||
|
<label class="form-label">Actual amount (€)</label>
|
||||||
|
<input class="form-input" type="text" name="actual_amount" placeholder="{{cents $req.AmountCents}}" required>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label">Actual vendor</label>
|
||||||
|
<input class="form-input" type="text" name="actual_vendor" value="{{$req.Vendor}}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-bottom:14px;">
|
||||||
|
<label style="display:flex; align-items:center; gap:8px; cursor:pointer; font-size:13px;">
|
||||||
|
<input type="checkbox" name="store_changed" value="true"> Store/vendor changed from original
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div style="margin-bottom:16px;" id="change-note-wrap" style="display:none;">
|
||||||
|
<label class="form-label">Change note</label>
|
||||||
|
<input class="form-input" type="text" name="change_note" placeholder="What changed?">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">Confirm delivery</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if $req.Delivery}}
|
||||||
|
<div class="card animate-on-scroll" style="margin-bottom:20px;">
|
||||||
|
<h2 style="margin-bottom:12px;">Delivery record</h2>
|
||||||
|
<div style="display:grid; grid-template-columns:1fr 1fr; gap:10px; font-size:13px;">
|
||||||
|
<div><span style="color:var(--text3);">Actual amount:</span> {{cents $req.Delivery.ActualAmountCents}}</div>
|
||||||
|
<div><span style="color:var(--text3);">Vendor:</span> {{$req.Delivery.ActualVendor}}</div>
|
||||||
|
<div><span style="color:var(--text3);">Delivered:</span> {{dateShort $req.Delivery.DeliveredAt}}</div>
|
||||||
|
{{if $req.Delivery.StoreChanged}}<div style="grid-column:1/-1;"><span style="color:#fbbf24; font-size:11px; font-weight:700;">⚠ Store changed</span> — {{$req.Delivery.ChangeNote}}</div>{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<!-- Cash advance settlement -->
|
||||||
|
{{if and (eq (print $req.Type) "cash_advance") (or (eq (print $status) "disbursed") (eq (print $status) "settlement_due") (eq (print $status) "partial_settlement"))}}
|
||||||
|
<div class="card animate-on-scroll" style="margin-bottom:20px; border-color:#fbbf24;">
|
||||||
|
<h2 style="margin-bottom:14px;">Submit Settlement</h2>
|
||||||
|
<form method="post" action="/orgs/{{$d.Org.Slug}}/requests/{{$req.ID}}/settle">
|
||||||
|
<div style="display:grid; grid-template-columns:1fr 1fr; gap:12px; margin-bottom:16px;">
|
||||||
|
<div>
|
||||||
|
<label class="form-label">Amount spent (€)</label>
|
||||||
|
<input class="form-input" type="text" name="amount_spent" required>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label">Amount returned (€)</label>
|
||||||
|
<input class="form-input" type="text" name="amount_returned" placeholder="0.00">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">Submit settlement</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if $req.Settlement}}
|
||||||
|
<div class="card animate-on-scroll" style="margin-bottom:20px;">
|
||||||
|
<h2 style="margin-bottom:12px;">Settlement record</h2>
|
||||||
|
<div style="display:grid; grid-template-columns:1fr 1fr; gap:10px; font-size:13px;">
|
||||||
|
<div><span style="color:var(--text3);">Spent:</span> {{cents $req.Settlement.AmountSpentCents}}</div>
|
||||||
|
<div><span style="color:var(--text3);">Returned:</span> {{cents $req.Settlement.AmountReturnedCents}}</div>
|
||||||
|
<div><span style="color:var(--text3);">Settled at:</span> {{dateShort $req.Settlement.SettledAt}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<!-- Attachments -->
|
||||||
|
<div class="card animate-on-scroll" style="margin-bottom:20px;">
|
||||||
|
<h2 style="margin-bottom:12px;">Attachments</h2>
|
||||||
|
{{if $d.Attachments}}
|
||||||
|
<div style="display:flex; flex-direction:column; gap:8px; margin-bottom:12px;">
|
||||||
|
{{range $d.Attachments}}
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center; padding:8px 12px; background:var(--bg3); border-radius:var(--radius-sm);">
|
||||||
|
<span style="font-size:13px;">{{.Filename}}</span>
|
||||||
|
<span style="font-size:11px; color:var(--text3);">{{.MimeType}}</span>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<p style="font-size:13px; color:var(--text3); margin-bottom:12px;">No attachments yet.</p>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status log -->
|
||||||
|
<div class="card animate-on-scroll">
|
||||||
|
<h2 style="margin-bottom:14px;">Status history</h2>
|
||||||
|
<div style="display:flex; flex-direction:column; gap:10px;">
|
||||||
|
{{range $req.StatusLog}}
|
||||||
|
<div style="display:flex; gap:12px; align-items:flex-start;">
|
||||||
|
<div style="width:8px; height:8px; border-radius:50%; background:var(--accent); margin-top:5px; flex-shrink:0;"></div>
|
||||||
|
<div style="flex:1;">
|
||||||
|
<div style="display:flex; gap:8px; align-items:center; margin-bottom:2px;">
|
||||||
|
<span style="font-size:12px; font-weight:700; padding:1px 7px; border-radius:20px; background:{{statusColor (print .Status)}};">{{.Status}}</span>
|
||||||
|
<span style="font-size:11px; color:var(--text3);">{{dateShort .ChangedAt}} · {{.ChangedBy}}</span>
|
||||||
|
</div>
|
||||||
|
{{if .Comment}}<p style="font-size:13px; color:var(--text2); margin:4px 0 0; line-height:1.5;">{{.Comment}}</p>{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sidebar actions -->
|
||||||
|
<div>
|
||||||
|
<div class="card animate-on-scroll">
|
||||||
|
<h2 style="margin-bottom:14px; font-size:14px;">Actions</h2>
|
||||||
|
<form method="post" action="/orgs/{{$d.Org.Slug}}/requests/{{$req.ID}}/action" style="display:flex; flex-direction:column; gap:8px;">
|
||||||
|
<div>
|
||||||
|
<label class="form-label" style="font-size:11px;">Comment</label>
|
||||||
|
<textarea class="form-input" name="comment" rows="2" placeholder="Optional note…" style="font-size:12px;"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if eq (print $status) "draft"}}
|
||||||
|
{{if $isOwner}}<button name="action" value="submit" class="btn btn-primary" style="width:100%;">Submit for review</button>{{end}}
|
||||||
|
<button name="action" value="cancel" class="btn btn-outline" style="width:100%; color:var(--red);" onclick="return confirm('Cancel this request?')">Cancel</button>
|
||||||
|
{{end}}
|
||||||
|
{{if eq (print $status) "submitted"}}
|
||||||
|
{{if $isManager}}<button name="action" value="review" class="btn btn-primary" style="width:100%;">Start review</button>{{end}}
|
||||||
|
<button name="action" value="cancel" class="btn btn-outline" style="width:100%; color:var(--red);" onclick="return confirm('Cancel?')">Cancel</button>
|
||||||
|
{{end}}
|
||||||
|
{{if eq (print $status) "info_requested"}}
|
||||||
|
{{if $isOwner}}<button name="action" value="submit" class="btn btn-primary" style="width:100%;">Resubmit</button>{{end}}
|
||||||
|
{{end}}
|
||||||
|
{{if eq (print $status) "under_review"}}
|
||||||
|
{{if $isManager}}
|
||||||
|
<button name="action" value="approve" class="btn btn-primary" style="width:100%;" onclick="return confirm('Approve?')">Approve</button>
|
||||||
|
<button name="action" value="request_info" class="btn btn-outline" style="width:100%;">Request info</button>
|
||||||
|
<button name="action" value="reject" class="btn btn-sm" style="width:100%; color:var(--red); background:transparent; border:1px solid var(--red);" onclick="return confirm('Reject?')">Reject</button>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
{{if eq (print $status) "approved"}}
|
||||||
|
{{if $isManager}}
|
||||||
|
{{if eq (print $req.Type) "reimbursement"}}<button name="action" value="mark_paid" class="btn btn-primary" style="width:100%;">Mark as paid</button>{{end}}
|
||||||
|
{{if eq (print $req.Type) "purchase_order"}}<button name="action" value="mark_ordered" class="btn btn-primary" style="width:100%;">Mark as ordered</button>{{end}}
|
||||||
|
{{if eq (print $req.Type) "cash_advance"}}<button name="action" value="disburse" class="btn btn-primary" style="width:100%;">Disburse cash</button>{{end}}
|
||||||
|
{{if eq (print $req.Type) "income"}}<button name="action" value="mark_pending_payment" class="btn btn-primary" style="width:100%;">Mark pending payment</button>{{end}}
|
||||||
|
{{if eq (print $req.Type) "budget_transfer"}}<button name="action" value="done" class="btn btn-primary" style="width:100%;">Mark done</button>{{end}}
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
{{if eq (print $status) "disbursed"}}
|
||||||
|
{{if $isManager}}<button name="action" value="settlement_due" class="btn btn-outline" style="width:100%;">Request settlement</button>{{end}}
|
||||||
|
{{end}}
|
||||||
|
{{if eq (print $status) "pending_payment"}}
|
||||||
|
{{if $isManager}}<button name="action" value="mark_received" class="btn btn-primary" style="width:100%;">Mark as received</button>{{end}}
|
||||||
|
{{end}}
|
||||||
|
{{if or (eq (print $status) "paid") (eq (print $status) "delivered") (eq (print $status) "received") (eq (print $status) "settled")}}
|
||||||
|
{{if $isManager}}<button name="action" value="reconcile" class="btn btn-outline" style="width:100%;">Mark reconciled</button>{{end}}
|
||||||
|
{{end}}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
75
apps/finance/services/api/main/templates/org_requests.html
Normal file
75
apps/finance/services/api/main/templates/org_requests.html
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
{{$d := .}}
|
||||||
|
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:24px; flex-wrap:wrap; gap:12px;">
|
||||||
|
<div>
|
||||||
|
<div style="font-size:12px; color:var(--text3); margin-bottom:4px;">
|
||||||
|
<a href="/orgs" style="color:var(--text3); text-decoration:none;">Orgs</a> /
|
||||||
|
<a href="/orgs/{{$d.Org.Slug}}" style="color:var(--text3); text-decoration:none;">{{$d.Org.Name}}</a> / Requests
|
||||||
|
</div>
|
||||||
|
<h1>Transaction Requests</h1>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex; gap:8px; flex-wrap:wrap;">
|
||||||
|
<a href="/orgs/{{$d.Org.Slug}}/ledger" class="btn btn-outline btn-sm">Ledger</a>
|
||||||
|
<a href="/orgs/{{$d.Org.Slug}}/requests/new" class="btn btn-primary btn-sm">+ New request</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status filter tabs -->
|
||||||
|
{{$cur := $d.StatusFilter}}
|
||||||
|
<div style="display:flex; gap:6px; flex-wrap:wrap; margin-bottom:20px; font-size:12px; font-weight:600;">
|
||||||
|
<a href="?"style="padding:5px 12px; border-radius:20px; text-decoration:none; {{if eq $cur ""}}background:var(--accent);color:#000;{{else}}background:var(--bg3);color:var(--text3);{{end}}">All</a>
|
||||||
|
<a href="?status=draft" style="padding:5px 12px; border-radius:20px; text-decoration:none; {{if eq $cur "draft"}}background:var(--accent);color:#000;{{else}}background:var(--bg3);color:var(--text3);{{end}}">draft</a>
|
||||||
|
<a href="?status=submitted" style="padding:5px 12px; border-radius:20px; text-decoration:none; {{if eq $cur "submitted"}}background:var(--accent);color:#000;{{else}}background:var(--bg3);color:var(--text3);{{end}}">submitted</a>
|
||||||
|
<a href="?status=under_review" style="padding:5px 12px; border-radius:20px; text-decoration:none; {{if eq $cur "under_review"}}background:var(--accent);color:#000;{{else}}background:var(--bg3);color:var(--text3);{{end}}">under_review</a>
|
||||||
|
<a href="?status=info_requested" style="padding:5px 12px; border-radius:20px; text-decoration:none; {{if eq $cur "info_requested"}}background:var(--accent);color:#000;{{else}}background:var(--bg3);color:var(--text3);{{end}}">info_requested</a>
|
||||||
|
<a href="?status=approved" style="padding:5px 12px; border-radius:20px; text-decoration:none; {{if eq $cur "approved"}}background:var(--accent);color:#000;{{else}}background:var(--bg3);color:var(--text3);{{end}}">approved</a>
|
||||||
|
<a href="?status=rejected" style="padding:5px 12px; border-radius:20px; text-decoration:none; {{if eq $cur "rejected"}}background:var(--accent);color:#000;{{else}}background:var(--bg3);color:var(--text3);{{end}}">rejected</a>
|
||||||
|
<a href="?status=paid" style="padding:5px 12px; border-radius:20px; text-decoration:none; {{if eq $cur "paid"}}background:var(--accent);color:#000;{{else}}background:var(--bg3);color:var(--text3);{{end}}">paid</a>
|
||||||
|
<a href="?status=ordered" style="padding:5px 12px; border-radius:20px; text-decoration:none; {{if eq $cur "ordered"}}background:var(--accent);color:#000;{{else}}background:var(--bg3);color:var(--text3);{{end}}">ordered</a>
|
||||||
|
<a href="?status=settled" style="padding:5px 12px; border-radius:20px; text-decoration:none; {{if eq $cur "settled"}}background:var(--accent);color:#000;{{else}}background:var(--bg3);color:var(--text3);{{end}}">settled</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if $d.Requests}}
|
||||||
|
<div class="card animate-on-scroll" style="padding:0; overflow:hidden;">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Description</th>
|
||||||
|
<th>By</th>
|
||||||
|
<th style="text-align:right;">Amount</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range $d.Requests}}
|
||||||
|
{{$status := .CurrentStatus}}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<span style="font-size:11px; font-weight:700; padding:2px 7px; border-radius:4px; background:var(--bg3); color:var(--text2);">{{.Type}}</span>
|
||||||
|
</td>
|
||||||
|
<td style="max-width:260px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:13px;">{{.Description}}</td>
|
||||||
|
<td style="font-size:12px; color:var(--text3);">{{.SubmitterEmail}}</td>
|
||||||
|
<td style="text-align:right; font-size:13px; font-weight:600;">{{cents .AmountCents}}</td>
|
||||||
|
<td>
|
||||||
|
<span style="font-size:11px; font-weight:700; padding:2px 8px; border-radius:20px; background:{{statusColor (print $status)}};">{{$status}}</span>
|
||||||
|
</td>
|
||||||
|
<td style="text-align:right;">
|
||||||
|
<a href="/orgs/{{$d.Org.Slug}}/requests/{{.ID}}" class="btn btn-outline btn-sm">View</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<div class="card empty-state animate-on-scroll">
|
||||||
|
<div style="font-size:36px; margin-bottom:12px;">📋</div>
|
||||||
|
<h3>No requests{{if $d.StatusFilter}} with status "{{$d.StatusFilter}}"{{end}}</h3>
|
||||||
|
<p style="margin-bottom:16px;">Submit transaction requests to track expenses, income, and transfers.</p>
|
||||||
|
<a href="/orgs/{{$d.Org.Slug}}/requests/new" class="btn btn-primary">New request</a>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
Loading…
x
Reference in New Issue
Block a user