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; +}