From bea798324cc620aa39ac5d680955ed200df446bf Mon Sep 17 00:00:00 2001 From: Hardik Date: Thu, 14 May 2026 11:50:11 +0530 Subject: [PATCH] feat(inventory): Sites, GST lookup, distance sorting, cart, inventory tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Schema: - Site model (name, code, address, lat/lng, isActive) - ItemInventory model (quantity per product per site, unique[productId,siteId]) - ItemConsumption model (daily usage per product per site) - Vendor: add latitude, longitude - Vessel: add siteId (home port) - PurchaseOrder: add siteId (delivery site) Permissions: add manage_sites to MANAGER and ADMIN Sidebar: Inventory section (Vendors, Items, Vessels, Sites, Cart) for MANAGER and ADMIN; old admin items reorganised Lib: - lib/geo.ts: Haversine distance + Nominatim pincode geocoding - lib/gst-lookup.ts: AbhiAPI GSTIN lookup (ABHIAPI_KEY env var) - lib/cart.ts: localStorage cart (add/remove/clear + cart-updated event) API: GET /api/gst?gstin= — validates GSTIN, fetches via AbhiAPI, geocodes pincode via Nominatim, returns name/address/lat/lng Vendor form: GSTIN "Look up" button auto-fills name, address, and lat/lng; lat/lng fields editable as override Sites: full CRUD at /admin/sites; detail page with inventory table, consumption recording form, BarChart (stock) + LineChart (30-day consumption), linked vessels, recent POs Vessels: detail page at /admin/vessels/[id] with "Create PO" button, PO history and spend summary; accessible to MANAGER Items detail: price comparison BarChart; site distance filter (dropdown → re-render sorted by Haversine distance); "Add to Cart" per vendor row; stock-by-site section Cart: /inventory/cart — localStorage CartView; qty edit, remove, clear; "Create PO →" encodes cart into /po/new?cart=... Receipt/delivery: confirmReceipt now upserts ItemInventory (increment qty) for each linked line item, using PO siteId or vessel home site as the delivery location Co-Authored-By: Claude Sonnet 4.6 --- .../.claude/scheduled_tasks.lock | 1 + App/pelagia-portal/CLAUDE.md | 89 +++++++ .../admin/products/[id]/item-price-chart.tsx | 21 ++ .../app/(portal)/admin/products/[id]/page.tsx | 218 +++++++++++------- .../admin/sites/[id]/consumption-form.tsx | 61 +++++ .../app/(portal)/admin/sites/[id]/page.tsx | 218 ++++++++++++++++++ .../(portal)/admin/sites/[id]/site-charts.tsx | 43 ++++ .../app/(portal)/admin/sites/actions.ts | 97 ++++++++ .../app/(portal)/admin/sites/page.tsx | 90 ++++++++ .../app/(portal)/admin/sites/site-form.tsx | 114 +++++++++ .../app/(portal)/admin/vendors/actions.ts | 14 +- .../(portal)/admin/vendors/vendor-form.tsx | 153 ++++++------ .../app/(portal)/admin/vessels/[id]/page.tsx | 117 ++++++++++ .../app/(portal)/inventory/cart/cart-view.tsx | 122 ++++++++++ .../app/(portal)/inventory/cart/page.tsx | 16 ++ .../app/(portal)/po/[id]/receipt/actions.ts | 31 ++- App/pelagia-portal/app/api/gst/route.ts | 27 +++ .../inventory/add-to-cart-button.tsx | 31 +++ .../components/layout/sidebar.tsx | 38 ++- App/pelagia-portal/lib/cart.ts | 44 ++++ App/pelagia-portal/lib/geo.ts | 30 +++ App/pelagia-portal/lib/gst-lookup.ts | 61 +++++ App/pelagia-portal/lib/permissions.ts | 5 +- .../migration.sql | 78 +++++++ App/pelagia-portal/prisma/schema.prisma | 70 +++++- 25 files changed, 1609 insertions(+), 180 deletions(-) create mode 100644 App/pelagia-portal/.claude/scheduled_tasks.lock create mode 100644 App/pelagia-portal/CLAUDE.md create mode 100644 App/pelagia-portal/app/(portal)/admin/products/[id]/item-price-chart.tsx create mode 100644 App/pelagia-portal/app/(portal)/admin/sites/[id]/consumption-form.tsx create mode 100644 App/pelagia-portal/app/(portal)/admin/sites/[id]/page.tsx create mode 100644 App/pelagia-portal/app/(portal)/admin/sites/[id]/site-charts.tsx create mode 100644 App/pelagia-portal/app/(portal)/admin/sites/actions.ts create mode 100644 App/pelagia-portal/app/(portal)/admin/sites/page.tsx create mode 100644 App/pelagia-portal/app/(portal)/admin/sites/site-form.tsx create mode 100644 App/pelagia-portal/app/(portal)/admin/vessels/[id]/page.tsx create mode 100644 App/pelagia-portal/app/(portal)/inventory/cart/cart-view.tsx create mode 100644 App/pelagia-portal/app/(portal)/inventory/cart/page.tsx create mode 100644 App/pelagia-portal/app/api/gst/route.ts create mode 100644 App/pelagia-portal/components/inventory/add-to-cart-button.tsx create mode 100644 App/pelagia-portal/lib/cart.ts create mode 100644 App/pelagia-portal/lib/geo.ts create mode 100644 App/pelagia-portal/lib/gst-lookup.ts create mode 100644 App/pelagia-portal/prisma/migrations/20260514060941_add_site_inventory_consumption/migration.sql diff --git a/App/pelagia-portal/.claude/scheduled_tasks.lock b/App/pelagia-portal/.claude/scheduled_tasks.lock new file mode 100644 index 0000000..6c25d93 --- /dev/null +++ b/App/pelagia-portal/.claude/scheduled_tasks.lock @@ -0,0 +1 @@ +{"sessionId":"09360cd7-ccc6-4301-988d-d01a37fd1072","pid":23908,"acquiredAt":1777480375077} \ No newline at end of file diff --git a/App/pelagia-portal/CLAUDE.md b/App/pelagia-portal/CLAUDE.md new file mode 100644 index 0000000..016fc89 --- /dev/null +++ b/App/pelagia-portal/CLAUDE.md @@ -0,0 +1,89 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Commands + +```bash +# Development +pnpm dev # Next.js + Turbopack at localhost:3000 +pnpm lint # ESLint +pnpm type-check # tsc --noEmit + +# Tests +pnpm test # Unit tests (Vitest, jsdom) +pnpm test:watch # Unit tests in watch mode +pnpm test:integration # Integration tests (Vitest, node + real DB) +pnpm test:e2e # E2E tests (Playwright, headless) +pnpm test:e2e:ui # E2E tests with interactive UI +pnpm test:all # All test suites + +# Run a single test file +pnpm test -- tests/unit/po-line-items-editor.test.tsx +pnpm test:integration -- tests/integration/create-po.test.ts + +# Database +pnpm db:migrate # Create + apply migration (dev) +pnpm db:migrate:deploy # Apply migrations (CI/prod) +pnpm db:seed # Populate sample data +pnpm db:studio # Prisma GUI at localhost:5555 +pnpm db:reset # Drop + recreate + seed (dev) +``` + +## Architecture + +### Overview + +Internal purchase order management system for a maritime company. Full-stack Next.js 15 App Router app with Prisma + PostgreSQL, NextAuth v5 credentials auth, and Tailwind CSS v4. + +**Key design decisions:** +- Server Components for all data-fetching pages; Client Components only where interactivity is needed +- Server Actions for all mutations (form submissions, approvals, etc.) +- Prisma `Decimal` fields **cannot** be passed directly to Client Components — convert with `Number()` in the Server Component before passing as props (see `po-detail.tsx` → `lineItemsForEditor` pattern) +- File storage toggles automatically: Cloudflare R2 in production, `.dev-uploads/` directory in development +- Email toggles automatically: Resend in production, console log in development + +### PO Lifecycle (State Machine) + +`lib/po-state-machine.ts` enforces all status transitions. The canonical flow: + +``` +DRAFT → SUBMITTED → MGR_REVIEW → MGR_APPROVED → SENT_FOR_PAYMENT → PAID_DELIVERED → CLOSED + ↓↑ + EDITS_REQUESTED / REJECTED / VENDOR_ID_PENDING +``` + +Every status change is validated against the state machine and recorded as a `POAction` row (audit trail). + +### Role-Based Permissions + +`lib/permissions.ts` defines `hasPermission(role, permission)` and `requirePermission(role, permission)`. Roles: `TECHNICAL`, `MANNING`, `ACCOUNTS`, `MANAGER`, `SUPERUSER`, `AUDITOR`, `ADMIN`. + +**Pattern:** Server Actions call `requirePermission()` at the top before any DB write. + +### Key Directories + +- `app/(portal)/` — All authenticated pages (portal layout with sidebar) +- `app/api/po/[id]/export/` — PDF and XLSX export endpoint +- `lib/validations/po.ts` — Zod schemas for PO forms; exports `TC_FIXED_LINE` and `TC_DEFAULTS` +- `lib/po-state-machine.ts` — All valid status transitions with required roles +- `lib/notifier.ts` — Email dispatch (Resend in prod, console in dev) +- `lib/storage.ts` — File upload/download (R2 in prod, local in dev) +- `components/po/` — PO-specific components (line items editor, status badge, etc.) +- `tests/integration/helpers.ts` — `makeSession()`, `makePoForm()`, `fd()` for integration test setup + +### GST Calculation + +`totalAmount = sum(quantity × unitPrice × (1 + gstRate))` for each line item. The `gstRate` is stored as a decimal on `POLineItem` (e.g., `0.18` = 18%). This applies in Server Actions when computing `totalPrice` per line and the PO `totalAmount`. + +### Environment Variables + +``` +NEXTAUTH_SECRET # Required always +NEXTAUTH_URL # Required always (e.g., http://localhost:3000) +DATABASE_URL # PostgreSQL connection string + +# Optional in dev (defaults to local storage + console email): +R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_BUCKET_NAME, R2_PUBLIC_URL +RESEND_API_KEY, EMAIL_FROM, EMAIL_FROM_NAME +``` diff --git a/App/pelagia-portal/app/(portal)/admin/products/[id]/item-price-chart.tsx b/App/pelagia-portal/app/(portal)/admin/products/[id]/item-price-chart.tsx new file mode 100644 index 0000000..1ae402c --- /dev/null +++ b/App/pelagia-portal/app/(portal)/admin/products/[id]/item-price-chart.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid } from "recharts"; +import { formatCurrency } from "@/lib/utils"; + +export function ItemPriceChart({ data }: { data: { vendor: string; price: number }[] }) { + return ( +
+

