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:
parent
1c7d0b8901
commit
bea798324c
25 changed files with 1609 additions and 180 deletions
1
App/pelagia-portal/.claude/scheduled_tasks.lock
Normal file
1
App/pelagia-portal/.claude/scheduled_tasks.lock
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"sessionId":"09360cd7-ccc6-4301-988d-d01a37fd1072","pid":23908,"acquiredAt":1777480375077}
|
||||
89
App/pelagia-portal/CLAUDE.md
Normal file
89
App/pelagia-portal/CLAUDE.md
Normal 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
|
||||
```
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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({
|
||||
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 } } },
|
||||
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 } } } },
|
||||
},
|
||||
});
|
||||
}),
|
||||
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>
|
||||
<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>
|
||||
{canManage && (
|
||||
<ToggleProductButton product={{
|
||||
id: product.id,
|
||||
code: product.code,
|
||||
name: product.name,
|
||||
description: product.description,
|
||||
isActive: product.isActive,
|
||||
}} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Price summary */}
|
||||
{product.vendorPrices.length > 0 && (
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{/* 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>
|
||||
<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>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Vendors that carry this item */}
|
||||
{/* 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>
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
218
App/pelagia-portal/app/(portal)/admin/sites/[id]/page.tsx
Normal file
218
App/pelagia-portal/app/(portal)/admin/sites/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
97
App/pelagia-portal/app/(portal)/admin/sites/actions.ts
Normal file
97
App/pelagia-portal/app/(portal)/admin/sites/actions.ts
Normal 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 };
|
||||
}
|
||||
90
App/pelagia-portal/app/(portal)/admin/sites/page.tsx
Normal file
90
App/pelagia-portal/app/(portal)/admin/sites/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
114
App/pelagia-portal/app/(portal)/admin/sites/site-form.tsx
Normal file
114
App/pelagia-portal/app/(portal)/admin/sites/site-form.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
<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">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">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">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>
|
||||
<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" />
|
||||
</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>
|
||||
|
|
|
|||
117
App/pelagia-portal/app/(portal)/admin/vessels/[id]/page.tsx
Normal file
117
App/pelagia-portal/app/(portal)/admin/vessels/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
122
App/pelagia-portal/app/(portal)/inventory/cart/cart-view.tsx
Normal file
122
App/pelagia-portal/app/(portal)/inventory/cart/cart-view.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
App/pelagia-portal/app/(portal)/inventory/cart/page.tsx
Normal file
16
App/pelagia-portal/app/(portal)/inventory/cart/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 } }),
|
||||
|
|
|
|||
27
App/pelagia-portal/app/api/gst/route.ts
Normal file
27
App/pelagia-portal/app/api/gst/route.ts
Normal 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 });
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
|
|
|
|||
44
App/pelagia-portal/lib/cart.ts
Normal file
44
App/pelagia-portal/lib/cart.ts
Normal 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"));
|
||||
}
|
||||
30
App/pelagia-portal/lib/geo.ts
Normal file
30
App/pelagia-portal/lib/geo.ts
Normal 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;
|
||||
}
|
||||
61
App/pelagia-portal/lib/gst-lookup.ts
Normal file
61
App/pelagia-portal/lib/gst-lookup.ts
Normal 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) };
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
],
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -61,6 +61,24 @@ model User {
|
|||
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[]
|
||||
}
|
||||
|
||||
|
|
@ -91,6 +112,8 @@ model Vendor {
|
|||
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[]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue