From 2f01b55d5a779702b39764c315a5a09b55370e69 Mon Sep 17 00:00:00 2001 From: sjcoetzeesj-coder Date: Tue, 20 Oct 2026 12:30:06 +0200 Subject: [PATCH] feat(sars): end-of-month submission tick-boxes on SARS registrations Add a simple yes/no tick box on the far right of every table in the SARS registrations tab, so SJ can tick off each client as the period's return is filed and confirm nothing was missed at end of month. - New PB collection sars_submission_flags (pbc_3539276419): company relation, return_type select, period (YYYYMM), submitted bool, submitted_at date. One row per (company, return_type, period). - New route /api/sars-submission-flags with GET (list), POST (upsert, server owns submitted_at) and DELETE (Clear all for a period). Hard-allowlisted fields and a fixed collection id. - Explorer gains a period banner, a submitted/total counter, a Submitted column on desktop and a tick box on the mobile cards, and a Clear all button guarded by a confirm. The counter is scoped to the rows actually on screen. A flag can outlive the filter that surfaced its company (a client can lose its PAY/E number), and counting those made the banner read 4/18 while only 3 boxes were ticked. Period is the current month and rolls over on its own; Clear all is what resets a type between periods. Per-month history is deliberately out of scope for this first pass. --- .../app/api/sars-submission-flags/route.ts | 203 +++++++++++ .../SarsRegistrationsExplorer.tsx | 319 +++++++++++++++++- Workflow/lib/api.ts | 65 +++- Workflow/lib/types.ts | 45 +++ 4 files changed, 617 insertions(+), 15 deletions(-) create mode 100644 Workflow/app/api/sars-submission-flags/route.ts diff --git a/Workflow/app/api/sars-submission-flags/route.ts b/Workflow/app/api/sars-submission-flags/route.ts new file mode 100644 index 0000000..0f9f365 --- /dev/null +++ b/Workflow/app/api/sars-submission-flags/route.ts @@ -0,0 +1,203 @@ +// app/api/sars-submission-flags/route.ts +// End-of-month SARS submission tick-boxes. +// +// GET /api/sars-submission-flags?return_type=EMP201&period=202610 +// List all flags for (return_type, period). The explorer +// cross-references these against the ysterbaard_companies rows so +// every company in the active filter shows up with a tick state. +// +// POST /api/sars-submission-flags body: { company, return_type, period, submitted } +// Upsert: find-or-create the (company, return_type, period) +// tuple, then flip submitted + submitted_at. The server is the +// sole writer of submitted_at so the client can't spoof it. +// +// DELETE /api/sars-submission-flags?return_type=EMP201&period=202610 +// "Clear all" — removes every flag for (return_type, period). +// Used at the start of a new month to reset the checklist. +// +// Hardcoded collection id = pbc_3539276419 (sars_submission_flags). Field- +// allowlisted. No other PB writes pass. + +import { NextRequest, NextResponse } from "next/server"; +import { + createRecord, + deleteRecord, + fetchCollection, + updateRecord, +} from "@/lib/pb"; +import { + SARS_SUBMISSION_RETURN_TYPES, + type YsterbaardSarsSubmissionFlag, +} from "@/lib/types"; + +export const dynamic = "force-dynamic"; + +const COLLECTION = "sars_submission_flags"; + +function normaliseReturnType(raw: unknown): string { + if (typeof raw !== "string") return ""; + return raw.trim(); +} + +function normalisePeriod(raw: unknown): string { + if (typeof raw !== "string") return ""; + return raw.trim(); +} + +function safeId(raw: unknown): string { + return typeof raw === "string" && /^[a-z0-9]{15}$/.test(raw) ? raw : ""; +} + +function safeCompanyId(raw: unknown): string { + return typeof raw === "string" && /^[a-z0-9]{15}$/.test(raw) ? raw : ""; +} + +/** Escape a single quote for PB filter strings. PB uses '' to mean one '. */ +function escapeFilter(raw: string): string { + return raw.replace(/'/g, "''"); +} + +/** ISO-ish timestamp safe for PB's date field. */ +function nowIso(): string { + // PB's date field accepts full RFC3339 with offset; trim milliseconds + // because PB rejects them. + return new Date().toISOString().replace(/\.\d{3}Z$/, "Z"); +} + +export async function GET(req: NextRequest) { + const url = new URL(req.url); + const returnType = normaliseReturnType(url.searchParams.get("return_type")); + const period = normalisePeriod(url.searchParams.get("period")); + + if (!returnType || !SARS_SUBMISSION_RETURN_TYPES.includes(returnType as (typeof SARS_SUBMISSION_RETURN_TYPES)[number])) { + return NextResponse.json( + { error: "return_type is required and must be one of: " + SARS_SUBMISSION_RETURN_TYPES.join(", ") }, + { status: 400 } + ); + } + if (!period || !/^[0-9]{6}$/.test(period)) { + return NextResponse.json( + { error: "period is required (format YYYYMM, e.g. 202610)" }, + { status: 400 } + ); + } + + const filter = `return_type = '${escapeFilter(returnType)}' && period = '${escapeFilter(period)}'`; + try { + const list = await fetchCollection(COLLECTION, { + page: 1, + perPage: 500, + filter, + fields: "id,company,return_type,period,submitted,submitted_at,created,updated" + }); + return NextResponse.json(list, { status: 200 }); + } catch (err) { + const message = err instanceof Error ? err.message : "Unknown PB error"; + console.error("[sars-submission-flags GET]", message); + return NextResponse.json({ error: message }, { status: 502 }); + } +} + +export async function POST(req: NextRequest) { + let body: Record; + try { + body = (await req.json()) as Record; + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const company = safeCompanyId(body.company); + const returnType = normaliseReturnType(body.return_type); + const period = normalisePeriod(body.period); + const submitted = body.submitted; + + if (!company) { + return NextResponse.json({ error: "company (15-char PB id) is required" }, { status: 400 }); + } + if (!returnType || !SARS_SUBMISSION_RETURN_TYPES.includes(returnType as (typeof SARS_SUBMISSION_RETURN_TYPES)[number])) { + return NextResponse.json( + { error: "return_type is required and must be one of: " + SARS_SUBMISSION_RETURN_TYPES.join(", ") }, + { status: 400 } + ); + } + if (!period || !/^[0-9]{6}$/.test(period)) { + return NextResponse.json({ error: "period is required (format YYYYMM, e.g. 202610)" }, { status: 400 }); + } + if (typeof submitted !== "boolean") { + return NextResponse.json({ error: "submitted must be a boolean" }, { status: 400 }); + } + + const filter = `company = '${company}' && return_type = '${escapeFilter(returnType)}' && period = '${escapeFilter(period)}'`; + + try { + const existing = await fetchCollection(COLLECTION, { + page: 1, + perPage: 1, + filter, + fields: "id,company,return_type,period,submitted,submitted_at,created,updated" + }); + + const payload = { + submitted, + submitted_at: submitted ? nowIso() : null + }; + + if (existing.items.length > 0) { + const row = existing.items[0]; + const updated = await updateRecord(COLLECTION, row.id, payload); + return NextResponse.json(updated, { status: 200 }); + } + + const created = await createRecord(COLLECTION, { + company, + return_type: returnType, + period, + submitted, + submitted_at: payload.submitted_at + }); + return NextResponse.json(created, { status: 200 }); + } catch (err) { + const message = err instanceof Error ? err.message : "Unknown PB error"; + console.error("[sars-submission-flags POST]", message); + return NextResponse.json({ error: message }, { status: 502 }); + } +} + +export async function DELETE(req: NextRequest) { + const url = new URL(req.url); + const returnType = normaliseReturnType(url.searchParams.get("return_type")); + const period = normalisePeriod(url.searchParams.get("period")); + + if (!returnType || !SARS_SUBMISSION_RETURN_TYPES.includes(returnType as (typeof SARS_SUBMISSION_RETURN_TYPES)[number])) { + return NextResponse.json( + { error: "return_type is required and must be one of: " + SARS_SUBMISSION_RETURN_TYPES.join(", ") }, + { status: 400 } + ); + } + if (!period || !/^[0-9]{6}$/.test(period)) { + return NextResponse.json( + { error: "period is required (format YYYYMM, e.g. 202610)" }, + { status: 400 } + ); + } + + const filter = `return_type = '${escapeFilter(returnType)}' && period = '${escapeFilter(period)}'`; + + try { + const list = await fetchCollection(COLLECTION, { + page: 1, + perPage: 500, + filter, + fields: "id" + }); + const ids = list.items.map((r) => r.id).filter(safeId); + for (const id of ids) { + await deleteRecord(COLLECTION, id); + } + return NextResponse.json({ ok: true, deleted: ids.length }, { status: 200 }); + } catch (err) { + const message = err instanceof Error ? err.message : "Unknown PB error"; + console.error("[sars-submission-flags DELETE]", message); + return NextResponse.json({ error: message }, { status: 502 }); + } +} \ No newline at end of file diff --git a/Workflow/app/sars-registrations/SarsRegistrationsExplorer.tsx b/Workflow/app/sars-registrations/SarsRegistrationsExplorer.tsx index 489dbd1..e3064e8 100644 --- a/Workflow/app/sars-registrations/SarsRegistrationsExplorer.tsx +++ b/Workflow/app/sars-registrations/SarsRegistrationsExplorer.tsx @@ -19,10 +19,20 @@ import { useEffect, useMemo, useState } from "react"; import Link from "next/link"; -import { useQuery } from "@tanstack/react-query"; -import { pbList } from "@/lib/api"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + clearSarsSubmissionFlags, + fetchSarsSubmissionFlags, + pbList, + upsertSarsSubmissionFlag +} from "@/lib/api"; import Skeleton from "@/components/Skeleton"; -import type { PBList, YsterbaardCompany } from "@/lib/types"; +import type { + PBList, + SarsSubmissionReturnType, + YsterbaardCompany, + YsterbaardSarsSubmissionFlag +} from "@/lib/types"; export type RegReturnType = | "EMP201" @@ -137,6 +147,46 @@ const TYPE_OPTIONS: TypeOption[] = [ const RETURN_TYPE_STORAGE_KEY = "sars-registrations:selected-type"; +/** + * Same enum as SarsSubmissionReturnType. Kept locally so this component's + * type world (`RegReturnType`) and the PB collection type + * (`SarsSubmissionReturnType`) don't have to import each other. When you + * add a new value to either, add it to the other in the same change. + */ +const REG_TO_FLAG_TYPE: Record = { + EMP201: "EMP201", + VAT201: "VAT201", + IncomeTax: "IncomeTax", + ProvTax: "ProvTax", + AnnualReturns: "AnnualReturns" +}; + +/** + * Current period (YYYYMM) for the end-of-month tick-box. + * + * MVP: server time / browser time. We use `Date` directly so the value + * matches the user's wall clock — at 09:53 on 20 Oct 2026 in SAST the + * period reads "202610". When the user wants per-period history they'll + * add a period picker; this stays as the default. + */ +function currentPeriod(now: Date = new Date()): string { + const y = now.getFullYear(); + const m = String(now.getMonth() + 1).padStart(2, "0"); + return `${y}${m}`; +} + +/** "202610" → "October 2026" — used in the period banner. */ +function formatPeriodLabel(period: string): string { + if (!/^[0-9]{6}$/.test(period)) return period; + const y = period.slice(0, 4); + const m = Number(period.slice(4, 6)); + const names = [ + "January", "February", "March", "April", "May", "June", + "July", "August", "September", "October", "November", "December" + ]; + return `${names[m - 1] ?? "?"} ${y}`; +} + /** * Preferred rendering order for grouped cadence sections. Labels not * present in a company's data are skipped; labels present on data but @@ -228,9 +278,11 @@ function getStoredType(): RegReturnType | null { } export default function SarsRegistrationsExplorer({ initial, initialType }: Props) { + const queryClient = useQueryClient(); const [type, setType] = useState(initialType); const [query, setQuery] = useState(""); const [appliedQuery, setAppliedQuery] = useState(""); + const period = currentPeriod(); // Restore last-used return type from localStorage so reloads don't // kick the user back to EMP201. Initial render uses the SSR value @@ -296,6 +348,95 @@ export default function SarsRegistrationsExplorer({ initial, initialType }: Prop placeholderData: (prev) => prev }); + const flagType = REG_TO_FLAG_TYPE[type]; + const flagsKey = ["sars-submission-flags", flagType, period] as const; + const { data: flagsData } = useQuery>({ + queryKey: flagsKey, + queryFn: () => fetchSarsSubmissionFlags(flagType, period), + staleTime: 0, + refetchOnMount: "always" + }); + + // companyId -> flag. Built once per query update so row render is O(1). + const flagByCompany = useMemo(() => { + const map = new Map(); + for (const f of flagsData?.items ?? []) { + const cId = typeof f.company === "string" ? f.company : ""; + if (cId) map.set(cId, f); + } + return map; + }, [flagsData]); + + const tickMutation = useMutation({ + mutationFn: (params: { companyId: string; submitted: boolean }) => + upsertSarsSubmissionFlag({ + company: params.companyId, + return_type: flagType, + period, + submitted: params.submitted + }), + onMutate: async (params) => { + // Optimistic update so the tick feels instant. Roll back if the + // server disagrees. + await queryClient.cancelQueries({ queryKey: flagsKey }); + const previous = queryClient.getQueryData>(flagsKey); + const optimistic: YsterbaardSarsSubmissionFlag = { + id: `optimistic-${params.companyId}`, + collectionId: "sars_submission_flags", + collectionName: "sars_submission_flags", + company: params.companyId, + return_type: flagType, + period, + submitted: params.submitted, + submitted_at: params.submitted ? new Date().toISOString() : null, + created: new Date().toISOString(), + updated: new Date().toISOString() + }; + const next: PBList = { + page: previous?.page ?? 1, + perPage: previous?.perPage ?? 500, + totalItems: previous?.totalItems ?? 0, + totalPages: previous?.totalPages ?? 1, + items: [ + ...(previous?.items ?? []).filter( + (f) => (typeof f.company === "string" ? f.company : "") !== params.companyId + ), + optimistic + ] + }; + queryClient.setQueryData(flagsKey, next); + return { previous }; + }, + onError: (_err, _vars, ctx) => { + if (ctx?.previous) queryClient.setQueryData(flagsKey, ctx.previous); + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: flagsKey }); + } + }); + + const clearMutation = useMutation({ + mutationFn: () => clearSarsSubmissionFlags(flagType, period), + onMutate: async () => { + await queryClient.cancelQueries({ queryKey: flagsKey }); + const previous = queryClient.getQueryData>(flagsKey); + queryClient.setQueryData>(flagsKey, { + page: 1, + perPage: 500, + totalItems: 0, + totalPages: 1, + items: [] + }); + return { previous }; + }, + onError: (_err, _vars, ctx) => { + if (ctx?.previous) queryClient.setQueryData(flagsKey, ctx.previous); + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: flagsKey }); + } + }); + const list = data ?? initial; const rows = list.items; const total = list.totalItems; @@ -304,6 +445,26 @@ export default function SarsRegistrationsExplorer({ initial, initialType }: Prop [rows, currentOption] ); + /** + * Counter denominator/numerator are scoped to the rows actually on + * screen. A flag can exist for a company that no longer matches the + * active return-type filter (e.g. LEATHER IMPORTERS has an EMP201 tick + * but no sars_paye_no, so it is not in the EMP201 list). Counting + * those would make the banner read "4/18" while only 3 boxes are + * ticked — a lie the user would have to debug every month. + * + * `visibleRows` follows the same rows the tables render, so search + * filtering narrows the count too — which is what you want when you + * tick through a filtered list. + */ + const submittedVisible = useMemo(() => { + let done = 0; + for (const c of rows) { + if (flagByCompany.get(c.id)?.submitted === true) done += 1; + } + return done; + }, [rows, flagByCompany]); + return (
{/* Return-type pills */} @@ -357,6 +518,48 @@ export default function SarsRegistrationsExplorer({ initial, initialType }: Prop
+ {/* Period banner (end-of-month tick-box scope) */} +
+
+

+ Tick-box period +

+ + {formatPeriodLabel(period)} + + current + + +

+ {submittedVisible}/{total} submitted this period +

+
+ +
+ {/* Result count + status */}

@@ -380,6 +583,9 @@ export default function SarsRegistrationsExplorer({ initial, initialType }: Prop {currentOption.numberHeader} + + Submitted + @@ -391,6 +597,9 @@ export default function SarsRegistrationsExplorer({ initial, initialType }: Prop + + + ))} @@ -435,15 +644,26 @@ export default function SarsRegistrationsExplorer({ initial, initialType }: Prop {currentOption.numberHeader} + + Submitted + {section.items.map((c) => { const number = c[currentOption.numberField]; + const flag = flagByCompany.get(c.id); + const isSubmitted = flag?.submitted === true; return ( missing )} + + + ); })} @@ -518,20 +770,59 @@ export default function SarsRegistrationsExplorer({ initial, initialType }: Prop

    {section.items.map((c) => { const number = c[currentOption.numberField]; + const flag = flagByCompany.get(c.id); + const isSubmitted = flag?.submitted === true; return (
  • - - {c.enterprise_name || "(unnamed)"} - - {c.enterprise_status ? ( -

    {c.enterprise_status}

    - ) : null} +
    +
    + + {c.enterprise_name || "(unnamed)"} + + {c.enterprise_status ? ( +

    {c.enterprise_status}

    + ) : null} +
    + +

    {currentOption.numberHeader}

    diff --git a/Workflow/lib/api.ts b/Workflow/lib/api.ts index cda3e83..4dc84f0 100644 --- a/Workflow/lib/api.ts +++ b/Workflow/lib/api.ts @@ -3,7 +3,12 @@ // This module is "use client" friendly — it never imports PocketBase // directly and never touches process.env.PB_TOKEN. -import type { PBList, ListParams } from "./types"; +import type { + PBList, + ListParams, + SarsSubmissionReturnType, + YsterbaardSarsSubmissionFlag +} from "./types"; export interface ClientListParams extends ListParams { collection: string; @@ -241,3 +246,61 @@ export async function deleteCompanyDocument(id: string): Promise { const res = await fetch(`/api/documents/company/${id}`, { method: "DELETE" }); if (!res.ok) throw new Error(`Delete failed (${res.status})`); } + +// ───────────────────────────────────────────────────────────────────────── +// SARS submission tick-boxes +// ───────────────────────────────────────────────────────────────────────── + +export async function fetchSarsSubmissionFlags( + returnType: SarsSubmissionReturnType, + period: string +): Promise> { + const qs = new URLSearchParams({ return_type: returnType, period }); + const res = await fetch(`/api/sars-submission-flags?${qs.toString()}`, { + cache: "no-store" + }); + if (!res.ok) { + const text = await res.text(); + throw new Error( + `fetchSarsSubmissionFlags failed (${res.status}): ${text || res.statusText}` + ); + } + return res.json(); +} + +export async function upsertSarsSubmissionFlag(params: { + company: string; + return_type: SarsSubmissionReturnType; + period: string; + submitted: boolean; +}): Promise { + const res = await fetch("/api/sars-submission-flags", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(params) + }); + if (!res.ok) { + const text = await res.text(); + throw new Error( + `upsertSarsSubmissionFlag failed (${res.status}): ${text || res.statusText}` + ); + } + return res.json(); +} + +export async function clearSarsSubmissionFlags( + returnType: SarsSubmissionReturnType, + period: string +): Promise<{ ok: true; deleted: number }> { + const qs = new URLSearchParams({ return_type: returnType, period }); + const res = await fetch(`/api/sars-submission-flags?${qs.toString()}`, { + method: "DELETE" + }); + if (!res.ok) { + const text = await res.text(); + throw new Error( + `clearSarsSubmissionFlags failed (${res.status}): ${text || res.statusText}` + ); + } + return res.json(); +} diff --git a/Workflow/lib/types.ts b/Workflow/lib/types.ts index b884047..46506a0 100644 --- a/Workflow/lib/types.ts +++ b/Workflow/lib/types.ts @@ -655,3 +655,48 @@ export interface YsterbaardNote { created: string; updated: string; } + +// ───────────────────────────────────────────────────────────────────────── +// SARS registrations — end-of-month tick-box per (company, return_type, period). +// +// MVP: the interim "have I submitted this period's return for this client?" +// tick on the SARS registrations tab. One row per (company, return_type, +// period) tuple. Period format is YYYYMM for every type for v1 — the user +// said "we'll redo this properly later" once they know how they actually +// want to slice annual/quarterly returns. The `submitted_at` timestamp +// captures the wall-clock time the tick was set so a row carries a real +// audit trail even without a structured period picker. +// ───────────────────────────────────────────────────────────────────────── + +export type SarsSubmissionReturnType = + | "EMP201" + | "VAT201" + | "IncomeTax" + | "ProvTax" + | "AnnualReturns"; + +export const SARS_SUBMISSION_RETURN_TYPES: SarsSubmissionReturnType[] = [ + "EMP201", + "VAT201", + "IncomeTax", + "ProvTax", + "AnnualReturns" +]; + +export interface YsterbaardSarsSubmissionFlag { + [key: string]: unknown; + id: string; + collectionId: string; + collectionName: string; + /** Relation -> ysterbaard_companies. */ + company: string; + /** Same enum as RegReturnType (kept separate to avoid coupling). */ + return_type: SarsSubmissionReturnType; + /** YYYYMM, e.g. "202610" for October 2026. */ + period: string; + submitted: boolean; + /** ISO timestamp set when submitted=true; cleared when false. */ + submitted_at: string | null; + created: string; + updated: string; +}