diff --git a/App/CLAUDE.md b/App/CLAUDE.md index ef0a9e7..4213e2a 100644 --- a/App/CLAUDE.md +++ b/App/CLAUDE.md @@ -104,6 +104,10 @@ A PO's **cost centre is a Vessel** (the `Vessel` model). `PurchaseOrder.vesselId The three PO forms (`new-po-form`, `edit-po-form`, `manager-edit-po-form`) render a shared `` — a native `` + ``) — type a one-off clause or pick a catalogued one — suggesting the active clauses of that category (`lib/terms-data.ts` `getActiveTermsByCategory`). **"Others" stays free text**, and the fixed boilerplate lines (`TC_FIXED_LINE` / `TC_FIXED_LINE_2`) are not catalogued. The `tc*` columns stay **free-text snapshots** (export/import unchanged); since the slot is a free-text combobox, any current/custom value is preserved as-is. No "work order" type — POs only (per the issue's steer). + ### PO Numbering (`lib/po-number.ts`) Structured format: **`COMPANY/VESSEL/PO_ID/FY`** — e.g. `PMS/HNR1/9000/2024-25`. The financial year is Indian (Apr–Mar) rendered `YYYY-YY`. System-generated `PO_ID` starts at **9000** to avoid clashing with historical numbers. **Imported POs keep their original PO number** verbatim; `parsePoNumber()` extracts the company/vessel/id parts on import. diff --git a/App/app/(portal)/admin/terms/actions.ts b/App/app/(portal)/admin/terms/actions.ts new file mode 100644 index 0000000..bf406ad --- /dev/null +++ b/App/app/(portal)/admin/terms/actions.ts @@ -0,0 +1,68 @@ +"use server"; + +import { auth } from "@/auth"; +import { db } from "@/lib/db"; +import { hasPermission } from "@/lib/permissions"; +import { revalidatePath } from "next/cache"; +import { z } from "zod"; +import { TermsCategory } from "@prisma/client"; + +const schema = z.object({ + category: z.nativeEnum(TermsCategory), + text: z.string().trim().min(1, "Clause text is required"), +}); + +type Result = { ok: true } | { error: string }; + +async function guard(): Promise<{ ok: true } | { error: string }> { + const session = await auth(); + if (!session?.user || !hasPermission(session.user.role, "manage_terms")) { + return { error: "Forbidden" }; + } + return { ok: true }; +} + +export async function createTerm(formData: FormData): Promise { + const g = await guard(); + if ("error" in g) return g; + + const parsed = schema.safeParse(Object.fromEntries(formData)); + if (!parsed.success) return { error: parsed.error.errors[0].message }; + + await db.termsCondition.create({ data: { category: parsed.data.category, text: parsed.data.text } }); + revalidatePath("/admin/terms"); + return { ok: true }; +} + +export async function updateTerm(id: string, formData: FormData): Promise { + const g = await guard(); + if ("error" in g) return g; + + const parsed = schema.safeParse(Object.fromEntries(formData)); + if (!parsed.success) return { error: parsed.error.errors[0].message }; + + await db.termsCondition.update({ where: { id }, data: { category: parsed.data.category, text: parsed.data.text } }); + revalidatePath("/admin/terms"); + return { ok: true }; +} + +export async function toggleTermActive(id: string): Promise { + const g = await guard(); + if ("error" in g) return g; + + const term = await db.termsCondition.findUnique({ where: { id }, select: { isActive: true } }); + if (!term) return { error: "Not found" }; + await db.termsCondition.update({ where: { id }, data: { isActive: !term.isActive } }); + revalidatePath("/admin/terms"); + return { ok: true }; +} + +export async function deleteTerm(id: string): Promise { + const g = await guard(); + if ("error" in g) return g; + + // Safe to delete: POs keep their T&C as text snapshots, so no PO references this row. + await db.termsCondition.delete({ where: { id } }); + revalidatePath("/admin/terms"); + return { ok: true }; +} diff --git a/App/app/(portal)/admin/terms/page.tsx b/App/app/(portal)/admin/terms/page.tsx new file mode 100644 index 0000000..5c73169 --- /dev/null +++ b/App/app/(portal)/admin/terms/page.tsx @@ -0,0 +1,29 @@ +import { auth } from "@/auth"; +import { db } from "@/lib/db"; +import { hasPermission } from "@/lib/permissions"; +import { redirect } from "next/navigation"; +import { TermsTable } from "./terms-table"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { title: "Terms & Conditions" }; + +export default async function TermsPage() { + const session = await auth(); + if (!session?.user) redirect("/login"); + if (!hasPermission(session.user.role, "manage_terms")) redirect("/dashboard"); + + const terms = await db.termsCondition.findMany({ + orderBy: [{ category: "asc" }, { isActive: "desc" }, { createdAt: "asc" }], + }); + + return ( + ({ + id: t.id, + category: t.category, + text: t.text, + isActive: t.isActive, + }))} + /> + ); +} diff --git a/App/app/(portal)/admin/terms/terms-form.tsx b/App/app/(portal)/admin/terms/terms-form.tsx new file mode 100644 index 0000000..aa73802 --- /dev/null +++ b/App/app/(portal)/admin/terms/terms-form.tsx @@ -0,0 +1,108 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import type { TermsCategory } from "@prisma/client"; +import { AdminDialog } from "@/components/ui/admin-dialog"; +import { TERMS_CATEGORIES, TERMS_CATEGORY_LABEL } from "@/lib/terms"; +import { createTerm, updateTerm } from "./actions"; + +const INPUT = "w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20"; + +export type TermRow = { + id: string; + category: TermsCategory; + text: string; + isActive: boolean; +}; + +function Fields({ term }: { term?: TermRow }) { + return ( +
+
+ + +
+
+ +