Price Comparison by Vendor

+ + + + + `₹${(v / 1000).toFixed(0)}k`} /> + [formatCurrency(v), "Price"]} /> + + + +
+ ); +} diff --git a/App/pelagia-portal/app/(portal)/admin/products/[id]/page.tsx b/App/pelagia-portal/app/(portal)/admin/products/[id]/page.tsx index 117643b..7b28ffd 100644 --- a/App/pelagia-portal/app/(portal)/admin/products/[id]/page.tsx +++ b/App/pelagia-portal/app/(portal)/admin/products/[id]/page.tsx @@ -4,11 +4,15 @@ import { hasPermission } from "@/lib/permissions"; import { notFound, redirect } from "next/navigation"; import Link from "next/link"; import { formatCurrency, formatDate } from "@/lib/utils"; +import { distanceKm, formatDistance } from "@/lib/geo"; import { ToggleProductButton } from "../product-form"; +import { AddToCartButton } from "@/components/inventory/add-to-cart-button"; +import { ItemPriceChart } from "./item-price-chart"; import type { Metadata } from "next"; interface Props { params: Promise<{ id: string }>; + searchParams: Promise<{ site?: string }>; } export async function generateMetadata({ params }: Props): Promise { @@ -17,148 +21,186 @@ export async function generateMetadata({ params }: Props): Promise { return { title: product?.name ?? "Item Detail" }; } -export default async function ProductDetailPage({ params }: Props) { +export default async function ProductDetailPage({ params, searchParams }: Props) { const session = await auth(); if (!session?.user) redirect("/login"); if (!hasPermission(session.user.role, "manage_products")) redirect("/dashboard"); const { id } = await params; + const { site: siteId } = await searchParams; - const product = await db.product.findUnique({ - where: { id }, - include: { - vendorPrices: { - include: { vendor: { select: { id: true, name: true, vendorId: true, isVerified: true, isActive: true } } }, - orderBy: { price: "asc" }, + const [product, sites] = await Promise.all([ + db.product.findUnique({ + where: { id }, + include: { + vendorPrices: { + include: { + vendor: { + select: { id: true, name: true, vendorId: true, isVerified: true, isActive: true, latitude: true, longitude: true }, + }, + }, + orderBy: { price: "asc" }, + }, + lastVendor: true, + inventory: { include: { site: { select: { id: true, name: true } } } }, }, - lastVendor: true, - }, - }); + }), + db.site.findMany({ where: { isActive: true, latitude: { not: null }, longitude: { not: null } }, select: { id: true, name: true, latitude: true, longitude: true } }), + ]); if (!product) notFound(); const canManage = session.user.role === "ADMIN"; + const selectedSite = siteId ? sites.find((s) => s.id === siteId) ?? null : null; - // Price stats const prices = product.vendorPrices.map((vp) => Number(vp.price)); const minPrice = prices.length > 0 ? Math.min(...prices) : null; const maxPrice = prices.length > 0 ? Math.max(...prices) : null; + // Enrich vendors with distance from selected site + type EnrichedVp = typeof product.vendorPrices[0] & { distanceKm: number | null }; + const enriched: EnrichedVp[] = product.vendorPrices.map((vp) => { + let dist: number | null = null; + if (selectedSite?.latitude && selectedSite.longitude && vp.vendor.latitude && vp.vendor.longitude) { + dist = distanceKm(selectedSite.latitude, selectedSite.longitude, vp.vendor.latitude, vp.vendor.longitude); + } + return { ...vp, distanceKm: dist }; + }); + + // Sort: if site selected, sort by distance first; otherwise by price + if (selectedSite) { + enriched.sort((a, b) => { + if (a.distanceKm !== null && b.distanceKm !== null) return a.distanceKm - b.distanceKm; + if (a.distanceKm !== null) return -1; + if (b.distanceKm !== null) return 1; + return Number(a.price) - Number(b.price); + }); + } + + const priceChartData = enriched.map((vp) => ({ + vendor: vp.vendor.name.length > 16 ? vp.vendor.name.slice(0, 14) + "…" : vp.vendor.name, + price: Number(vp.price), + })); + return ( -
- {/* Breadcrumb */} +
Items / {product.name}
- {/* Header */}
{product.code} - + {product.isActive ? "Active" : "Inactive"}

{product.name}

- {product.description && ( -

{product.description}

- )} + {product.description &&

{product.description}

}
- {canManage && ( - +
+ + {canManage && } +
+
+ + {/* Stats */} +
+
+

Vendors

+

{product.vendorPrices.length}

+
+
+

Lowest Price

+

{minPrice !== null ? formatCurrency(minPrice) : "—"}

+
+
+

Highest Price

+

{maxPrice !== null ? formatCurrency(maxPrice) : "—"}

+
+
+

Sites with stock

+

{product.inventory.length}

+
+
+ + {/* Price chart */} + {priceChartData.length > 1 && } + + {/* Site filter for distance */} +
+ Sort by distance from site: +
+ +
+ {selectedSite && ( + Clear )}
- {/* Price summary */} - {product.vendorPrices.length > 0 && ( -
-
-

Vendors

-

{product.vendorPrices.length}

-
-
-

Lowest Price

-

- {minPrice !== null ? formatCurrency(minPrice) : "—"} -

-
-
-

Highest Price

-

- {maxPrice !== null ? formatCurrency(maxPrice) : "—"} -

-
-
- )} - - {/* Vendors that carry this item */} + {/* Vendors table */}

Available From ({product.vendorPrices.length} vendor{product.vendorPrices.length !== 1 ? "s" : ""}) + {selectedSite && sorted by distance from {selectedSite.name}}

- {product.vendorPrices.length === 0 ? ( -

- No vendor pricing on record yet. Prices are recorded automatically when a PO containing this item is marked as paid. -

+ {enriched.length === 0 ? ( +

No vendor pricing on record yet. Updated automatically when a PO is marked as paid.

) : ( - - + {selectedSite && } + + - {product.vendorPrices.map((vp) => { + {enriched.map((vp, idx) => { const price = Number(vp.price); - const isCheapest = minPrice !== null && price === minPrice && product.vendorPrices.length > 1; + const isCheapest = minPrice !== null && price === minPrice && enriched.length > 1; + const isClosest = selectedSite && idx === 0 && vp.distanceKm !== null; return ( - + {selectedSite && ( + + )} + ); })} @@ -166,6 +208,22 @@ export default async function ProductDetailPage({ params }: Props) {
VendorVendor ID Verified PriceLast UpdatedDistanceUpdated
- - {vp.vendor.name} - - {!vp.vendor.isActive && ( - inactive - )} - - {vp.vendor.vendorId ?? Pending} + {vp.vendor.name} + {!vp.vendor.isActive && inactive} - + {vp.vendor.isVerified ? "Verified" : "Unverified"} - - {formatCurrency(price)} - - {isCheapest && ( - lowest - )} + {formatCurrency(price)} + {isCheapest && !selectedSite && lowest} + {vp.distanceKm !== null + ? {formatDistance(vp.distanceKm)}{isClosest ? " ★" : ""} + : No location} + {formatDate(vp.updatedAt)} + +
)}
+ + {/* Inventory by site */} + {product.inventory.length > 0 && ( +
+

Stock by Site

+
+ {product.inventory.map((inv) => ( + + {inv.site.name} + {Number(inv.quantity)} units + + ))} +
+
+ )}
); } diff --git a/App/pelagia-portal/app/(portal)/admin/sites/[id]/consumption-form.tsx b/App/pelagia-portal/app/(portal)/admin/sites/[id]/consumption-form.tsx new file mode 100644 index 0000000..18668bf --- /dev/null +++ b/App/pelagia-portal/app/(portal)/admin/sites/[id]/consumption-form.tsx @@ -0,0 +1,61 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { recordConsumption } from "../actions"; + +const INPUT = "rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none"; + +interface Props { + siteId: string; + products: { id: string; name: string; code: string }[]; +} + +export function ConsumptionForm({ siteId, products }: Props) { + const router = useRouter(); + const [pending, setPending] = useState(false); + const [success, setSuccess] = useState(false); + const [error, setError] = useState(""); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setPending(true); setSuccess(false); setError(""); + const fd = new FormData(e.currentTarget); + fd.set("siteId", siteId); + const result = await recordConsumption(fd); + if ("error" in result) { setError(result.error); } + else { setSuccess(true); (e.target as HTMLFormElement).reset(); router.refresh(); } + setPending(false); + } + + return ( +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + {success &&

Recorded.

} + {error &&

{error}

} +
+ ); +} diff --git a/App/pelagia-portal/app/(portal)/admin/sites/[id]/page.tsx b/App/pelagia-portal/app/(portal)/admin/sites/[id]/page.tsx new file mode 100644 index 0000000..1023d1e --- /dev/null +++ b/App/pelagia-portal/app/(portal)/admin/sites/[id]/page.tsx @@ -0,0 +1,218 @@ +import { auth } from "@/auth"; +import { db } from "@/lib/db"; +import { hasPermission } from "@/lib/permissions"; +import { notFound, redirect } from "next/navigation"; +import Link from "next/link"; +import { formatCurrency, formatDate } from "@/lib/utils"; +import { EditSiteButton } from "../site-form"; +import { SiteCharts } from "./site-charts"; +import { ConsumptionForm } from "./consumption-form"; +import type { Metadata } from "next"; + +interface Props { params: Promise<{ id: string }> } + +export async function generateMetadata({ params }: Props): Promise { + const { id } = await params; + const site = await db.site.findUnique({ where: { id }, select: { name: true } }); + return { title: site?.name ?? "Site Detail" }; +} + +export default async function SiteDetailPage({ params }: Props) { + const session = await auth(); + if (!session?.user) redirect("/login"); + if (!hasPermission(session.user.role, "manage_sites")) redirect("/dashboard"); + + const { id } = await params; + + const thirtyDaysAgo = new Date(); + thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); + + const [site, products] = await Promise.all([ + db.site.findUnique({ + where: { id }, + include: { + vessels: { select: { id: true, name: true, imoNumber: true, isActive: true } }, + inventory: { + include: { product: { select: { id: true, name: true, code: true } } }, + orderBy: { quantity: "desc" }, + }, + consumption: { + where: { date: { gte: thirtyDaysAgo } }, + include: { product: { select: { name: true } } }, + orderBy: { date: "asc" }, + }, + purchaseOrders: { + select: { id: true, poNumber: true, status: true, totalAmount: true, createdAt: true, vendor: { select: { name: true } } }, + orderBy: { createdAt: "desc" }, + take: 8, + }, + }, + }), + db.product.findMany({ where: { isActive: true }, select: { id: true, name: true, code: true }, orderBy: { name: "asc" } }), + ]); + + if (!site) notFound(); + + const canEdit = session.user.role === "ADMIN"; + + // Build chart data: inventory bar + const inventoryChartData = site.inventory.map((inv) => ({ + name: inv.product.name.length > 20 ? inv.product.name.substring(0, 18) + "…" : inv.product.name, + quantity: Number(inv.quantity), + })); + + // Build consumption chart: group by date, sum quantities + const consumptionByDate = new Map(); + for (const c of site.consumption) { + const key = formatDate(c.date); + consumptionByDate.set(key, (consumptionByDate.get(key) ?? 0) + Number(c.quantity)); + } + const consumptionChartData = Array.from(consumptionByDate.entries()).map(([date, qty]) => ({ date, qty })); + + const STATUS_LABELS: Record = { + DRAFT: "Draft", MGR_REVIEW: "Under Review", MGR_APPROVED: "Approved", + SENT_FOR_PAYMENT: "Sent for Payment", PAID_DELIVERED: "Paid", CLOSED: "Closed", + SUBMITTED: "Submitted", REJECTED: "Rejected", + }; + + return ( +
+ {/* Breadcrumb */} +
+ Sites + / + {site.name} +
+ + {/* Header */} +
+
+
+ {site.code} + + {site.isActive ? "Active" : "Inactive"} + +
+

{site.name}

+ {site.address &&

{site.address}

} + {site.latitude && site.longitude && ( +

{site.latitude.toFixed(5)}, {site.longitude.toFixed(5)}

+ )} +
+
+ + + Create PO + + {canEdit && } +
+
+ + {/* Summary cards */} +
+
+

Assigned Vessels

+

{site.vessels.length}

+
+
+

Items Tracked

+

{site.inventory.length}

+
+
+

POs (all time)

+

{site.purchaseOrders.length}

+
+
+ + {/* Charts */} + {(inventoryChartData.length > 0 || consumptionChartData.length > 0) && ( + + )} + + {/* Inventory table */} +
+

Inventory at this site

+ {site.inventory.length === 0 ? ( +

No inventory tracked yet. Updated automatically when POs are delivered here.

+ ) : ( + + + + + + + + + + + {site.inventory.map((inv) => ( + + + + + + + ))} + +
ItemCodeQty on handUpdated
+ + {inv.product.name} + + {inv.product.code}{Number(inv.quantity)}{formatDate(inv.updatedAt)}
+ )} +
+ + {/* Record consumption */} +
+

Record Daily Consumption

+ +
+ + {/* Assigned vessels */} + {site.vessels.length > 0 && ( +
+

Assigned Vessels

+
+ {site.vessels.map((v) => ( + + {v.name} + {v.imoNumber && IMO {v.imoNumber}} + + ))} +
+
+ )} + + {/* Recent POs */} + {site.purchaseOrders.length > 0 && ( +
+

Recent Purchase Orders

+ + + + + + + + + + + + {site.purchaseOrders.map((po) => ( + + + + + + + + ))} + +
POVendorStatusAmountDate
+ {po.poNumber} + {po.vendor?.name ?? }{STATUS_LABELS[po.status] ?? po.status}{formatCurrency(Number(po.totalAmount))}{formatDate(po.createdAt)}
+
+ )} +
+ ); +} diff --git a/App/pelagia-portal/app/(portal)/admin/sites/[id]/site-charts.tsx b/App/pelagia-portal/app/(portal)/admin/sites/[id]/site-charts.tsx new file mode 100644 index 0000000..1d7a4f5 --- /dev/null +++ b/App/pelagia-portal/app/(portal)/admin/sites/[id]/site-charts.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { BarChart, Bar, LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid } from "recharts"; + +interface Props { + inventoryData: { name: string; quantity: number }[]; + consumptionData: { date: string; qty: number }[]; +} + +export function SiteCharts({ inventoryData, consumptionData }: Props) { + return ( +
+ {inventoryData.length > 0 && ( +
+

Inventory Levels

+ + + + + + [v, "Qty"]} /> + + + +
+ )} + {consumptionData.length > 0 && ( +
+

Daily Consumption (30 days)

+ + + + + + + + + +
+ )} +
+ ); +} diff --git a/App/pelagia-portal/app/(portal)/admin/sites/actions.ts b/App/pelagia-portal/app/(portal)/admin/sites/actions.ts new file mode 100644 index 0000000..0daf842 --- /dev/null +++ b/App/pelagia-portal/app/(portal)/admin/sites/actions.ts @@ -0,0 +1,97 @@ +"use server"; + +import { auth } from "@/auth"; +import { db } from "@/lib/db"; +import { hasPermission } from "@/lib/permissions"; +import { geocodePincode } from "@/lib/geo"; +import { revalidatePath } from "next/cache"; +import { z } from "zod"; + +const siteSchema = z.object({ + name: z.string().min(1, "Name is required"), + code: z.string().min(1, "Code is required").max(20).toUpperCase(), + address: z.string().optional(), + pincode: z.string().optional(), + latitude: z.coerce.number().optional(), + longitude: z.coerce.number().optional(), +}); + +type Result = { ok: true } | { error: string }; + +export async function createSite(formData: FormData): Promise { + const session = await auth(); + if (!session?.user || !hasPermission(session.user.role, "manage_sites")) return { error: "Forbidden" }; + + const parsed = siteSchema.safeParse(Object.fromEntries(formData)); + if (!parsed.success) return { error: parsed.error.errors[0].message }; + const { name, code, address, pincode, latitude, longitude } = parsed.data; + + let lat = latitude, lng = longitude; + if (!lat && pincode) { + const geo = await geocodePincode(pincode); + if (geo) { lat = geo.lat; lng = geo.lng; } + } + + try { + await db.site.create({ data: { name, code, address: address ?? null, latitude: lat ?? null, longitude: lng ?? null } }); + } catch { + return { error: "A site with that code already exists." }; + } + revalidatePath("/admin/sites"); + return { ok: true }; +} + +export async function updateSite(id: string, formData: FormData): Promise { + const session = await auth(); + if (!session?.user || !hasPermission(session.user.role, "manage_sites")) return { error: "Forbidden" }; + + const parsed = siteSchema.safeParse(Object.fromEntries(formData)); + if (!parsed.success) return { error: parsed.error.errors[0].message }; + const { name, code, address, pincode, latitude, longitude } = parsed.data; + + let lat = latitude, lng = longitude; + if (!lat && pincode) { + const geo = await geocodePincode(pincode); + if (geo) { lat = geo.lat; lng = geo.lng; } + } + + await db.site.update({ where: { id }, data: { name, code, address: address ?? null, latitude: lat ?? null, longitude: lng ?? null } }); + revalidatePath("/admin/sites"); + revalidatePath(`/admin/sites/${id}`); + return { ok: true }; +} + +export async function toggleSiteActive(id: string): Promise { + const session = await auth(); + if (!session?.user || !hasPermission(session.user.role, "manage_sites")) return { error: "Forbidden" }; + + const site = await db.site.findUnique({ where: { id }, select: { isActive: true } }); + if (!site) return { error: "Not found" }; + await db.site.update({ where: { id }, data: { isActive: !site.isActive } }); + revalidatePath("/admin/sites"); + return { ok: true }; +} + +export async function recordConsumption(formData: FormData): Promise { + const session = await auth(); + if (!session?.user) return { error: "Unauthorized" }; + + const schema = z.object({ + siteId: z.string().min(1), + productId: z.string().min(1), + date: z.string().min(1), + quantity: z.coerce.number().positive(), + note: z.string().optional(), + }); + const parsed = schema.safeParse(Object.fromEntries(formData)); + if (!parsed.success) return { error: parsed.error.errors[0].message }; + const { siteId, productId, date, quantity, note } = parsed.data; + + await db.itemConsumption.upsert({ + where: { productId_siteId_date: { productId, siteId, date: new Date(date) } }, + update: { quantity, note: note ?? null, recordedById: session.user.id }, + create: { productId, siteId, date: new Date(date), quantity, note: note ?? null, recordedById: session.user.id }, + }); + revalidatePath(`/admin/sites/${siteId}`); + return { ok: true }; +} diff --git a/App/pelagia-portal/app/(portal)/admin/sites/page.tsx b/App/pelagia-portal/app/(portal)/admin/sites/page.tsx new file mode 100644 index 0000000..dccc574 --- /dev/null +++ b/App/pelagia-portal/app/(portal)/admin/sites/page.tsx @@ -0,0 +1,90 @@ +import { auth } from "@/auth"; +import { db } from "@/lib/db"; +import { hasPermission } from "@/lib/permissions"; +import { redirect } from "next/navigation"; +import Link from "next/link"; +import { AddSiteButton, EditSiteButton } from "./site-form"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { title: "Sites" }; + +export default async function SitesPage() { + const session = await auth(); + if (!session?.user) redirect("/login"); + if (!hasPermission(session.user.role, "manage_sites")) redirect("/dashboard"); + + const sites = await db.site.findMany({ + orderBy: { name: "asc" }, + include: { + _count: { select: { vessels: true, inventory: true } }, + }, + }); + + const canEdit = session.user.role === "ADMIN"; + + return ( +
+
+
+

Sites

+

Ports, depots and offices with inventory

+
+ {canEdit && } +
+ +
+ + + + + + + + + + + {canEdit && } + + + + {sites.length === 0 && ( + + + + )} + {sites.map((site) => ( + + + + + + + + + {canEdit && ( + + )} + + ))} + +
NameCodeAddressVesselsItems trackedLocationStatus
+ No sites yet. Add your first port, depot or office. +
+ + {site.name} + + {site.code}{site.address ?? }{site._count.vessels || }{site._count.inventory || } + {site.latitude && site.longitude + ? `${site.latitude.toFixed(4)}, ${site.longitude.toFixed(4)}` + : Not set} + + + {site.isActive ? "Active" : "Inactive"} + + + +
+
+
+ ); +} diff --git a/App/pelagia-portal/app/(portal)/admin/sites/site-form.tsx b/App/pelagia-portal/app/(portal)/admin/sites/site-form.tsx new file mode 100644 index 0000000..6467d02 --- /dev/null +++ b/App/pelagia-portal/app/(portal)/admin/sites/site-form.tsx @@ -0,0 +1,114 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { AdminDialog } from "@/components/ui/admin-dialog"; +import { createSite, updateSite, toggleSiteActive } 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"; + +type SiteRow = { id: string; name: string; code: string; address: string | null; latitude: number | null; longitude: number | null; isActive: boolean }; + +function SiteFormFields({ site }: { site?: SiteRow }) { + return ( +
+
+
+ + +
+
+ + +
+
+ + +
+
+ +