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.
203 lines
No EOL
7.1 KiB
TypeScript
203 lines
No EOL
7.1 KiB
TypeScript
// 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<YsterbaardSarsSubmissionFlag>(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<string, unknown>;
|
|
try {
|
|
body = (await req.json()) as Record<string, unknown>;
|
|
} 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<YsterbaardSarsSubmissionFlag>(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<YsterbaardSarsSubmissionFlag>(COLLECTION, row.id, payload);
|
|
return NextResponse.json(updated, { status: 200 });
|
|
}
|
|
|
|
const created = await createRecord<YsterbaardSarsSubmissionFlag>(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<YsterbaardSarsSubmissionFlag>(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 });
|
|
}
|
|
} |