diff --git a/App/.env.example b/App/.env.example index 02150f6..e4a3b60 100644 --- a/App/.env.example +++ b/App/.env.example @@ -72,6 +72,13 @@ FORGEJO_URL=https://git.pelagiamarine.com FORGEJO_REPO=shad0w/pelagia-portal FORGEJO_TOKEN= +# ── Feature flags (NEXT_PUBLIC_, available to client + server) ─ +# Inventory tracking (site stock / consumption). On unless explicitly "false". +# NEXT_PUBLIC_INVENTORY_ENABLED=false +# Let submitters (TECHNICAL/MANNING) read & export every PO and open the History +# page (read-only). Opt-in — on only when exactly "true". +# NEXT_PUBLIC_SUBMITTER_VIEW_ALL_ENABLED=true + # ── Non-production banner ───────────────────────────────────── # When set, a fixed "internal dev / staging" banner is shown (EnvBanner). # Leave UNSET in production. Staging sets this automatically. diff --git a/App/CLAUDE.md b/App/CLAUDE.md index b6c7c72..ef0a9e7 100644 --- a/App/CLAUDE.md +++ b/App/CLAUDE.md @@ -98,6 +98,12 @@ A PO's **cost centre is a Vessel** (the `Vessel` model). `PurchaseOrder.vesselId `Company` represents the sister company a PO is billed under (`PurchaseOrder.companyId`, optional). Fields: `name`, `code` (unique short code, e.g. `PMS`), `gstNumber`, `address`, `telephone`, `mobile`, `email`, `invoiceEmail`, `invoiceAddress`. Managed at `/admin/companies`. The selected company's details populate the **exported PO header / invoice block** (falling back to hardcoded Pelagia defaults when no company is linked). +### Delivery Locations (issue #19) + +`DeliveryLocation` (a `Company` FK + free-text `address` + `isActive`) is an admin-managed list that backs the PO **Place of Delivery** dropdown. Managed at `/admin/delivery-locations`, gated by the **`manage_delivery_locations`** permission (Manager + SuperUser + Admin — explicitly **not** admin-only, per the issue). The CRUD mirrors `/admin/sites` (table + Add/Edit dialogs + activate/deactivate + delete). + +The three PO forms (`new-po-form`, `edit-po-form`, `manager-edit-po-form`) render a shared `` — a native ` setF({ ...f, name: e.target.value })} required /> - + setF({ ...f, email: e.target.value })} /> diff --git a/App/app/(portal)/admin/delivery-locations/actions.ts b/App/app/(portal)/admin/delivery-locations/actions.ts new file mode 100644 index 0000000..83fd19f --- /dev/null +++ b/App/app/(portal)/admin/delivery-locations/actions.ts @@ -0,0 +1,77 @@ +"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"; + +const schema = z.object({ + companyId: z.string().min(1, "Company is required"), + address: z.string().trim().min(1, "Delivery address 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_delivery_locations")) { + return { error: "Forbidden" }; + } + return { ok: true }; +} + +export async function createDeliveryLocation(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 }; + + // Guard against a dangling FK if the company was removed concurrently. + const company = await db.company.findUnique({ where: { id: parsed.data.companyId }, select: { id: true } }); + if (!company) return { error: "Selected company no longer exists." }; + + await db.deliveryLocation.create({ + data: { companyId: parsed.data.companyId, address: parsed.data.address }, + }); + revalidatePath("/admin/delivery-locations"); + return { ok: true }; +} + +export async function updateDeliveryLocation(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.deliveryLocation.update({ + where: { id }, + data: { companyId: parsed.data.companyId, address: parsed.data.address }, + }); + revalidatePath("/admin/delivery-locations"); + return { ok: true }; +} + +export async function toggleDeliveryLocationActive(id: string): Promise { + const g = await guard(); + if ("error" in g) return g; + + const loc = await db.deliveryLocation.findUnique({ where: { id }, select: { isActive: true } }); + if (!loc) return { error: "Not found" }; + await db.deliveryLocation.update({ where: { id }, data: { isActive: !loc.isActive } }); + revalidatePath("/admin/delivery-locations"); + return { ok: true }; +} + +export async function deleteDeliveryLocation(id: string): Promise { + const g = await guard(); + if ("error" in g) return g; + + // Safe to delete: POs keep their place-of-delivery as a text snapshot, so no + // purchase order references this row. + await db.deliveryLocation.delete({ where: { id } }); + revalidatePath("/admin/delivery-locations"); + return { ok: true }; +} diff --git a/App/app/(portal)/admin/delivery-locations/delivery-location-form.tsx b/App/app/(portal)/admin/delivery-locations/delivery-location-form.tsx new file mode 100644 index 0000000..d187e2c --- /dev/null +++ b/App/app/(portal)/admin/delivery-locations/delivery-location-form.tsx @@ -0,0 +1,110 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { AdminDialog } from "@/components/ui/admin-dialog"; +import { createDeliveryLocation, updateDeliveryLocation } 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 CompanyOption = { id: string; name: string }; +export type DeliveryLocationRow = { + id: string; + companyId: string; + companyName: string; + address: string; + isActive: boolean; +}; + +function Fields({ companies, location }: { companies: CompanyOption[]; location?: DeliveryLocationRow }) { + return ( +
+
+ + +
+
+ +