From a99b2ed5df0464f44e8cc8e7cfc04a84f5ff0088 Mon Sep 17 00:00:00 2001 From: Hardik Date: Wed, 24 Jun 2026 03:38:32 +0530 Subject: [PATCH 1/2] feat(po): admin-managed Terms & Conditions catalogue + PO dropdowns (#11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the Place-of-Delivery (#19) pattern: an admin clause library that feeds the PO T&C fields as dropdowns. (No "work order" type — POs only, per steer.) - schema + migration: TermsCondition (category enum + text + isActive); the migration seeds the prior TC_DEFAULTS as the starting clauses. - permission manage_terms (Manager + SuperUser + Admin). - admin screen /admin/terms: table + Add/Edit dialogs + activate/deactivate + delete (mirrors /admin/delivery-locations); sidebar link under Administration. - PO forms (new / edit / manager-edit): the five named T&C slots (Delivery / Dispatch / Inspection / Transit Insurance / Payment Terms) become a shared select sourced from active clauses of that category; "Others" stays free text; the fixed boilerplate lines are untouched. - tc* columns stay free-text SNAPSHOTS (export/import unchanged); a current value not among active clauses is preserved as a "(current)" option. - tests: terms CRUD + permission guard + grouping helper (6). Co-Authored-By: Claude Opus 4.8 (1M context) --- App/CLAUDE.md | 4 + App/app/(portal)/admin/terms/actions.ts | 68 +++++++++ App/app/(portal)/admin/terms/page.tsx | 29 ++++ App/app/(portal)/admin/terms/terms-form.tsx | 108 +++++++++++++++ App/app/(portal)/admin/terms/terms-table.tsx | 131 ++++++++++++++++++ .../approvals/[id]/manager-edit-po-form.tsx | 12 +- App/app/(portal)/approvals/[id]/page.tsx | 3 + .../(portal)/po/[id]/edit/edit-po-form.tsx | 12 +- App/app/(portal)/po/[id]/edit/page.tsx | 3 + App/app/(portal)/po/new/new-po-form.tsx | 7 +- App/app/(portal)/po/new/page.tsx | 3 + App/components/layout/sidebar.tsx | 4 +- App/components/po/terms-field.tsx | 38 +++++ App/lib/permissions.ts | 4 + App/lib/terms-data.ts | 14 ++ App/lib/terms.ts | 36 +++++ .../migration.sql | 26 ++++ App/prisma/schema.prisma | 26 ++++ App/tests/integration/terms.test.ts | 93 +++++++++++++ 19 files changed, 610 insertions(+), 11 deletions(-) create mode 100644 App/app/(portal)/admin/terms/actions.ts create mode 100644 App/app/(portal)/admin/terms/page.tsx create mode 100644 App/app/(portal)/admin/terms/terms-form.tsx create mode 100644 App/app/(portal)/admin/terms/terms-table.tsx create mode 100644 App/components/po/terms-field.tsx create mode 100644 App/lib/terms-data.ts create mode 100644 App/lib/terms.ts create mode 100644 App/prisma/migrations/20260624140000_terms_conditions/migration.sql create mode 100644 App/tests/integration/terms.test.ts diff --git a/App/CLAUDE.md b/App/CLAUDE.md index ef0a9e7..d8bb7b5 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 `` populated from 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); a current value not among the active clauses is preserved as a "(current)" option. 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 ( +
+
+ + +
+
+ +