feat(inventory): Sites, GST lookup, distance sorting, cart, inventory tracking

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 <noreply@anthropic.com>
This commit is contained in:
Hardik 2026-05-14 11:50:11 +05:30
parent 1c7d0b8901
commit bea798324c
25 changed files with 1609 additions and 180 deletions

View file

@ -0,0 +1 @@
{"sessionId":"09360cd7-ccc6-4301-988d-d01a37fd1072","pid":23908,"acquiredAt":1777480375077}

View file

@ -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
```

View file

@ -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 (
<div className="rounded-lg border border-neutral-200 bg-white p-5">
<p className="text-sm font-semibold text-neutral-900 mb-4">Price Comparison by Vendor</p>
<ResponsiveContainer width="100%" height={200}>
<BarChart data={data} margin={{ left: 8, right: 16 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="vendor" tick={{ fontSize: 11 }} />
<YAxis tick={{ fontSize: 11 }} tickFormatter={(v) => `${(v / 1000).toFixed(0)}k`} />
<Tooltip formatter={(v: number) => [formatCurrency(v), "Price"]} />
<Bar dataKey="price" fill="#2563eb" radius={[3, 3, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
);
}

View file

@ -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<Metadata> {
@ -17,148 +21,186 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
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 (
<div className="max-w-5xl space-y-6">
{/* Breadcrumb */}
<div className="max-w-6xl space-y-6">
<div className="flex items-center gap-2 text-sm text-neutral-500">
<Link href="/admin/products" className="hover:text-neutral-700">Items</Link>
<span>/</span>
<span className="text-neutral-900 font-medium">{product.name}</span>
</div>
{/* Header */}
<div className="flex items-start justify-between gap-4">
<div>
<div className="flex items-center gap-3 mb-1">
<span className="font-mono text-xs text-neutral-500">{product.code}</span>
<span className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${
product.isActive ? "bg-success-100 text-success-700" : "bg-neutral-100 text-neutral-500"
}`}>
<span className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${product.isActive ? "bg-success-100 text-success-700" : "bg-neutral-100 text-neutral-500"}`}>
{product.isActive ? "Active" : "Inactive"}
</span>
</div>
<h1 className="text-2xl font-semibold text-neutral-900">{product.name}</h1>
{product.description && (
<p className="mt-1 text-sm text-neutral-500">{product.description}</p>
)}
{product.description && <p className="mt-1 text-sm text-neutral-500">{product.description}</p>}
</div>
{canManage && (
<ToggleProductButton product={{
id: product.id,
code: product.code,
name: product.name,
description: product.description,
isActive: product.isActive,
}} />
<div className="flex gap-2 items-start">
<AddToCartButton item={{ productId: product.id, name: product.name, description: product.description ?? undefined, unit: "pc", unitPrice: minPrice ?? 0 }} />
{canManage && <ToggleProductButton product={{ id: product.id, code: product.code, name: product.name, description: product.description, isActive: product.isActive }} />}
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-4 gap-4">
<div className="rounded-lg border border-neutral-200 bg-white px-5 py-4">
<p className="text-xs text-neutral-500 mb-1">Vendors</p>
<p className="text-2xl font-semibold text-neutral-900">{product.vendorPrices.length}</p>
</div>
<div className="rounded-lg border border-neutral-200 bg-white px-5 py-4">
<p className="text-xs text-neutral-500 mb-1">Lowest Price</p>
<p className="text-2xl font-semibold text-success-700">{minPrice !== null ? formatCurrency(minPrice) : "—"}</p>
</div>
<div className="rounded-lg border border-neutral-200 bg-white px-5 py-4">
<p className="text-xs text-neutral-500 mb-1">Highest Price</p>
<p className="text-2xl font-semibold text-neutral-900">{maxPrice !== null ? formatCurrency(maxPrice) : "—"}</p>
</div>
<div className="rounded-lg border border-neutral-200 bg-white px-5 py-4">
<p className="text-xs text-neutral-500 mb-1">Sites with stock</p>
<p className="text-2xl font-semibold text-neutral-900">{product.inventory.length}</p>
</div>
</div>
{/* Price chart */}
{priceChartData.length > 1 && <ItemPriceChart data={priceChartData} />}
{/* Site filter for distance */}
<div className="flex items-center gap-3">
<span className="text-sm font-medium text-neutral-700">Sort by distance from site:</span>
<form method="get">
<select name="site" onChange={(e) => { (e.target.form as HTMLFormElement).submit(); }}
defaultValue={siteId ?? ""}
className="rounded-lg border border-neutral-300 px-3 py-1.5 text-sm focus:outline-none">
<option value="">Price (cheapest first)</option>
{sites.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
</form>
{selectedSite && (
<Link href={`/admin/products/${id}`} className="text-sm text-neutral-500 hover:underline">Clear</Link>
)}
</div>
{/* Price summary */}
{product.vendorPrices.length > 0 && (
<div className="grid grid-cols-3 gap-4">
<div className="rounded-lg border border-neutral-200 bg-white px-5 py-4">
<p className="text-xs text-neutral-500 mb-1">Vendors</p>
<p className="text-2xl font-semibold text-neutral-900">{product.vendorPrices.length}</p>
</div>
<div className="rounded-lg border border-neutral-200 bg-white px-5 py-4">
<p className="text-xs text-neutral-500 mb-1">Lowest Price</p>
<p className="text-2xl font-semibold text-success-700">
{minPrice !== null ? formatCurrency(minPrice) : "—"}
</p>
</div>
<div className="rounded-lg border border-neutral-200 bg-white px-5 py-4">
<p className="text-xs text-neutral-500 mb-1">Highest Price</p>
<p className="text-2xl font-semibold text-neutral-900">
{maxPrice !== null ? formatCurrency(maxPrice) : "—"}
</p>
</div>
</div>
)}
{/* Vendors that carry this item */}
{/* Vendors table */}
<div className="rounded-lg border border-neutral-200 bg-white p-6">
<h2 className="text-sm font-semibold text-neutral-900 mb-4">
Available From
<span className="ml-2 text-neutral-400 font-normal">({product.vendorPrices.length} vendor{product.vendorPrices.length !== 1 ? "s" : ""})</span>
{selectedSite && <span className="ml-2 text-primary-600 font-normal text-xs">sorted by distance from {selectedSite.name}</span>}
</h2>
{product.vendorPrices.length === 0 ? (
<p className="text-sm text-neutral-400 italic">
No vendor pricing on record yet. Prices are recorded automatically when a PO containing this item is marked as paid.
</p>
{enriched.length === 0 ? (
<p className="text-sm text-neutral-400 italic">No vendor pricing on record yet. Updated automatically when a PO is marked as paid.</p>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-neutral-200">
<th className="pb-2 text-left font-medium text-neutral-600">Vendor</th>
<th className="pb-2 text-left font-medium text-neutral-600 pl-4">Vendor ID</th>
<th className="pb-2 text-left font-medium text-neutral-600 pl-4">Verified</th>
<th className="pb-2 text-right font-medium text-neutral-600 pl-4">Price</th>
<th className="pb-2 text-right font-medium text-neutral-600 pl-4">Last Updated</th>
{selectedSite && <th className="pb-2 text-right font-medium text-neutral-600 pl-4">Distance</th>}
<th className="pb-2 text-right font-medium text-neutral-600 pl-4">Updated</th>
<th className="pb-2 pl-4" />
</tr>
</thead>
<tbody className="divide-y divide-neutral-100">
{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 (
<tr key={vp.id} className="hover:bg-neutral-50">
<td className="py-2.5 pr-4">
<Link
href={`/admin/vendors/${vp.vendor.id}`}
className="font-medium text-primary-600 hover:underline"
>
{vp.vendor.name}
</Link>
{!vp.vendor.isActive && (
<span className="ml-2 text-xs text-neutral-400 italic">inactive</span>
)}
</td>
<td className="py-2.5 pl-4 font-mono text-xs text-neutral-500">
{vp.vendor.vendorId ?? <span className="italic text-neutral-400">Pending</span>}
<Link href={`/admin/vendors/${vp.vendor.id}`} className="font-medium text-primary-600 hover:underline">{vp.vendor.name}</Link>
{!vp.vendor.isActive && <span className="ml-2 text-xs text-neutral-400 italic">inactive</span>}
</td>
<td className="py-2.5 pl-4">
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${
vp.vendor.isVerified ? "bg-success-100 text-success-700" : "bg-warning-100 text-warning-700"
}`}>
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${vp.vendor.isVerified ? "bg-success-100 text-success-700" : "bg-warning-100 text-warning-700"}`}>
{vp.vendor.isVerified ? "Verified" : "Unverified"}
</span>
</td>
<td className="py-2.5 pl-4 text-right">
<span className={`font-semibold ${isCheapest ? "text-success-700" : "text-neutral-900"}`}>
{formatCurrency(price)}
</span>
{isCheapest && (
<span className="ml-1.5 text-xs text-success-600">lowest</span>
)}
<span className={`font-semibold ${isCheapest ? "text-success-700" : "text-neutral-900"}`}>{formatCurrency(price)}</span>
{isCheapest && !selectedSite && <span className="ml-1.5 text-xs text-success-600">lowest</span>}
</td>
{selectedSite && (
<td className="py-2.5 pl-4 text-right">
{vp.distanceKm !== null
? <span className={isClosest ? "font-semibold text-primary-700" : "text-neutral-600"}>{formatDistance(vp.distanceKm)}{isClosest ? " ★" : ""}</span>
: <span className="text-neutral-400 italic text-xs">No location</span>}
</td>
)}
<td className="py-2.5 pl-4 text-right text-neutral-500">{formatDate(vp.updatedAt)}</td>
<td className="py-2.5 pl-4">
<AddToCartButton
item={{ productId: product.id, name: product.name, description: product.description ?? undefined, unit: "pc", unitPrice: price, vendorId: vp.vendor.id, vendorName: vp.vendor.name }}
className="text-xs text-primary-600 hover:underline font-medium whitespace-nowrap"
/>
</td>
</tr>
);
})}
@ -166,6 +208,22 @@ export default async function ProductDetailPage({ params }: Props) {
</table>
)}
</div>
{/* Inventory by site */}
{product.inventory.length > 0 && (
<div className="rounded-lg border border-neutral-200 bg-white p-6">
<h2 className="text-sm font-semibold text-neutral-900 mb-4">Stock by Site</h2>
<div className="flex flex-wrap gap-3">
{product.inventory.map((inv) => (
<Link key={inv.id} href={`/admin/sites/${inv.site.id}`}
className="rounded-lg border border-neutral-200 px-4 py-2 text-sm hover:bg-neutral-50">
<span className="font-medium text-neutral-900">{inv.site.name}</span>
<span className="ml-2 text-neutral-500">{Number(inv.quantity)} units</span>
</Link>
))}
</div>
</div>
)}
</div>
);
}

View file

@ -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<HTMLFormElement>) {
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 (
<form onSubmit={handleSubmit} className="flex flex-wrap items-end gap-3">
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Item *</label>
<select name="productId" required className={INPUT + " w-56"}>
<option value="">Select item</option>
{products.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Date *</label>
<input name="date" type="date" required defaultValue={new Date().toISOString().split("T")[0]} className={INPUT} />
</div>
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Qty consumed *</label>
<input name="quantity" type="number" step="any" min="0.001" required placeholder="e.g. 2" className={INPUT + " w-28"} />
</div>
<div className="flex-1">
<label className="block text-xs font-medium text-neutral-700 mb-1">Note</label>
<input name="note" className={INPUT + " w-full"} placeholder="Optional" />
</div>
<button type="submit" disabled={pending} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white disabled:opacity-60">
{pending ? "Saving…" : "Record"}
</button>
{success && <p className="text-sm text-success-700">Recorded.</p>}
{error && <p className="text-sm text-danger-700">{error}</p>}
</form>
);
}

View file

@ -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<Metadata> {
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<string, number>();
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<string, string> = {
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 (
<div className="max-w-6xl space-y-6">
{/* Breadcrumb */}
<div className="flex items-center gap-2 text-sm text-neutral-500">
<Link href="/admin/sites" className="hover:text-neutral-700">Sites</Link>
<span>/</span>
<span className="text-neutral-900 font-medium">{site.name}</span>
</div>
{/* Header */}
<div className="flex items-start justify-between gap-4">
<div>
<div className="flex items-center gap-3 mb-1">
<span className="font-mono text-xs text-neutral-500">{site.code}</span>
<span className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${site.isActive ? "bg-success-100 text-success-700" : "bg-neutral-100 text-neutral-500"}`}>
{site.isActive ? "Active" : "Inactive"}
</span>
</div>
<h1 className="text-2xl font-semibold text-neutral-900">{site.name}</h1>
{site.address && <p className="mt-1 text-sm text-neutral-500">{site.address}</p>}
{site.latitude && site.longitude && (
<p className="text-xs text-neutral-400 mt-0.5">{site.latitude.toFixed(5)}, {site.longitude.toFixed(5)}</p>
)}
</div>
<div className="flex gap-2">
<Link href={`/po/new?siteId=${site.id}`} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700">
+ Create PO
</Link>
{canEdit && <EditSiteButton site={{ id: site.id, name: site.name, code: site.code, address: site.address, latitude: site.latitude, longitude: site.longitude, isActive: site.isActive }} />}
</div>
</div>
{/* Summary cards */}
<div className="grid grid-cols-3 gap-4">
<div className="rounded-lg border border-neutral-200 bg-white px-5 py-4">
<p className="text-xs text-neutral-500 mb-1">Assigned Vessels</p>
<p className="text-2xl font-semibold text-neutral-900">{site.vessels.length}</p>
</div>
<div className="rounded-lg border border-neutral-200 bg-white px-5 py-4">
<p className="text-xs text-neutral-500 mb-1">Items Tracked</p>
<p className="text-2xl font-semibold text-neutral-900">{site.inventory.length}</p>
</div>
<div className="rounded-lg border border-neutral-200 bg-white px-5 py-4">
<p className="text-xs text-neutral-500 mb-1">POs (all time)</p>
<p className="text-2xl font-semibold text-neutral-900">{site.purchaseOrders.length}</p>
</div>
</div>
{/* Charts */}
{(inventoryChartData.length > 0 || consumptionChartData.length > 0) && (
<SiteCharts inventoryData={inventoryChartData} consumptionData={consumptionChartData} />
)}
{/* Inventory table */}
<div className="rounded-lg border border-neutral-200 bg-white p-6">
<h2 className="text-sm font-semibold text-neutral-900 mb-4">Inventory at this site</h2>
{site.inventory.length === 0 ? (
<p className="text-sm text-neutral-400 italic">No inventory tracked yet. Updated automatically when POs are delivered here.</p>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-neutral-200">
<th className="pb-2 text-left font-medium text-neutral-600">Item</th>
<th className="pb-2 text-left font-medium text-neutral-600 pl-4">Code</th>
<th className="pb-2 text-right font-medium text-neutral-600 pl-4">Qty on hand</th>
<th className="pb-2 text-right font-medium text-neutral-600 pl-4">Updated</th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-100">
{site.inventory.map((inv) => (
<tr key={inv.id}>
<td className="py-2 pr-4">
<Link href={`/admin/products/${inv.product.id}`} className="font-medium text-primary-600 hover:underline">
{inv.product.name}
</Link>
</td>
<td className="py-2 pl-4 font-mono text-xs text-neutral-500">{inv.product.code}</td>
<td className="py-2 pl-4 text-right font-semibold text-neutral-900">{Number(inv.quantity)}</td>
<td className="py-2 pl-4 text-right text-neutral-500">{formatDate(inv.updatedAt)}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{/* Record consumption */}
<div className="rounded-lg border border-neutral-200 bg-white p-6">
<h2 className="text-sm font-semibold text-neutral-900 mb-4">Record Daily Consumption</h2>
<ConsumptionForm siteId={site.id} products={products} />
</div>
{/* Assigned vessels */}
{site.vessels.length > 0 && (
<div className="rounded-lg border border-neutral-200 bg-white p-6">
<h2 className="text-sm font-semibold text-neutral-900 mb-4">Assigned Vessels</h2>
<div className="flex flex-wrap gap-2">
{site.vessels.map((v) => (
<Link key={v.id} href={`/admin/vessels/${v.id}`}
className="rounded-lg border border-neutral-200 px-3 py-1.5 text-sm font-medium text-neutral-700 hover:bg-neutral-50">
{v.name}
{v.imoNumber && <span className="ml-1.5 text-xs text-neutral-400 font-mono">IMO {v.imoNumber}</span>}
</Link>
))}
</div>
</div>
)}
{/* Recent POs */}
{site.purchaseOrders.length > 0 && (
<div className="rounded-lg border border-neutral-200 bg-white p-6">
<h2 className="text-sm font-semibold text-neutral-900 mb-4">Recent Purchase Orders</h2>
<table className="w-full text-sm">
<thead>
<tr className="border-b border-neutral-200">
<th className="pb-2 text-left font-medium text-neutral-600">PO</th>
<th className="pb-2 text-left font-medium text-neutral-600 pl-4">Vendor</th>
<th className="pb-2 text-left font-medium text-neutral-600 pl-4">Status</th>
<th className="pb-2 text-right font-medium text-neutral-600 pl-4">Amount</th>
<th className="pb-2 text-right font-medium text-neutral-600 pl-4">Date</th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-100">
{site.purchaseOrders.map((po) => (
<tr key={po.id}>
<td className="py-2 pr-4">
<Link href={`/po/${po.id}`} className="font-mono text-xs text-primary-600 hover:underline">{po.poNumber}</Link>
</td>
<td className="py-2 pl-4 text-neutral-600">{po.vendor?.name ?? <span className="italic text-neutral-400"></span>}</td>
<td className="py-2 pl-4 text-neutral-600">{STATUS_LABELS[po.status] ?? po.status}</td>
<td className="py-2 pl-4 text-right">{formatCurrency(Number(po.totalAmount))}</td>
<td className="py-2 pl-4 text-right text-neutral-500">{formatDate(po.createdAt)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}

View file

@ -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 (
<div className="grid grid-cols-2 gap-4">
{inventoryData.length > 0 && (
<div className="rounded-lg border border-neutral-200 bg-white p-5">
<p className="text-sm font-semibold text-neutral-900 mb-4">Inventory Levels</p>
<ResponsiveContainer width="100%" height={220}>
<BarChart data={inventoryData} layout="vertical" margin={{ left: 8, right: 16 }}>
<CartesianGrid strokeDasharray="3 3" horizontal={false} />
<XAxis type="number" tick={{ fontSize: 11 }} />
<YAxis type="category" dataKey="name" width={130} tick={{ fontSize: 11 }} />
<Tooltip formatter={(v) => [v, "Qty"]} />
<Bar dataKey="quantity" fill="#2563eb" radius={[0, 3, 3, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
)}
{consumptionData.length > 0 && (
<div className="rounded-lg border border-neutral-200 bg-white p-5">
<p className="text-sm font-semibold text-neutral-900 mb-4">Daily Consumption (30 days)</p>
<ResponsiveContainer width="100%" height={220}>
<LineChart data={consumptionData} margin={{ left: 0, right: 16 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" tick={{ fontSize: 10 }} interval="preserveStartEnd" />
<YAxis tick={{ fontSize: 11 }} />
<Tooltip />
<Line type="monotone" dataKey="qty" stroke="#16a34a" strokeWidth={2} dot={false} name="Units consumed" />
</LineChart>
</ResponsiveContainer>
</div>
)}
</div>
);
}

View file

@ -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<Result> {
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<Result> {
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<Result> {
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<Result> {
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 };
}

View file

@ -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 (
<div>
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-neutral-900">Sites</h1>
<p className="text-sm text-neutral-500 mt-0.5">Ports, depots and offices with inventory</p>
</div>
{canEdit && <AddSiteButton />}
</div>
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-neutral-50 border-b border-neutral-200">
<tr>
<th className="px-4 py-3 text-left font-medium text-neutral-600">Name</th>
<th className="px-4 py-3 text-left font-medium text-neutral-600">Code</th>
<th className="px-4 py-3 text-left font-medium text-neutral-600">Address</th>
<th className="px-4 py-3 text-right font-medium text-neutral-600">Vessels</th>
<th className="px-4 py-3 text-right font-medium text-neutral-600">Items tracked</th>
<th className="px-4 py-3 text-left font-medium text-neutral-600">Location</th>
<th className="px-4 py-3 text-left font-medium text-neutral-600">Status</th>
{canEdit && <th className="px-4 py-3"></th>}
</tr>
</thead>
<tbody className="divide-y divide-neutral-100">
{sites.length === 0 && (
<tr>
<td colSpan={canEdit ? 8 : 7} className="px-4 py-8 text-center text-neutral-400">
No sites yet. Add your first port, depot or office.
</td>
</tr>
)}
{sites.map((site) => (
<tr key={site.id} className="hover:bg-neutral-50">
<td className="px-4 py-3">
<Link href={`/admin/sites/${site.id}`} className="font-medium text-primary-600 hover:underline">
{site.name}
</Link>
</td>
<td className="px-4 py-3 font-mono text-xs text-neutral-500">{site.code}</td>
<td className="px-4 py-3 text-neutral-500 max-w-xs truncate">{site.address ?? <span className="italic text-neutral-400"></span>}</td>
<td className="px-4 py-3 text-right text-neutral-600">{site._count.vessels || <span className="text-neutral-400"></span>}</td>
<td className="px-4 py-3 text-right text-neutral-600">{site._count.inventory || <span className="text-neutral-400"></span>}</td>
<td className="px-4 py-3 text-xs text-neutral-500">
{site.latitude && site.longitude
? `${site.latitude.toFixed(4)}, ${site.longitude.toFixed(4)}`
: <span className="italic text-neutral-400">Not set</span>}
</td>
<td className="px-4 py-3">
<span className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${site.isActive ? "bg-success-100 text-success-700" : "bg-neutral-100 text-neutral-500"}`}>
{site.isActive ? "Active" : "Inactive"}
</span>
</td>
{canEdit && (
<td className="px-4 py-3">
<EditSiteButton site={{ id: site.id, name: site.name, code: site.code, address: site.address, latitude: site.latitude, longitude: site.longitude, isActive: site.isActive }} />
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View file

@ -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 (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div className="col-span-2">
<label className="block text-xs font-medium text-neutral-700 mb-1">Site Name *</label>
<input name="name" defaultValue={site?.name} required className={INPUT} placeholder="e.g. Navi Mumbai Port" />
</div>
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Code *</label>
<input name="code" defaultValue={site?.code} required className={INPUT} placeholder="e.g. NMB" style={{ textTransform: "uppercase" }} />
</div>
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Pincode (for auto-geocoding)</label>
<input name="pincode" className={INPUT} placeholder="e.g. 400614" />
</div>
<div className="col-span-2">
<label className="block text-xs font-medium text-neutral-700 mb-1">Address</label>
<textarea name="address" defaultValue={site?.address ?? ""} rows={2} className={INPUT} />
</div>
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Latitude (override)</label>
<input name="latitude" type="number" step="any" defaultValue={site?.latitude ?? ""} className={INPUT} placeholder="18.6117" />
</div>
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Longitude (override)</label>
<input name="longitude" type="number" step="any" defaultValue={site?.longitude ?? ""} className={INPUT} placeholder="73.0059" />
</div>
</div>
</div>
);
}
export function AddSiteButton() {
const router = useRouter();
const [open, setOpen] = useState(false);
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault(); setPending(true); setError("");
const result = await createSite(new FormData(e.currentTarget));
if ("error" in result) { setError(result.error); setPending(false); }
else { setOpen(false); router.refresh(); }
}
return (
<>
<button onClick={() => setOpen(true)} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700">
+ Add Site
</button>
<AdminDialog open={open} onClose={() => setOpen(false)} title="Add Site">
<form onSubmit={handleSubmit} className="space-y-4">
<SiteFormFields />
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded px-3 py-2">{error}</p>}
<div className="flex gap-3 justify-end">
<button type="button" onClick={() => setOpen(false)} className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700">Cancel</button>
<button type="submit" disabled={pending} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white disabled:opacity-60">{pending ? "Saving…" : "Create Site"}</button>
</div>
</form>
</AdminDialog>
</>
);
}
export function EditSiteButton({ site }: { site: SiteRow }) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault(); setPending(true); setError("");
const result = await updateSite(site.id, new FormData(e.currentTarget));
if ("error" in result) { setError(result.error); setPending(false); }
else { setOpen(false); router.refresh(); }
}
async function handleToggle() {
await toggleSiteActive(site.id); router.refresh();
}
return (
<>
<button onClick={() => setOpen(true)} className="text-xs text-primary-600 hover:underline font-medium">Edit</button>
<AdminDialog open={open} onClose={() => setOpen(false)} title={`Edit — ${site.name}`}>
<form onSubmit={handleSubmit} className="space-y-4">
<SiteFormFields site={site} />
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded px-3 py-2">{error}</p>}
<div className="flex items-center justify-between">
<button type="button" onClick={handleToggle} className={`text-xs underline ${site.isActive ? "text-danger-600" : "text-success-600"}`}>
{site.isActive ? "Deactivate" : "Activate"}
</button>
<div className="flex gap-3">
<button type="button" onClick={() => setOpen(false)} className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700">Cancel</button>
<button type="submit" disabled={pending} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white disabled:opacity-60">{pending ? "Saving…" : "Save"}</button>
</div>
</div>
</form>
</AdminDialog>
</>
);
}

View file

@ -13,6 +13,8 @@ const vendorSchema = z.object({
vendorId: z.string().optional(),
address: z.string().optional(),
gstin: z.string().optional(),
latitude: z.coerce.number().optional(),
longitude: z.coerce.number().optional(),
contactName: z.string().optional(),
contactMobile: z.string().optional(),
contactEmail: z.string().email("Invalid contact email").optional().or(z.literal("")),
@ -29,6 +31,8 @@ export async function createVendor(formData: FormData): Promise<ActionResult> {
vendorId: formData.get("vendorId") || undefined,
address: formData.get("address") || undefined,
gstin: formData.get("gstin") || undefined,
latitude: formData.get("latitude") || undefined,
longitude: formData.get("longitude") || undefined,
contactName: formData.get("contactName") || undefined,
contactMobile: formData.get("contactMobile") || undefined,
contactEmail: formData.get("contactEmail") || undefined,
@ -47,6 +51,8 @@ export async function createVendor(formData: FormData): Promise<ActionResult> {
vendorId: data.vendorId ?? null,
address: data.address ?? null,
gstin: data.gstin ?? null,
latitude: data.latitude ?? null,
longitude: data.longitude ?? null,
contactName: data.contactName ?? null,
contactMobile: data.contactMobile ?? null,
contactEmail: data.contactEmail || null,
@ -72,6 +78,8 @@ export async function updateVendor(formData: FormData): Promise<ActionResult> {
vendorId: formData.get("vendorId") || undefined,
address: formData.get("address") || undefined,
gstin: formData.get("gstin") || undefined,
latitude: formData.get("latitude") || undefined,
longitude: formData.get("longitude") || undefined,
contactName: formData.get("contactName") || undefined,
contactMobile: formData.get("contactMobile") || undefined,
contactEmail: formData.get("contactEmail") || undefined,
@ -80,9 +88,7 @@ export async function updateVendor(formData: FormData): Promise<ActionResult> {
const data = parsed.data;
if (data.vendorId) {
const conflict = await db.vendor.findFirst({
where: { vendorId: data.vendorId, id: { not: id } },
});
const conflict = await db.vendor.findFirst({ where: { vendorId: data.vendorId, id: { not: id } } });
if (conflict) return { error: "Another vendor already has that Vendor ID" };
}
@ -93,6 +99,8 @@ export async function updateVendor(formData: FormData): Promise<ActionResult> {
vendorId: data.vendorId ?? null,
address: data.address ?? null,
gstin: data.gstin ?? null,
latitude: data.latitude ?? null,
longitude: data.longitude ?? null,
contactName: data.contactName ?? null,
contactMobile: data.contactMobile ?? null,
contactEmail: data.contactEmail || null,

View file

@ -6,53 +6,95 @@ import { AdminDialog } from "@/components/ui/admin-dialog";
import { createVendor, updateVendor, toggleVendorActive } from "./actions";
type VendorRow = {
id: string;
name: string;
vendorId: string | null;
address: string | null;
gstin: string | null;
contactName: string | null;
contactMobile: string | null;
contactEmail: string | null;
isActive: boolean;
id: string; name: string; vendorId: string | null; address: string | null;
gstin: string | null; contactName: string | null; contactMobile: string | null;
contactEmail: string | null; isActive: boolean;
};
const INPUT_CLS =
"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";
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 GstData = { legalName: string; tradeName: string; address: string; state: string; pincode: string; lat: number | null; lng: number | null; error?: string };
function VendorFormFields({ vendor }: { vendor?: VendorRow }) {
const [gstin, setGstin] = useState(vendor?.gstin ?? "");
const [name, setName] = useState(vendor?.name ?? "");
const [address, setAddress] = useState(vendor?.address ?? "");
const [lat, setLat] = useState("");
const [lng, setLng] = useState("");
const [looking, setLooking] = useState(false);
const [gstError, setGstError] = useState("");
async function lookupGst() {
if (gstin.length < 15) { setGstError("Enter a valid 15-character GSTIN"); return; }
setLooking(true); setGstError("");
try {
const res = await fetch(`/api/gst?gstin=${encodeURIComponent(gstin)}`);
const data: GstData = await res.json();
if (data.error) { setGstError(data.error); return; }
if (!name) setName(data.tradeName || data.legalName);
setAddress([data.address, data.state, data.pincode].filter(Boolean).join(", "));
if (data.lat) setLat(String(data.lat));
if (data.lng) setLng(String(data.lng));
} catch { setGstError("Lookup failed — check network and API key"); }
finally { setLooking(false); }
}
return (
<div className="space-y-3">
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">
GSTIN <span className="text-neutral-400 font-normal">(recommended auto-fills name, address & location)</span>
</label>
<div className="flex gap-2">
<input name="gstin" value={gstin} onChange={(e) => setGstin(e.target.value.toUpperCase())}
className={INPUT + " font-mono tracking-widest"} placeholder="e.g. 27AAACG1840M1ZL" maxLength={15} />
<button type="button" onClick={lookupGst} disabled={looking || gstin.length < 15}
className="shrink-0 rounded-lg border border-primary-300 bg-primary-50 px-3 py-2 text-sm font-medium text-primary-700 hover:bg-primary-100 disabled:opacity-50 whitespace-nowrap">
{looking ? "Looking up…" : "Look up"}
</button>
</div>
{gstError && <p className="mt-1 text-xs text-danger-600">{gstError}</p>}
</div>
<div className="grid grid-cols-2 gap-3">
<div className="col-span-2">
<label className="block text-xs font-medium text-neutral-700 mb-1">Vendor Name *</label>
<input name="name" defaultValue={vendor?.name} required className={INPUT_CLS} />
<input name="name" value={name} onChange={(e) => setName(e.target.value)} required className={INPUT} />
</div>
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Vendor ID (leave blank if unverified)</label>
<input name="vendorId" defaultValue={vendor?.vendorId ?? ""} className={INPUT_CLS} placeholder="e.g. VND-0042" />
</div>
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">GSTIN</label>
<input name="gstin" defaultValue={vendor?.gstin ?? ""} className={INPUT_CLS} placeholder="e.g. 27AAACG1840M1ZL" />
<label className="block text-xs font-medium text-neutral-700 mb-1">Vendor ID <span className="text-neutral-400 font-normal">(leave blank if unverified)</span></label>
<input name="vendorId" defaultValue={vendor?.vendorId ?? ""} className={INPUT} placeholder="VND-0042" />
</div>
</div>
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Address</label>
<textarea name="address" defaultValue={vendor?.address ?? ""} rows={2} className={INPUT_CLS} placeholder="Full vendor address" />
<label className="block text-xs font-medium text-neutral-700 mb-1">Registered Address</label>
<textarea name="address" value={address} onChange={(e) => setAddress(e.target.value)} rows={2} className={INPUT} />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Latitude <span className="text-neutral-400 font-normal">(auto from GSTIN)</span></label>
<input name="latitude" type="number" step="any" value={lat} onChange={(e) => setLat(e.target.value)} className={INPUT} placeholder="18.6117" />
</div>
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Longitude</label>
<input name="longitude" type="number" step="any" value={lng} onChange={(e) => setLng(e.target.value)} className={INPUT} placeholder="73.0059" />
</div>
</div>
<div className="grid grid-cols-3 gap-3">
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Contact Name</label>
<input name="contactName" defaultValue={vendor?.contactName ?? ""} className={INPUT_CLS} />
<input name="contactName" defaultValue={vendor?.contactName ?? ""} className={INPUT} />
</div>
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Contact Mobile</label>
<input name="contactMobile" defaultValue={vendor?.contactMobile ?? ""} className={INPUT_CLS} placeholder="e.g. 9876543210" />
<label className="block text-xs font-medium text-neutral-700 mb-1">Mobile</label>
<input name="contactMobile" defaultValue={vendor?.contactMobile ?? ""} className={INPUT} placeholder="9876543210" />
</div>
<div>
<label className="block text-xs font-medium text-neutral-700 mb-1">Contact Email</label>
<input name="contactEmail" type="email" defaultValue={vendor?.contactEmail ?? ""} className={INPUT_CLS} />
<label className="block text-xs font-medium text-neutral-700 mb-1">Email</label>
<input name="contactEmail" type="email" defaultValue={vendor?.contactEmail ?? ""} className={INPUT} />
</div>
</div>
</div>
@ -66,9 +108,7 @@ export function AddVendorButton() {
const [error, setError] = useState("");
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setPending(true);
setError("");
e.preventDefault(); setPending(true); setError("");
const result = await createVendor(new FormData(e.currentTarget));
if ("error" in result) { setError(result.error); setPending(false); }
else { setOpen(false); router.refresh(); }
@ -76,23 +116,14 @@ export function AddVendorButton() {
return (
<>
<button onClick={() => setOpen(true)}
className="rounded-lg bg-primary-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-primary-700 transition-colors">
+ Add Vendor
</button>
<button onClick={() => setOpen(true)} className="rounded-lg bg-primary-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-primary-700">+ Add Vendor</button>
<AdminDialog title="Add Vendor" open={open} onClose={() => setOpen(false)}>
<form onSubmit={handleSubmit} className="space-y-4">
<VendorFormFields />
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
<div className="flex justify-end gap-3 pt-1">
<button type="button" onClick={() => setOpen(false)}
className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50">
Cancel
</button>
<button type="submit" disabled={pending}
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60">
{pending ? "Creating…" : "Create Vendor"}
</button>
<div className="flex justify-end gap-3">
<button type="button" onClick={() => setOpen(false)} className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700">Cancel</button>
<button type="submit" disabled={pending} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white disabled:opacity-60">{pending ? "Creating…" : "Create Vendor"}</button>
</div>
</form>
</AdminDialog>
@ -104,13 +135,10 @@ export function EditVendorButton({ vendor }: { vendor: VendorRow }) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [pending, setPending] = useState(false);
const [toggling, setToggling] = useState(false);
const [error, setError] = useState("");
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setPending(true);
setError("");
e.preventDefault(); setPending(true); setError("");
const fd = new FormData(e.currentTarget);
fd.set("id", vendor.id);
const result = await updateVendor(fd);
@ -118,38 +146,23 @@ export function EditVendorButton({ vendor }: { vendor: VendorRow }) {
else { setOpen(false); router.refresh(); }
}
async function handleToggle() {
setToggling(true);
await toggleVendorActive(vendor.id);
router.refresh();
setToggling(false);
}
async function handleToggle() { await toggleVendorActive(vendor.id); router.refresh(); }
return (
<>
<div className="flex items-center gap-3">
<button onClick={() => setOpen(true)}
className="text-sm text-primary-600 hover:text-primary-700 font-medium">
Edit
</button>
<button onClick={handleToggle} disabled={toggling}
className={`text-sm font-medium ${vendor.isActive ? "text-danger-600 hover:text-danger-700" : "text-success-600 hover:text-success-700"}`}>
{toggling ? "…" : vendor.isActive ? "Deactivate" : "Activate"}
</button>
</div>
<AdminDialog title="Edit Vendor" open={open} onClose={() => setOpen(false)}>
<button onClick={() => setOpen(true)} className="text-sm text-primary-600 hover:underline font-medium">Edit</button>
<AdminDialog title={`Edit — ${vendor.name}`} open={open} onClose={() => setOpen(false)}>
<form onSubmit={handleSubmit} className="space-y-4">
<VendorFormFields vendor={vendor} />
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
<div className="flex justify-end gap-3 pt-1">
<button type="button" onClick={() => setOpen(false)}
className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50">
Cancel
</button>
<button type="submit" disabled={pending}
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60">
{pending ? "Saving…" : "Save Changes"}
<div className="flex items-center justify-between">
<button type="button" onClick={handleToggle} className={`text-xs underline ${vendor.isActive ? "text-danger-600" : "text-success-600"}`}>
{vendor.isActive ? "Deactivate" : "Activate"}
</button>
<div className="flex gap-3">
<button type="button" onClick={() => setOpen(false)} className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700">Cancel</button>
<button type="submit" disabled={pending} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white disabled:opacity-60">{pending ? "Saving…" : "Save"}</button>
</div>
</div>
</form>
</AdminDialog>

View file

@ -0,0 +1,117 @@
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 type { Metadata } from "next";
interface Props { params: Promise<{ id: string }> }
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { id } = await params;
const v = await db.vessel.findUnique({ where: { id }, select: { name: true } });
return { title: v?.name ?? "Vessel Detail" };
}
export default async function VesselDetailPage({ params }: Props) {
const session = await auth();
if (!session?.user) redirect("/login");
if (!hasPermission(session.user.role, "manage_sites") && !hasPermission(session.user.role, "manage_vessels_accounts")) redirect("/dashboard");
const { id } = await params;
const vessel = await db.vessel.findUnique({
where: { id },
include: {
site: true,
purchaseOrders: {
select: { id: true, poNumber: true, status: true, totalAmount: true, createdAt: true, vendor: { select: { name: true } } },
orderBy: { createdAt: "desc" },
take: 10,
},
},
});
if (!vessel) notFound();
const STATUS_LABELS: Record<string, string> = {
DRAFT: "Draft", SUBMITTED: "Submitted", MGR_REVIEW: "Under Review",
MGR_APPROVED: "Approved", SENT_FOR_PAYMENT: "Sent for Payment",
PAID_DELIVERED: "Paid", CLOSED: "Closed", REJECTED: "Rejected",
};
const totalSpend = vessel.purchaseOrders.filter(p => p.status === "CLOSED" || p.status === "PAID_DELIVERED")
.reduce((s, p) => s + Number(p.totalAmount), 0);
return (
<div className="max-w-5xl space-y-6">
<div className="flex items-center gap-2 text-sm text-neutral-500">
<Link href="/admin/vessels" className="hover:text-neutral-700">Vessels</Link>
<span>/</span>
<span className="text-neutral-900 font-medium">{vessel.name}</span>
</div>
<div className="flex items-start justify-between gap-4">
<div>
<div className="flex items-center gap-3 mb-1">
{vessel.imoNumber && <span className="font-mono text-xs text-neutral-500">IMO {vessel.imoNumber}</span>}
<span className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${vessel.isActive ? "bg-success-100 text-success-700" : "bg-neutral-100 text-neutral-500"}`}>
{vessel.isActive ? "Active" : "Inactive"}
</span>
</div>
<h1 className="text-2xl font-semibold text-neutral-900">{vessel.name}</h1>
{vessel.site && (
<p className="mt-1 text-sm text-neutral-500">
Home port: <Link href={`/admin/sites/${vessel.site.id}`} className="text-primary-600 hover:underline">{vessel.site.name}</Link>
</p>
)}
</div>
<Link href={`/po/new?vesselId=${vessel.id}`} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700">
+ Create PO
</Link>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="rounded-lg border border-neutral-200 bg-white px-5 py-4">
<p className="text-xs text-neutral-500 mb-1">Total POs</p>
<p className="text-2xl font-semibold text-neutral-900">{vessel.purchaseOrders.length}</p>
</div>
<div className="rounded-lg border border-neutral-200 bg-white px-5 py-4">
<p className="text-xs text-neutral-500 mb-1">Total Spend (closed)</p>
<p className="text-2xl font-semibold text-neutral-900">{formatCurrency(totalSpend)}</p>
</div>
</div>
<div className="rounded-lg border border-neutral-200 bg-white p-6">
<h2 className="text-sm font-semibold text-neutral-900 mb-4">Purchase Orders</h2>
{vessel.purchaseOrders.length === 0 ? (
<p className="text-sm text-neutral-400 italic">No POs yet.</p>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-neutral-200">
<th className="pb-2 text-left font-medium text-neutral-600">PO</th>
<th className="pb-2 text-left font-medium text-neutral-600 pl-4">Vendor</th>
<th className="pb-2 text-left font-medium text-neutral-600 pl-4">Status</th>
<th className="pb-2 text-right font-medium text-neutral-600 pl-4">Amount</th>
<th className="pb-2 text-right font-medium text-neutral-600 pl-4">Date</th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-100">
{vessel.purchaseOrders.map((po) => (
<tr key={po.id}>
<td className="py-2 pr-4"><Link href={`/po/${po.id}`} className="font-mono text-xs text-primary-600 hover:underline">{po.poNumber}</Link></td>
<td className="py-2 pl-4 text-neutral-600">{po.vendor?.name ?? <span className="italic text-neutral-400"></span>}</td>
<td className="py-2 pl-4 text-neutral-600">{STATUS_LABELS[po.status] ?? po.status}</td>
<td className="py-2 pl-4 text-right">{formatCurrency(Number(po.totalAmount))}</td>
<td className="py-2 pl-4 text-right text-neutral-500">{formatDate(po.createdAt)}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,122 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Trash2, ShoppingCart } from "lucide-react";
import { getCart, saveCart, clearCart, type CartItem } from "@/lib/cart";
import { formatCurrency } from "@/lib/utils";
import Link from "next/link";
export function CartView() {
const router = useRouter();
const [items, setItems] = useState<CartItem[]>([]);
const [mounted, setMounted] = useState(false);
useEffect(() => {
setItems(getCart());
setMounted(true);
const handler = () => setItems(getCart());
window.addEventListener("cart-updated", handler);
return () => window.removeEventListener("cart-updated", handler);
}, []);
function updateQty(idx: number, qty: number) {
const next = items.map((item, i) => i === idx ? { ...item, quantity: qty } : item);
setItems(next); saveCart(next);
}
function remove(idx: number) {
const next = items.filter((_, i) => i !== idx);
setItems(next); saveCart(next);
}
function createPO() {
// Encode cart into query params and navigate to new PO with prefill
const encoded = encodeURIComponent(JSON.stringify(items));
router.push(`/po/new?cart=${encoded}`);
}
if (!mounted) return null;
if (items.length === 0) {
return (
<div className="rounded-lg border border-neutral-200 bg-white p-12 text-center">
<ShoppingCart className="h-12 w-12 text-neutral-300 mx-auto mb-4" />
<p className="text-neutral-500 font-medium">Your cart is empty</p>
<p className="text-sm text-neutral-400 mt-1 mb-6">Browse Items or Vendors to add line items</p>
<div className="flex gap-3 justify-center">
<Link href="/admin/products" className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50">Browse Items</Link>
<Link href="/admin/vendors" className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50">Browse Vendors</Link>
</div>
</div>
);
}
const total = items.reduce((s, i) => s + i.quantity * i.unitPrice, 0);
return (
<div className="space-y-4">
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-neutral-50 border-b border-neutral-200">
<tr>
<th className="px-4 py-3 text-left font-medium text-neutral-600">Item</th>
<th className="px-4 py-3 text-left font-medium text-neutral-600">Vendor</th>
<th className="px-4 py-3 text-right font-medium text-neutral-600">Unit Price</th>
<th className="px-4 py-3 text-right font-medium text-neutral-600">Qty</th>
<th className="px-4 py-3 text-right font-medium text-neutral-600">Total</th>
<th className="px-4 py-3 w-8" />
</tr>
</thead>
<tbody className="divide-y divide-neutral-100">
{items.map((item, i) => (
<tr key={i}>
<td className="px-4 py-3">
<p className="font-medium text-neutral-900">{item.name}</p>
{item.description && <p className="text-xs text-neutral-500">{item.description}</p>}
</td>
<td className="px-4 py-3 text-neutral-600">{item.vendorName ?? <span className="italic text-neutral-400">Not specified</span>}</td>
<td className="px-4 py-3 text-right text-neutral-700">{item.unitPrice > 0 ? formatCurrency(item.unitPrice) : <span className="italic text-neutral-400">TBD</span>}</td>
<td className="px-4 py-3 text-right">
<input
type="number" min="1" step="any"
value={item.quantity}
onChange={(e) => updateQty(i, parseFloat(e.target.value) || 1)}
className="w-20 rounded border border-neutral-200 px-2 py-1 text-sm text-right"
/>
</td>
<td className="px-4 py-3 text-right font-medium text-neutral-900">
{item.unitPrice > 0 ? formatCurrency(item.quantity * item.unitPrice) : "—"}
</td>
<td className="px-4 py-3">
<button onClick={() => remove(i)} className="text-neutral-400 hover:text-danger"><Trash2 className="h-4 w-4" /></button>
</td>
</tr>
))}
</tbody>
<tfoot className="border-t border-neutral-200 bg-neutral-50">
<tr>
<td colSpan={4} className="px-4 py-3 text-right text-sm font-semibold text-neutral-900">Estimated Total (excl. GST)</td>
<td className="px-4 py-3 text-right text-sm font-semibold text-neutral-900">{formatCurrency(total)}</td>
<td />
</tr>
</tfoot>
</table>
</div>
<div className="flex items-center justify-between">
<button onClick={() => { clearCart(); setItems([]); }} className="text-sm text-danger-600 hover:underline">Clear cart</button>
<div className="flex gap-3">
<Link href="/admin/products" className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50">
+ Add more items
</Link>
<button onClick={createPO} className="rounded-lg bg-primary-600 px-5 py-2 text-sm font-semibold text-white hover:bg-primary-700">
Create Purchase Order
</button>
</div>
</div>
<p className="text-xs text-neutral-400">Prices shown are last known prices. Final amounts are set in the PO form.</p>
</div>
);
}

View file

@ -0,0 +1,16 @@
import type { Metadata } from "next";
import { CartView } from "./cart-view";
export const metadata: Metadata = { title: "Cart" };
export default function CartPage() {
return (
<div className="max-w-4xl">
<div className="mb-6">
<h1 className="text-2xl font-semibold text-neutral-900">Cart</h1>
<p className="text-sm text-neutral-500 mt-0.5">Review items and create a Purchase Order</p>
</div>
<CartView />
</div>
);
}

View file

@ -18,7 +18,11 @@ export async function confirmReceipt({
const po = await db.purchaseOrder.findUnique({
where: { id: poId },
include: { submitter: true },
include: {
submitter: true,
lineItems: true,
vessel: { include: { site: true } },
},
});
if (!po) return { error: "PO not found" };
if (!canPerformAction(po.status, "confirm_receipt", session.user.role)) {
@ -30,19 +34,28 @@ export async function confirmReceipt({
data: {
status: "CLOSED",
closedAt: new Date(),
receipt: notes
? { create: { storageKey: "", fileName: "no-file", notes } }
: undefined,
receipt: notes ? { create: { storageKey: "", fileName: "no-file", notes } } : undefined,
actions: {
create: {
actionType: "RECEIPT_CONFIRMED",
actorId: session.user.id,
note: notes ?? null,
},
create: { actionType: "RECEIPT_CONFIRMED", actorId: session.user.id, note: notes ?? null },
},
},
});
// Auto-update inventory: use PO siteId, fall back to vessel's home site
const siteId = (po as typeof po & { siteId?: string | null }).siteId ?? po.vessel?.site?.id ?? null;
if (siteId) {
for (const li of po.lineItems) {
if (!li.productId) continue;
const qty = Number(li.quantity);
await db.itemInventory.upsert({
where: { productId_siteId: { productId: li.productId, siteId } },
update: { quantity: { increment: qty } },
create: { productId: li.productId, siteId, quantity: qty },
});
}
revalidatePath(`/admin/sites/${siteId}`);
}
const [managers, accounts] = await Promise.all([
db.user.findMany({ where: { role: "MANAGER", isActive: true } }),
db.user.findMany({ where: { role: "ACCOUNTS", isActive: true } }),

View file

@ -0,0 +1,27 @@
import { auth } from "@/auth";
import { lookupGstin } from "@/lib/gst-lookup";
import { geocodePincode } from "@/lib/geo";
import { NextRequest, NextResponse } from "next/server";
export async function GET(req: NextRequest) {
const session = await auth();
if (!session?.user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const gstin = req.nextUrl.searchParams.get("gstin")?.trim().toUpperCase() ?? "";
if (!/^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z][1-9A-Z]Z[0-9A-Z]$/.test(gstin)) {
return NextResponse.json({ error: "Invalid GSTIN format" }, { status: 400 });
}
const result = await lookupGstin(gstin);
if ("error" in result) return NextResponse.json(result, { status: 422 });
// Geocode the pincode to get coordinates
let lat: number | null = null;
let lng: number | null = null;
if (result.pincode) {
const geo = await geocodePincode(result.pincode);
if (geo) { lat = geo.lat; lng = geo.lng; }
}
return NextResponse.json({ ...result, lat, lng });
}

View file

@ -0,0 +1,31 @@
"use client";
import { useState } from "react";
import { addToCart, type CartItem } from "@/lib/cart";
import { ShoppingCart, Check } from "lucide-react";
interface Props {
item: Omit<CartItem, "quantity">;
className?: string;
}
export function AddToCartButton({ item, className }: Props) {
const [added, setAdded] = useState(false);
function handle() {
addToCart({ ...item, quantity: 1 });
setAdded(true);
setTimeout(() => setAdded(false), 1500);
}
return (
<button
type="button"
onClick={handle}
className={className ?? "flex items-center gap-1.5 rounded-lg border border-primary-300 bg-primary-50 px-3 py-1.5 text-sm font-medium text-primary-700 hover:bg-primary-100 transition-colors"}
>
{added ? <Check className="h-3.5 w-3.5" /> : <ShoppingCart className="h-3.5 w-3.5" />}
{added ? "Added" : "Add to Cart"}
</button>
);
}

View file

@ -17,6 +17,9 @@ import {
Anchor,
Package,
Upload,
MapPin,
ShoppingCart,
BarChart3,
} from "lucide-react";
import type { Role } from "@prisma/client";
@ -35,16 +38,20 @@ const NAV_ITEMS: NavItem[] = [
{ href: "/approvals", label: "Approvals", icon: CheckSquare, roles: ["MANAGER", "SUPERUSER"] },
{ href: "/payments", label: "Payments", icon: CreditCard, roles: ["ACCOUNTS"] },
{ href: "/history", label: "History", icon: History, roles: ["MANAGER", "SUPERUSER", "ACCOUNTS", "AUDITOR", "ADMIN"] },
{ href: "/admin/vendors", label: "Vendors", icon: Store, roles: ["MANAGER", "ACCOUNTS"] },
{ href: "/admin/products", label: "Items", icon: Package, roles: ["MANAGER"] },
];
const INVENTORY_ITEMS: NavItem[] = [
{ href: "/admin/vendors", label: "Vendors", icon: Store, roles: ["MANAGER", "ACCOUNTS", "ADMIN"] },
{ href: "/admin/products", label: "Items", icon: Package, roles: ["MANAGER", "ADMIN"] },
{ href: "/admin/vessels", label: "Vessels", icon: Ship, roles: ["MANAGER", "ADMIN"] },
{ href: "/admin/sites", label: "Sites", icon: MapPin, roles: ["MANAGER", "ADMIN"] },
{ href: "/inventory/cart", label: "Cart", icon: ShoppingCart, roles: ["MANAGER", "SUPERUSER", "TECHNICAL", "MANNING"] },
];
const ADMIN_ITEMS: NavItem[] = [
{ href: "/admin/users", label: "Users", icon: Users },
{ href: "/admin/vessels", label: "Vessels", icon: Ship },
{ href: "/admin/accounts", label: "Accounts", icon: Building2 },
{ href: "/admin/vendors", label: "Vendors", icon: Store },
{ href: "/admin/products", label: "Products", icon: Package },
{ href: "/reports", label: "Reports", icon: BarChart3 },
];
export function Sidebar({ userRole }: { userRole: Role }) {
@ -54,6 +61,9 @@ export function Sidebar({ userRole }: { userRole: Role }) {
const visible = NAV_ITEMS.filter(
(item) => !item.roles || item.roles.includes(userRole)
);
const visibleInventory = INVENTORY_ITEMS.filter(
(item) => !item.roles || item.roles.includes(userRole)
);
return (
<aside className="flex h-screen w-60 shrink-0 flex-col border-r border-neutral-200 bg-white">
@ -69,12 +79,21 @@ export function Sidebar({ userRole }: { userRole: Role }) {
<NavLink key={item.href} item={item} pathname={pathname} />
))}
{visibleInventory.length > 0 && (
<>
<div className="pt-4 pb-1 px-3">
<p className="text-xs font-semibold text-neutral-400 uppercase tracking-wider">Inventory</p>
</div>
{visibleInventory.map((item) => (
<NavLink key={item.href} item={item} pathname={pathname} />
))}
</>
)}
{isAdmin && (
<>
<div className="pt-4 pb-1 px-3">
<p className="text-xs font-semibold text-neutral-400 uppercase tracking-wider">
Administration
</p>
<p className="text-xs font-semibold text-neutral-400 uppercase tracking-wider">Administration</p>
</div>
{ADMIN_ITEMS.map((item) => (
<NavLink key={item.href} item={item} pathname={pathname} />
@ -87,8 +106,7 @@ export function Sidebar({ userRole }: { userRole: Role }) {
}
function NavLink({ item, pathname }: { item: NavItem; pathname: string }) {
const isActive =
pathname === item.href || pathname.startsWith(item.href + "/");
const isActive = pathname === item.href || pathname.startsWith(item.href + "/");
const Icon = item.icon;
return (

View file

@ -0,0 +1,44 @@
export type CartItem = {
productId: string;
name: string;
description?: string;
quantity: number;
unit: string;
unitPrice: number;
vendorId?: string;
vendorName?: string;
};
const KEY = "pelagia_cart";
export function getCart(): CartItem[] {
if (typeof window === "undefined") return [];
try { return JSON.parse(localStorage.getItem(KEY) ?? "[]"); } catch { return []; }
}
export function saveCart(items: CartItem[]) {
if (typeof window === "undefined") return;
localStorage.setItem(KEY, JSON.stringify(items));
}
export function addToCart(item: CartItem) {
const cart = getCart();
const existing = cart.findIndex((c) => c.productId === item.productId && c.vendorId === item.vendorId);
if (existing >= 0) {
cart[existing].quantity += item.quantity;
} else {
cart.push(item);
}
saveCart(cart);
window.dispatchEvent(new Event("cart-updated"));
}
export function removeFromCart(productId: string, vendorId?: string) {
saveCart(getCart().filter((c) => !(c.productId === productId && c.vendorId === vendorId)));
window.dispatchEvent(new Event("cart-updated"));
}
export function clearCart() {
saveCart([]);
window.dispatchEvent(new Event("cart-updated"));
}

View file

@ -0,0 +1,30 @@
/** Haversine distance between two lat/lng points, in kilometres. */
export function distanceKm(
lat1: number, lon1: number,
lat2: number, lon2: number
): number {
const R = 6371;
const dLat = ((lat2 - lat1) * Math.PI) / 180;
const dLon = ((lon2 - lon1) * Math.PI) / 180;
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos((lat1 * Math.PI) / 180) *
Math.cos((lat2 * Math.PI) / 180) *
Math.sin(dLon / 2) ** 2;
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
export function formatDistance(km: number): string {
return km < 1 ? `${Math.round(km * 1000)} m` : `${km.toFixed(0)} km`;
}
/** Geocode an Indian pincode to lat/lng using Nominatim (free, no key). */
export async function geocodePincode(pincode: string): Promise<{ lat: number; lng: number } | null> {
try {
const url = `https://nominatim.openstreetmap.org/search?postalcode=${encodeURIComponent(pincode)}&countrycodes=in&format=json&limit=1`;
const res = await fetch(url, { headers: { "User-Agent": "PelagiaPortal/1.0" } });
const data = await res.json();
if (data[0]) return { lat: parseFloat(data[0].lat), lng: parseFloat(data[0].lon) };
} catch { /* ignore */ }
return null;
}

View file

@ -0,0 +1,61 @@
/**
* AbhiAPI GST verification.
* Env: ABHIAPI_KEY
*/
export type GstLookupResult = {
legalName: string;
tradeName: string;
address: string;
state: string;
pincode: string;
status: string;
businessType: string;
};
const STATE_CODES: Record<string, string> = {
"01": "Jammu & Kashmir", "02": "Himachal Pradesh", "03": "Punjab",
"04": "Chandigarh", "05": "Uttarakhand", "06": "Haryana",
"07": "Delhi", "08": "Rajasthan", "09": "Uttar Pradesh",
"10": "Bihar", "11": "Sikkim", "12": "Arunachal Pradesh",
"13": "Nagaland", "14": "Manipur", "15": "Mizoram",
"16": "Tripura", "17": "Meghalaya", "18": "Assam",
"19": "West Bengal", "20": "Jharkhand", "21": "Odisha",
"22": "Chhattisgarh", "23": "Madhya Pradesh", "24": "Gujarat",
"26": "Dadra & Nagar Haveli", "27": "Maharashtra", "28": "Andhra Pradesh",
"29": "Karnataka", "30": "Goa", "31": "Lakshadweep",
"32": "Kerala", "33": "Tamil Nadu", "34": "Puducherry",
"35": "Andaman & Nicobar", "36": "Telangana", "37": "Andhra Pradesh (new)",
};
export function parseGstinState(gstin: string): string {
return STATE_CODES[gstin.substring(0, 2)] ?? "Unknown";
}
export async function lookupGstin(gstin: string): Promise<GstLookupResult | { error: string }> {
const apiKey = process.env.ABHIAPI_KEY;
if (!apiKey) return { error: "GST lookup API key not configured (ABHIAPI_KEY)" };
try {
const res = await fetch(`https://api.abhiapi.com/gst/search?gstin=${encodeURIComponent(gstin)}`, {
headers: { "x-api-key": apiKey },
next: { revalidate: 3600 },
});
const json = await res.json();
if (!res.ok || !json.success) {
return { error: json.message ?? "GST lookup failed" };
}
const d = json.data;
return {
legalName: d.legalName ?? d.lgnm ?? "",
tradeName: d.tradeName ?? d.tradeNam ?? d.legalName ?? "",
address: d.address ?? d.pradr?.adr ?? "",
state: d.state ?? parseGstinState(gstin),
pincode: d.pincode ?? "",
status: d.status ?? d.sts ?? "Unknown",
businessType: d.businessType ?? d.dty ?? "",
};
} catch (e) {
return { error: String(e) };
}
}

View file

@ -17,7 +17,8 @@ export type Permission =
| "manage_users"
| "manage_vendors"
| "manage_vessels_accounts"
| "manage_products";
| "manage_products"
| "manage_sites";
const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
TECHNICAL: ["create_po", "submit_po", "edit_own_draft_po", "view_own_pos", "confirm_receipt"],
@ -37,6 +38,7 @@ const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
"export_reports",
"manage_vendors",
"manage_products",
"manage_sites",
],
SUPERUSER: [
"create_po",
@ -63,6 +65,7 @@ const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
"manage_vendors",
"manage_vessels_accounts",
"manage_products",
"manage_sites",
],
};

View file

@ -0,0 +1,78 @@
-- AlterTable
ALTER TABLE "PurchaseOrder" ADD COLUMN "siteId" TEXT;
-- AlterTable
ALTER TABLE "Vendor" ADD COLUMN "latitude" DOUBLE PRECISION,
ADD COLUMN "longitude" DOUBLE PRECISION;
-- AlterTable
ALTER TABLE "Vessel" ADD COLUMN "siteId" TEXT;
-- CreateTable
CREATE TABLE "Site" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"code" TEXT NOT NULL,
"address" TEXT,
"latitude" DOUBLE PRECISION,
"longitude" DOUBLE PRECISION,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Site_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ItemInventory" (
"id" TEXT NOT NULL,
"quantity" DECIMAL(10,3) NOT NULL,
"updatedAt" TIMESTAMP(3) NOT NULL,
"productId" TEXT NOT NULL,
"siteId" TEXT NOT NULL,
CONSTRAINT "ItemInventory_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ItemConsumption" (
"id" TEXT NOT NULL,
"date" DATE NOT NULL,
"quantity" DECIMAL(10,3) NOT NULL,
"note" TEXT,
"productId" TEXT NOT NULL,
"siteId" TEXT NOT NULL,
"recordedById" TEXT NOT NULL,
CONSTRAINT "ItemConsumption_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Site_code_key" ON "Site"("code");
-- CreateIndex
CREATE UNIQUE INDEX "ItemInventory_productId_siteId_key" ON "ItemInventory"("productId", "siteId");
-- CreateIndex
CREATE UNIQUE INDEX "ItemConsumption_productId_siteId_date_key" ON "ItemConsumption"("productId", "siteId", "date");
-- AddForeignKey
ALTER TABLE "Vessel" ADD CONSTRAINT "Vessel_siteId_fkey" FOREIGN KEY ("siteId") REFERENCES "Site"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ItemInventory" ADD CONSTRAINT "ItemInventory_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ItemInventory" ADD CONSTRAINT "ItemInventory_siteId_fkey" FOREIGN KEY ("siteId") REFERENCES "Site"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ItemConsumption" ADD CONSTRAINT "ItemConsumption_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ItemConsumption" ADD CONSTRAINT "ItemConsumption_siteId_fkey" FOREIGN KEY ("siteId") REFERENCES "Site"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ItemConsumption" ADD CONSTRAINT "ItemConsumption_recordedById_fkey" FOREIGN KEY ("recordedById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PurchaseOrder" ADD CONSTRAINT "PurchaseOrder_siteId_fkey" FOREIGN KEY ("siteId") REFERENCES "Site"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View file

@ -58,9 +58,27 @@ model User {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
submittedPOs PurchaseOrder[] @relation("Submitter")
submittedPOs PurchaseOrder[] @relation("Submitter")
actions POAction[]
notifications Notification[]
consumption ItemConsumption[]
}
model Site {
id String @id @default(cuid())
name String
code String @unique
address String?
latitude Float?
longitude Float?
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
vessels Vessel[]
purchaseOrders PurchaseOrder[]
inventory ItemInventory[]
consumption ItemConsumption[]
}
model Vessel {
@ -69,6 +87,9 @@ model Vessel {
imoNumber String? @unique
isActive Boolean @default(true)
siteId String?
site Site? @relation(fields: [siteId], references: [id])
purchaseOrders PurchaseOrder[]
}
@ -83,14 +104,16 @@ model Account {
}
model Vendor {
id String @id @default(cuid())
id String @id @default(cuid())
name String
vendorId String? @unique
vendorId String? @unique
address String?
gstin String?
contactName String?
contactMobile String?
contactEmail String?
latitude Float?
longitude Float?
isVerified Boolean @default(false)
isActive Boolean @default(true)
createdAt DateTime @default(now())
@ -114,6 +137,8 @@ model Product {
lineItems POLineItem[]
vendorPrices ProductVendorPrice[]
inventory ItemInventory[]
consumption ItemConsumption[]
}
model ProductVendorPrice {
@ -129,6 +154,35 @@ model ProductVendorPrice {
@@unique([productId, vendorId])
}
model ItemInventory {
id String @id @default(cuid())
quantity Decimal @db.Decimal(10, 3)
updatedAt DateTime @updatedAt
productId String
product Product @relation(fields: [productId], references: [id])
siteId String
site Site @relation(fields: [siteId], references: [id])
@@unique([productId, siteId])
}
model ItemConsumption {
id String @id @default(cuid())
date DateTime @db.Date
quantity Decimal @db.Decimal(10, 3)
note String?
productId String
product Product @relation(fields: [productId], references: [id])
siteId String
site Site @relation(fields: [siteId], references: [id])
recordedById String
recordedBy User @relation(fields: [recordedById], references: [id])
@@unique([productId, siteId, date])
}
model PurchaseOrder {
id String @id @default(cuid())
poNumber String @unique
@ -166,6 +220,8 @@ model PurchaseOrder {
account Account @relation(fields: [accountId], references: [id])
vendorId String?
vendor Vendor? @relation(fields: [vendorId], references: [id])
siteId String?
site Site? @relation(fields: [siteId], references: [id])
lineItems POLineItem[]
documents PODocument[]
@ -229,11 +285,11 @@ model Receipt {
}
model Notification {
id String @id @default(cuid())
id String @id @default(cuid())
subject String
body String
sentAt DateTime @default(now())
status String @default("sent")
body String
sentAt DateTime @default(now())
status String @default("sent")
poId String?
po PurchaseOrder? @relation(fields: [poId], references: [id])