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.
306 lines
10 KiB
TypeScript
306 lines
10 KiB
TypeScript
// lib/api.ts
|
|
// Browser-safe thin client that calls our own /api/pb proxy.
|
|
// This module is "use client" friendly — it never imports PocketBase
|
|
// directly and never touches process.env.PB_TOKEN.
|
|
|
|
import type {
|
|
PBList,
|
|
ListParams,
|
|
SarsSubmissionReturnType,
|
|
YsterbaardSarsSubmissionFlag
|
|
} from "./types";
|
|
|
|
export interface ClientListParams extends ListParams {
|
|
collection: string;
|
|
}
|
|
|
|
export interface ClientGetParams {
|
|
collection: string;
|
|
id: string;
|
|
expand?: string;
|
|
}
|
|
|
|
/** Type-safe client fetcher for paginated lists. */
|
|
export async function pbList<T>(params: ClientListParams): Promise<PBList<T>> {
|
|
const qs = new URLSearchParams();
|
|
qs.set("collection", params.collection);
|
|
if (params.page) qs.set("page", String(params.page));
|
|
if (params.perPage) qs.set("perPage", String(params.perPage));
|
|
if (params.sort) qs.set("sort", params.sort);
|
|
if (params.filter) qs.set("filter", params.filter);
|
|
if (params.expand) qs.set("expand", params.expand);
|
|
if (params.fields) qs.set("fields", params.fields);
|
|
|
|
const res = await fetch(`/api/pb/records/list?${qs.toString()}`, {
|
|
cache: "no-store"
|
|
});
|
|
if (!res.ok) {
|
|
const text = await res.text();
|
|
throw new Error(`PB list failed (${res.status}): ${text || res.statusText}`);
|
|
}
|
|
return res.json();
|
|
}
|
|
|
|
export async function pbGet<T>(params: ClientGetParams): Promise<T> {
|
|
const qs = new URLSearchParams();
|
|
qs.set("collection", params.collection);
|
|
if (params.expand) qs.set("expand", params.expand);
|
|
|
|
const res = await fetch(`/api/pb/records/${params.id}?${qs.toString()}`, {
|
|
cache: "no-store"
|
|
});
|
|
if (!res.ok) {
|
|
const text = await res.text();
|
|
throw new Error(`PB get failed (${res.status}): ${text || res.statusText}`);
|
|
}
|
|
return res.json();
|
|
}
|
|
|
|
/**
|
|
* Single-purpose toggle for `is_client` on a director or company row.
|
|
* Server enforces a hard allowlist; this client only sends
|
|
* { collection, id, is_client }, nothing else.
|
|
*
|
|
* Used by the directors/companies table rows and the detail-page toggle.
|
|
*/
|
|
export async function pbToggleClient(params: {
|
|
collection: "ysterbaard_directors" | "ysterbaard_companies";
|
|
id: string;
|
|
is_client: boolean;
|
|
}): Promise<{ ok: true; id: string; is_client: boolean; updated: string }> {
|
|
const res = await fetch("/api/pb/records/toggle-client", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
collection: params.collection,
|
|
id: params.id,
|
|
is_client: params.is_client
|
|
})
|
|
});
|
|
if (!res.ok) {
|
|
const text = await res.text();
|
|
throw new Error(`PB toggle failed (${res.status}): ${text || res.statusText}`);
|
|
}
|
|
return res.json();
|
|
}
|
|
|
|
/**
|
|
* Creates a new record on any collection.
|
|
* Only allowlisted fields pass through.
|
|
*/
|
|
export async function pbCreate(params: {
|
|
collection: string;
|
|
fields: Record<string, string | number | string[]>;
|
|
}): Promise<unknown> {
|
|
const res = await fetch("/api/pb/records/create", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(params),
|
|
});
|
|
if (!res.ok) {
|
|
const text = await res.text();
|
|
throw new Error(`PB create failed (${res.status}): ${text || res.statusText}`);
|
|
}
|
|
return res.json();
|
|
}
|
|
|
|
/**
|
|
* Creates a new director or company record with is_client=true.
|
|
* Only the allowlisted fields pass through — no arbitrary PB writes.
|
|
*
|
|
* Used by the Add Director / Add Company modal forms.
|
|
*/
|
|
export async function pbCreateClient(params: {
|
|
collection: "ysterbaard_directors" | "ysterbaard_companies";
|
|
fields: Record<string, string>;
|
|
}): Promise<unknown> {
|
|
const res = await fetch("/api/pb/records/create", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(params),
|
|
});
|
|
if (!res.ok) {
|
|
const text = await res.text();
|
|
throw new Error(`PB create failed (${res.status}): ${text || res.statusText}`);
|
|
}
|
|
return res.json();
|
|
}
|
|
|
|
/**
|
|
* Updates editable fields on an existing director or company record.
|
|
* Only allowlisted fields pass through. Use for the in-app edit form.
|
|
*/
|
|
export async function pbPatch(params: {
|
|
collection: string;
|
|
id: string;
|
|
fields: Record<string, string | number | boolean | string[]>;
|
|
}): Promise<unknown> {
|
|
const res = await fetch(`/api/pb/records/${params.id}?collection=${params.collection}`, {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(params.fields),
|
|
});
|
|
if (!res.ok) {
|
|
const text = await res.text();
|
|
throw new Error(`PB patch failed (${res.status}): ${text || res.statusText}`);
|
|
}
|
|
return res.json();
|
|
}
|
|
|
|
/**
|
|
* Deletes a director or company record permanently.
|
|
*/
|
|
export async function pbDelete(params: {
|
|
collection: string;
|
|
id: string;
|
|
}): Promise<void> {
|
|
const res = await fetch(`/api/pb/records/${params.id}?collection=${params.collection}`, {
|
|
method: "DELETE",
|
|
});
|
|
if (!res.ok) {
|
|
const text = await res.text();
|
|
throw new Error(`PB delete failed (${res.status}): ${text || res.statusText}`);
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// Document management helpers
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
export interface DocType {
|
|
id: string;
|
|
name: string;
|
|
context: "director" | "company";
|
|
description: string | null;
|
|
}
|
|
|
|
export async function fetchDocumentTypes(context?: "director" | "company"): Promise<PBList<DocType>> {
|
|
const qs = context ? `?context=${context}` : "";
|
|
const res = await fetch(`/api/documents/types${qs}`, { cache: "no-store" });
|
|
if (!res.ok) throw new Error(`Failed to load document types (${res.status})`);
|
|
return res.json();
|
|
}
|
|
|
|
export async function createDocumentType(data: {
|
|
name: string;
|
|
context: "director" | "company";
|
|
description?: string;
|
|
}): Promise<DocType> {
|
|
const res = await fetch("/api/documents/types", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(data),
|
|
});
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({}));
|
|
throw new Error((err as { error?: string }).error ?? "Failed to create document type");
|
|
}
|
|
return res.json();
|
|
}
|
|
|
|
export async function updateDocumentType(id: string, data: { name?: string; description?: string }): Promise<DocType> {
|
|
const res = await fetch(`/api/documents/types/${id}`, {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(data),
|
|
});
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({}));
|
|
throw new Error((err as { error?: string }).error ?? "Failed to update document type");
|
|
}
|
|
return res.json();
|
|
}
|
|
|
|
export async function deleteDocumentType(id: string): Promise<void> {
|
|
const res = await fetch(`/api/documents/types/${id}`, { method: "DELETE" });
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({}));
|
|
throw new Error((err as { error?: string }).error ?? "Failed to delete document type");
|
|
}
|
|
}
|
|
|
|
export async function uploadDirectorDocument(formData: FormData): Promise<unknown> {
|
|
const res = await fetch("/api/documents/director/upload", { method: "POST", body: formData });
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({}));
|
|
throw new Error((err as { error?: string }).error ?? "Upload failed");
|
|
}
|
|
return res.json();
|
|
}
|
|
|
|
export async function uploadCompanyDocument(formData: FormData): Promise<unknown> {
|
|
const res = await fetch("/api/documents/company/upload", { method: "POST", body: formData });
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({}));
|
|
throw new Error((err as { error?: string }).error ?? "Upload failed");
|
|
}
|
|
return res.json();
|
|
}
|
|
|
|
export async function deleteDirectorDocument(id: string): Promise<void> {
|
|
const res = await fetch(`/api/documents/director/${id}`, { method: "DELETE" });
|
|
if (!res.ok) throw new Error(`Delete failed (${res.status})`);
|
|
}
|
|
|
|
export async function deleteCompanyDocument(id: string): Promise<void> {
|
|
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<PBList<YsterbaardSarsSubmissionFlag>> {
|
|
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<YsterbaardSarsSubmissionFlag> {
|
|
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();
|
|
}
|