Compare commits
10 commits
9bbc97b9bd
...
b5a5097ab5
| Author | SHA1 | Date | |
|---|---|---|---|
| b5a5097ab5 | |||
| 025b932f70 | |||
| 2c912caedb | |||
| 2057fc2d8d | |||
| 6351eaa5e9 | |||
| 478f1d1f9c | |||
| 2c364f95e5 | |||
| 7b498a91f8 | |||
| 80fa1ea63c | |||
| 982a114eb5 |
11 changed files with 478 additions and 97 deletions
|
|
@ -5,9 +5,9 @@ import { notFound, redirect } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { formatCurrency, formatDate } from "@/lib/utils";
|
import { formatCurrency, formatDate } from "@/lib/utils";
|
||||||
import { distanceKm, formatDistance } from "@/lib/geo";
|
import { distanceKm, formatDistance } from "@/lib/geo";
|
||||||
import { ToggleProductButton } from "../product-form";
|
import { ToggleProductButton, EditProductButton } from "../product-form";
|
||||||
import { AddToCartButton } from "@/components/inventory/add-to-cart-button";
|
import { AddToCartButton } from "@/components/inventory/add-to-cart-button";
|
||||||
import { ItemPriceChart } from "./item-price-chart";
|
import { ItemPriceChart } from "@/app/(portal)/inventory/items/[id]/item-price-chart";
|
||||||
import { SiteSelect } from "@/components/inventory/site-select";
|
import { SiteSelect } from "@/components/inventory/site-select";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
|
@ -22,7 +22,7 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||||
return { title: product?.name ?? "Item Detail" };
|
return { title: product?.name ?? "Item Detail" };
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function ProductDetailPage({ params, searchParams }: Props) {
|
export default async function AdminProductDetailPage({ params, searchParams }: Props) {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session?.user) redirect("/login");
|
if (!session?.user) redirect("/login");
|
||||||
if (!hasPermission(session.user.role, "manage_products")) redirect("/dashboard");
|
if (!hasPermission(session.user.role, "manage_products")) redirect("/dashboard");
|
||||||
|
|
@ -47,19 +47,19 @@ export default async function ProductDetailPage({ params, searchParams }: Props)
|
||||||
inventory: { include: { site: { select: { id: true, name: 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 } }),
|
db.site.findMany({
|
||||||
|
where: { isActive: true, latitude: { not: null }, longitude: { not: null } },
|
||||||
|
select: { id: true, name: true, latitude: true, longitude: true },
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!product) notFound();
|
if (!product) notFound();
|
||||||
|
|
||||||
const canManage = session.user.role === "ADMIN";
|
|
||||||
const selectedSite = siteId ? sites.find((s) => s.id === siteId) ?? null : null;
|
const selectedSite = siteId ? sites.find((s) => s.id === siteId) ?? null : null;
|
||||||
|
|
||||||
const prices = product.vendorPrices.map((vp) => Number(vp.price));
|
const prices = product.vendorPrices.map((vp) => Number(vp.price));
|
||||||
const minPrice = prices.length > 0 ? Math.min(...prices) : null;
|
const minPrice = prices.length > 0 ? Math.min(...prices) : null;
|
||||||
const maxPrice = prices.length > 0 ? Math.max(...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 };
|
type EnrichedVp = typeof product.vendorPrices[0] & { distanceKm: number | null };
|
||||||
const enriched: EnrichedVp[] = product.vendorPrices.map((vp) => {
|
const enriched: EnrichedVp[] = product.vendorPrices.map((vp) => {
|
||||||
let dist: number | null = null;
|
let dist: number | null = null;
|
||||||
|
|
@ -69,7 +69,6 @@ export default async function ProductDetailPage({ params, searchParams }: Props)
|
||||||
return { ...vp, distanceKm: dist };
|
return { ...vp, distanceKm: dist };
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sort: if site selected, sort by distance first; otherwise by price
|
|
||||||
if (selectedSite) {
|
if (selectedSite) {
|
||||||
enriched.sort((a, b) => {
|
enriched.sort((a, b) => {
|
||||||
if (a.distanceKm !== null && b.distanceKm !== null) return a.distanceKm - b.distanceKm;
|
if (a.distanceKm !== null && b.distanceKm !== null) return a.distanceKm - b.distanceKm;
|
||||||
|
|
@ -86,12 +85,14 @@ export default async function ProductDetailPage({ params, searchParams }: Props)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-6xl space-y-6">
|
<div className="max-w-6xl space-y-6">
|
||||||
|
{/* Breadcrumb */}
|
||||||
<div className="flex items-center gap-2 text-sm text-neutral-500">
|
<div className="flex items-center gap-2 text-sm text-neutral-500">
|
||||||
<Link href="/admin/products" className="hover:text-neutral-700">Items</Link>
|
<Link href="/admin/products" className="hover:text-neutral-700">Items</Link>
|
||||||
<span>/</span>
|
<span>/</span>
|
||||||
<span className="text-neutral-900 font-medium">{product.name}</span>
|
<span className="text-neutral-900 font-medium">{product.name}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Header with edit controls */}
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-3 mb-1">
|
<div className="flex items-center gap-3 mb-1">
|
||||||
|
|
@ -105,7 +106,10 @@ export default async function ProductDetailPage({ params, searchParams }: Props)
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 items-start">
|
<div className="flex gap-2 items-start">
|
||||||
<AddToCartButton item={{ productId: product.id, name: product.name, description: product.description ?? undefined, unit: "pc", unitPrice: minPrice ?? 0 }} />
|
<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 }} />}
|
<EditProductButton
|
||||||
|
product={{ id: product.id, code: product.code, name: product.name, description: product.description, isActive: product.isActive }}
|
||||||
|
/>
|
||||||
|
<ToggleProductButton product={{ id: product.id, code: product.code, name: product.name, description: product.description, isActive: product.isActive }} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -129,10 +133,8 @@ export default async function ProductDetailPage({ params, searchParams }: Props)
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Price chart */}
|
|
||||||
{priceChartData.length > 1 && <ItemPriceChart data={priceChartData} />}
|
{priceChartData.length > 1 && <ItemPriceChart data={priceChartData} />}
|
||||||
|
|
||||||
{/* Site filter for distance */}
|
|
||||||
{sites.length > 0 && (
|
{sites.length > 0 && (
|
||||||
<SiteSelect
|
<SiteSelect
|
||||||
sites={sites.map((s) => ({ id: s.id, name: s.name }))}
|
sites={sites.map((s) => ({ id: s.id, name: s.name }))}
|
||||||
|
|
@ -150,7 +152,7 @@ export default async function ProductDetailPage({ params, searchParams }: Props)
|
||||||
{selectedSite && <span className="ml-2 text-primary-600 font-normal text-xs">sorted by distance from {selectedSite.name}</span>}
|
{selectedSite && <span className="ml-2 text-primary-600 font-normal text-xs">sorted by distance from {selectedSite.name}</span>}
|
||||||
</h2>
|
</h2>
|
||||||
{enriched.length === 0 ? (
|
{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>
|
<p className="text-sm text-neutral-400 italic">No vendor pricing on record yet.</p>
|
||||||
) : (
|
) : (
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
|
|
@ -205,7 +207,6 @@ export default async function ProductDetailPage({ params, searchParams }: Props)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Inventory by site */}
|
|
||||||
{product.inventory.length > 0 && (
|
{product.inventory.length > 0 && (
|
||||||
<div className="rounded-lg border border-neutral-200 bg-white p-6">
|
<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>
|
<h2 className="text-sm font-semibold text-neutral-900 mb-4">Stock by Site</h2>
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,34 @@ export async function createProduct(formData: FormData): Promise<ActionResult> {
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateProduct(formData: FormData): Promise<ActionResult> {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user || !hasPermission(session.user.role, "manage_products")) {
|
||||||
|
return { error: "Forbidden" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = formData.get("id") as string;
|
||||||
|
if (!id) return { error: "Product ID required" };
|
||||||
|
|
||||||
|
const parsed = z.object({
|
||||||
|
code: z.string().min(1).max(50),
|
||||||
|
name: z.string().min(1).max(200),
|
||||||
|
description: z.string().optional(),
|
||||||
|
}).safeParse({
|
||||||
|
code: formData.get("code"),
|
||||||
|
name: formData.get("name"),
|
||||||
|
description: formData.get("description") || undefined,
|
||||||
|
});
|
||||||
|
if (!parsed.success) return { error: parsed.error.errors[0].message };
|
||||||
|
|
||||||
|
const conflict = await db.product.findFirst({ where: { code: parsed.data.code, id: { not: id } } });
|
||||||
|
if (conflict) return { error: "Another product already uses that code." };
|
||||||
|
|
||||||
|
await db.product.update({ where: { id }, data: parsed.data });
|
||||||
|
revalidatePath("/admin/products");
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteProduct(id: string): Promise<ActionResult> {
|
export async function deleteProduct(id: string): Promise<ActionResult> {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session?.user || !hasPermission(session.user.role, "manage_products")) return { error: "Forbidden" };
|
if (!session?.user || !hasPermission(session.user.role, "manage_products")) return { error: "Forbidden" };
|
||||||
|
|
|
||||||
|
|
@ -20,11 +20,12 @@ export default async function AdminProductsPage() {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const canManage = hasPermission(session.user.role, "manage_products") && session.user.role === "ADMIN";
|
const canManage = hasPermission(session.user.role, "manage_products");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ProductsTable
|
<ProductsTable
|
||||||
canManage={canManage}
|
canManage={canManage}
|
||||||
|
detailBase="/admin/products"
|
||||||
products={products.map((p) => ({
|
products={products.map((p) => ({
|
||||||
id: p.id,
|
id: p.id,
|
||||||
code: p.code,
|
code: p.code,
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { AdminDialog } from "@/components/ui/admin-dialog";
|
import { AdminDialog } from "@/components/ui/admin-dialog";
|
||||||
import { createProduct, toggleProductActive } from "./actions";
|
import { createProduct, updateProduct, toggleProductActive } from "./actions";
|
||||||
|
|
||||||
type ProductRow = {
|
type ProductRow = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -18,10 +18,9 @@ function ProductFormFields({ product }: { product?: ProductRow }) {
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-neutral-700 mb-1">Product Code *</label>
|
<label className="block text-xs font-medium text-neutral-700 mb-1">Product Code *</label>
|
||||||
<input name="code" defaultValue={product?.code} required disabled={!!product}
|
<input name="code" defaultValue={product?.code} required
|
||||||
className="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 disabled:bg-neutral-50 disabled:text-neutral-400"
|
className="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"
|
||||||
placeholder="e.g. FUEL-OIL-001" />
|
placeholder="e.g. FUEL-OIL-001" />
|
||||||
{product && <p className="mt-1 text-xs text-neutral-400">Product code cannot be changed after creation.</p>}
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-neutral-700 mb-1">Name *</label>
|
<label className="block text-xs font-medium text-neutral-700 mb-1">Name *</label>
|
||||||
|
|
@ -78,6 +77,63 @@ export function AddProductButton() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function EditProductButton({
|
||||||
|
product,
|
||||||
|
open: controlledOpen,
|
||||||
|
onOpenChange,
|
||||||
|
}: {
|
||||||
|
product: ProductRow;
|
||||||
|
open?: boolean;
|
||||||
|
onOpenChange?: (v: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [internalOpen, setInternalOpen] = useState(false);
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const isControlled = controlledOpen !== undefined;
|
||||||
|
const open = isControlled ? controlledOpen : internalOpen;
|
||||||
|
const setOpen = isControlled ? (onOpenChange ?? (() => {})) : setInternalOpen;
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault();
|
||||||
|
setPending(true);
|
||||||
|
setError("");
|
||||||
|
const fd = new FormData(e.currentTarget);
|
||||||
|
fd.set("id", product.id);
|
||||||
|
const result = await updateProduct(fd);
|
||||||
|
if ("error" in result) { setError(result.error); setPending(false); }
|
||||||
|
else { setPending(false); setOpen(false); router.refresh(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{!isControlled && (
|
||||||
|
<button onClick={() => setOpen(true)}
|
||||||
|
className="rounded border border-primary-200 bg-primary-50 px-2.5 py-1 text-xs font-medium text-primary-700 hover:bg-primary-100 transition-colors">
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<AdminDialog title={`Edit — ${product.name}`} open={open} onClose={() => setOpen(false)}>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<ProductFormFields product={product} />
|
||||||
|
{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"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</AdminDialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function ToggleProductButton({ product }: { product: ProductRow }) {
|
export function ToggleProductButton({ product }: { product: ProductRow }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [toggling, setToggling] = useState(false);
|
const [toggling, setToggling] = useState(false);
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { useState } from "react";
|
||||||
import { formatCurrency, formatDate } from "@/lib/utils";
|
import { formatCurrency, formatDate } from "@/lib/utils";
|
||||||
import { useTableControls } from "@/components/ui/use-table-controls";
|
import { useTableControls } from "@/components/ui/use-table-controls";
|
||||||
import { TableControls, SortableTh } from "@/components/ui/table-controls";
|
import { TableControls, SortableTh } from "@/components/ui/table-controls";
|
||||||
import { AddProductButton } from "./product-form";
|
import { AddProductButton, EditProductButton } from "./product-form";
|
||||||
import { RowActionsMenu, RowActionsItem, RowActionsDestructiveItem, RowActionsSeparator } from "@/components/ui/row-actions-menu";
|
import { RowActionsMenu, RowActionsItem, RowActionsDestructiveItem, RowActionsSeparator } from "@/components/ui/row-actions-menu";
|
||||||
import { DeleteConfirmDialog } from "@/components/ui/delete-confirm-dialog";
|
import { DeleteConfirmDialog } from "@/components/ui/delete-confirm-dialog";
|
||||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||||
|
|
@ -26,12 +26,14 @@ export type ProductRow = {
|
||||||
const CHIPS = ["Active", "Inactive"];
|
const CHIPS = ["Active", "Inactive"];
|
||||||
|
|
||||||
function ProductActionsMenu({ product }: { product: ProductRow }) {
|
function ProductActionsMenu({ product }: { product: ProductRow }) {
|
||||||
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
const [toggleOpen, setToggleOpen] = useState(false);
|
const [toggleOpen, setToggleOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<RowActionsMenu>
|
<RowActionsMenu>
|
||||||
|
<RowActionsItem onClick={() => setEditOpen(true)}>Edit</RowActionsItem>
|
||||||
<RowActionsItem onClick={() => setToggleOpen(true)}>
|
<RowActionsItem onClick={() => setToggleOpen(true)}>
|
||||||
{product.isActive ? "Deactivate" : "Activate"}
|
{product.isActive ? "Deactivate" : "Activate"}
|
||||||
</RowActionsItem>
|
</RowActionsItem>
|
||||||
|
|
@ -39,6 +41,11 @@ function ProductActionsMenu({ product }: { product: ProductRow }) {
|
||||||
<RowActionsDestructiveItem onClick={() => setDeleteOpen(true)}>Delete</RowActionsDestructiveItem>
|
<RowActionsDestructiveItem onClick={() => setDeleteOpen(true)}>Delete</RowActionsDestructiveItem>
|
||||||
</RowActionsMenu>
|
</RowActionsMenu>
|
||||||
|
|
||||||
|
<EditProductButton
|
||||||
|
product={{ id: product.id, code: product.code, name: product.name, description: product.description, isActive: product.isActive }}
|
||||||
|
open={editOpen}
|
||||||
|
onOpenChange={setEditOpen}
|
||||||
|
/>
|
||||||
<DeleteConfirmDialog
|
<DeleteConfirmDialog
|
||||||
open={deleteOpen}
|
open={deleteOpen}
|
||||||
onOpenChange={setDeleteOpen}
|
onOpenChange={setDeleteOpen}
|
||||||
|
|
@ -60,9 +67,11 @@ function ProductActionsMenu({ product }: { product: ProductRow }) {
|
||||||
export function ProductsTable({
|
export function ProductsTable({
|
||||||
products,
|
products,
|
||||||
canManage,
|
canManage,
|
||||||
|
detailBase = "/inventory/items",
|
||||||
}: {
|
}: {
|
||||||
products: ProductRow[];
|
products: ProductRow[];
|
||||||
canManage: boolean;
|
canManage: boolean;
|
||||||
|
detailBase?: string;
|
||||||
}) {
|
}) {
|
||||||
const { search, setSearch, sortKey, sortDir, toggleSort, activeFilters, toggleFilter, filtered } =
|
const { search, setSearch, sortKey, sortDir, toggleSort, activeFilters, toggleFilter, filtered } =
|
||||||
useTableControls<ProductRow>({
|
useTableControls<ProductRow>({
|
||||||
|
|
@ -128,7 +137,7 @@ export function ProductsTable({
|
||||||
<tr key={product.id} className="hover:bg-neutral-50">
|
<tr key={product.id} className="hover:bg-neutral-50">
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<Link
|
<Link
|
||||||
href={`/admin/products/${product.id}`}
|
href={`${detailBase}/${product.id}`}
|
||||||
className="font-medium text-primary-600 hover:underline"
|
className="font-medium text-primary-600 hover:underline"
|
||||||
>
|
>
|
||||||
{product.name}
|
{product.name}
|
||||||
|
|
|
||||||
21
App/app/(portal)/inventory/items/[id]/item-price-chart.tsx
Normal file
21
App/app/(portal)/inventory/items/[id]/item-price-chart.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid } from "recharts";
|
||||||
|
import { formatCurrency } from "@/lib/utils";
|
||||||
|
|
||||||
|
export function ItemPriceChart({ data }: { data: { vendor: string; price: number }[] }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-neutral-200 bg-white p-5">
|
||||||
|
<p className="text-sm font-semibold text-neutral-900 mb-4">Price Comparison by Vendor</p>
|
||||||
|
<ResponsiveContainer width="100%" height={200}>
|
||||||
|
<BarChart data={data} margin={{ left: 8, right: 16 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" vertical={false} />
|
||||||
|
<XAxis dataKey="vendor" tick={{ fontSize: 11 }} />
|
||||||
|
<YAxis tick={{ fontSize: 11 }} tickFormatter={(v) => `₹${(v / 1000).toFixed(0)}k`} />
|
||||||
|
<Tooltip formatter={(v: number) => [formatCurrency(v), "Price"]} />
|
||||||
|
<Bar dataKey="price" fill="#2563eb" radius={[3, 3, 0, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
223
App/app/(portal)/inventory/items/[id]/page.tsx
Normal file
223
App/app/(portal)/inventory/items/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,223 @@
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { notFound, redirect } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { formatCurrency, formatDate } from "@/lib/utils";
|
||||||
|
import { distanceKm, formatDistance } from "@/lib/geo";
|
||||||
|
import { AddToCartButton } from "@/components/inventory/add-to-cart-button";
|
||||||
|
import { ItemPriceChart } from "./item-price-chart";
|
||||||
|
import { SiteSelect } from "@/components/inventory/site-select";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
searchParams: Promise<{ site?: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||||
|
const { id } = await params;
|
||||||
|
const product = await db.product.findUnique({ where: { id }, select: { name: true } });
|
||||||
|
return { title: product?.name ?? "Item Detail" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ItemDetailPage({ params, searchParams }: Props) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) redirect("/login");
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const { site: siteId } = await searchParams;
|
||||||
|
const baseHref = `/inventory/items/${id}`;
|
||||||
|
|
||||||
|
const [product, sites] = await Promise.all([
|
||||||
|
db.product.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
vendorPrices: {
|
||||||
|
include: {
|
||||||
|
vendor: {
|
||||||
|
select: { id: true, name: true, vendorId: true, isVerified: true, isActive: true, latitude: true, longitude: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { price: "asc" },
|
||||||
|
},
|
||||||
|
lastVendor: true,
|
||||||
|
inventory: { include: { site: { select: { id: true, name: true } } } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
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 selectedSite = siteId ? sites.find((s) => s.id === siteId) ?? null : null;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
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 };
|
||||||
|
});
|
||||||
|
|
||||||
|
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-6xl space-y-6">
|
||||||
|
{/* Breadcrumb */}
|
||||||
|
<div className="flex items-center gap-2 text-sm text-neutral-500">
|
||||||
|
<Link href="/inventory/items" 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"}`}>
|
||||||
|
{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>}
|
||||||
|
</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 }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid grid-cols-4 gap-4">
|
||||||
|
<div className="rounded-lg border border-neutral-200 bg-white px-5 py-4">
|
||||||
|
<p className="text-xs text-neutral-500 mb-1">Vendors</p>
|
||||||
|
<p className="text-2xl font-semibold text-neutral-900">{product.vendorPrices.length}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-neutral-200 bg-white px-5 py-4">
|
||||||
|
<p className="text-xs text-neutral-500 mb-1">Lowest Price</p>
|
||||||
|
<p className="text-2xl font-semibold text-success-700">{minPrice !== null ? formatCurrency(minPrice) : "—"}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-neutral-200 bg-white px-5 py-4">
|
||||||
|
<p className="text-xs text-neutral-500 mb-1">Highest Price</p>
|
||||||
|
<p className="text-2xl font-semibold text-neutral-900">{maxPrice !== null ? formatCurrency(maxPrice) : "—"}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-neutral-200 bg-white px-5 py-4">
|
||||||
|
<p className="text-xs text-neutral-500 mb-1">Sites with stock</p>
|
||||||
|
<p className="text-2xl font-semibold text-neutral-900">{product.inventory.length}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Price chart */}
|
||||||
|
{priceChartData.length > 1 && <ItemPriceChart data={priceChartData} />}
|
||||||
|
|
||||||
|
{/* Site filter */}
|
||||||
|
{sites.length > 0 && (
|
||||||
|
<SiteSelect
|
||||||
|
sites={sites.map((s) => ({ id: s.id, name: s.name }))}
|
||||||
|
currentSiteId={siteId ?? null}
|
||||||
|
baseHref={baseHref}
|
||||||
|
paramKey="site"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
{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">Verified</th>
|
||||||
|
<th className="pb-2 text-right font-medium text-neutral-600 pl-4">Price</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">
|
||||||
|
{enriched.map((vp, idx) => {
|
||||||
|
const price = Number(vp.price);
|
||||||
|
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">
|
||||||
|
<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 && !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>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stock 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,82 +1,43 @@
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
|
import { hasPermission } from "@/lib/permissions";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { distanceKm } from "@/lib/geo";
|
import { ProductsTable } from "@/app/(portal)/admin/products/products-table";
|
||||||
import { ItemsTable } from "./items-table";
|
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
export const metadata: Metadata = { title: "Browse Items" };
|
export const metadata: Metadata = { title: "Item Catalogue" };
|
||||||
|
|
||||||
interface Props {
|
export default async function InventoryItemsPage() {
|
||||||
searchParams: Promise<{ siteId?: string }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function InventoryItemsPage({ searchParams }: Props) {
|
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
if (!session?.user) redirect("/login");
|
if (!session?.user) redirect("/login");
|
||||||
|
|
||||||
const { siteId } = await searchParams;
|
const products = await db.product.findMany({
|
||||||
|
|
||||||
const [site, products, sites] = await Promise.all([
|
|
||||||
siteId
|
|
||||||
? db.site.findUnique({
|
|
||||||
where: { id: siteId, isActive: true },
|
|
||||||
select: { id: true, name: true, latitude: true, longitude: true },
|
|
||||||
})
|
|
||||||
: Promise.resolve(null),
|
|
||||||
db.product.findMany({
|
|
||||||
where: { isActive: true },
|
|
||||||
include: {
|
|
||||||
vendorPrices: {
|
|
||||||
where: { vendor: { isActive: true } },
|
|
||||||
include: {
|
|
||||||
vendor: {
|
|
||||||
select: { id: true, name: true, isVerified: true, latitude: true, longitude: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
orderBy: { price: "asc" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
orderBy: { name: "asc" },
|
|
||||||
}),
|
|
||||||
db.site.findMany({
|
|
||||||
where: { isActive: true },
|
where: { isActive: true },
|
||||||
orderBy: { name: "asc" },
|
orderBy: { name: "asc" },
|
||||||
select: { id: true, name: true, code: true },
|
include: {
|
||||||
}),
|
lastVendor: true,
|
||||||
]);
|
_count: { select: { vendorPrices: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const items = products.map((p) => ({
|
// canManage lets managers/admins see the Edit/Delete controls even from /inventory/items
|
||||||
id: p.id,
|
const canManage = hasPermission(session.user.role, "manage_products");
|
||||||
code: p.code,
|
|
||||||
name: p.name,
|
|
||||||
description: p.description ?? "",
|
|
||||||
vendors: p.vendorPrices.map((vp) => {
|
|
||||||
let dist: number | null = null;
|
|
||||||
if (site?.latitude && site.longitude && vp.vendor.latitude && vp.vendor.longitude) {
|
|
||||||
dist = distanceKm(site.latitude, site.longitude, vp.vendor.latitude, vp.vendor.longitude);
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
vendorId: vp.vendor.id,
|
|
||||||
vendorName: vp.vendor.name,
|
|
||||||
isVerified: vp.vendor.isVerified,
|
|
||||||
price: Number(vp.price),
|
|
||||||
distanceKm: dist,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-6xl">
|
<div className="max-w-6xl">
|
||||||
<div className="mb-6">
|
<ProductsTable
|
||||||
<h1 className="text-2xl font-semibold text-neutral-900">Browse Items</h1>
|
canManage={canManage}
|
||||||
<p className="mt-1 text-sm text-neutral-500">Search the catalogue and add items to your cart.</p>
|
products={products.map((p) => ({
|
||||||
</div>
|
id: p.id,
|
||||||
<ItemsTable
|
code: p.code,
|
||||||
items={items}
|
name: p.name,
|
||||||
hasSite={!!site}
|
description: p.description ?? null,
|
||||||
sites={sites}
|
lastPrice: p.lastPrice !== null ? Number(p.lastPrice) : null,
|
||||||
currentSiteId={siteId ?? null}
|
lastVendorName: p.lastVendor?.name ?? null,
|
||||||
|
updatedAt: p.updatedAt.toISOString(),
|
||||||
|
isActive: p.isActive,
|
||||||
|
vendorPriceCount: p._count.vendorPrices,
|
||||||
|
}))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -54,13 +54,14 @@ const PURCHASING_STAFF: NavItem[] = [
|
||||||
{ href: "/inventory/cart", label: "Cart", icon: ShoppingCart, roles: ["TECHNICAL", "MANNING", "SUPERUSER", "MANAGER"] },
|
{ href: "/inventory/cart", label: "Cart", icon: ShoppingCart, roles: ["TECHNICAL", "MANNING", "SUPERUSER", "MANAGER"] },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Manager / Admin catalogue management — Sites conditionally shown
|
// Manager catalogue management — Sites conditionally shown
|
||||||
|
// Admin does not use Purchasing; their links live under Administration
|
||||||
const PURCHASING_MGMT: NavItem[] = [
|
const PURCHASING_MGMT: NavItem[] = [
|
||||||
{ href: "/inventory/vendors", label: "Vendors", icon: Store, roles: ["MANAGER", "ADMIN"] },
|
{ href: "/inventory/vendors", label: "Vendors", icon: Store, roles: ["MANAGER"] },
|
||||||
{ href: "/admin/products", label: "Items", icon: Package, roles: ["MANAGER", "ADMIN"] },
|
{ href: "/inventory/items", label: "Items", icon: Package, roles: ["MANAGER"] },
|
||||||
{ href: "/admin/vessels", label: "Cost Centres", icon: Ship, roles: ["MANAGER", "ADMIN"] },
|
{ href: "/admin/vessels", label: "Cost Centres", icon: Ship, roles: ["MANAGER"] },
|
||||||
...(INVENTORY_ENABLED
|
...(INVENTORY_ENABLED
|
||||||
? [{ href: "/admin/sites", label: "Sites", icon: MapPin, roles: ["MANAGER", "ADMIN"] as Role[] }]
|
? [{ href: "/admin/sites", label: "Sites", icon: MapPin, roles: ["MANAGER"] as Role[] }]
|
||||||
: []),
|
: []),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -75,6 +76,8 @@ const MANAGER_ADMIN_ITEMS: NavItem[] = [
|
||||||
|
|
||||||
// Full Administration section (ADMIN only)
|
// Full Administration section (ADMIN only)
|
||||||
const ADMIN_ITEMS: NavItem[] = [
|
const ADMIN_ITEMS: NavItem[] = [
|
||||||
|
{ href: "/admin/vessels", label: "Cost Centres", icon: Ship },
|
||||||
|
{ href: "/admin/sites", label: "Sites", icon: MapPin },
|
||||||
{ href: "/admin/users", label: "Users", icon: Users },
|
{ href: "/admin/users", label: "Users", icon: Users },
|
||||||
{ href: "/admin/superuser-requests", label: "SuperUser Requests",icon: ShieldCheck },
|
{ href: "/admin/superuser-requests", label: "SuperUser Requests",icon: ShieldCheck },
|
||||||
{ href: "/admin/accounts", label: "Accounting Codes", icon: Building2 },
|
{ href: "/admin/accounts", label: "Accounting Codes", icon: Building2 },
|
||||||
|
|
|
||||||
|
|
@ -34,13 +34,12 @@ export function SearchableSelect({
|
||||||
|
|
||||||
useEffect(() => { setMounted(true); }, []);
|
useEffect(() => { setMounted(true); }, []);
|
||||||
|
|
||||||
// Recalculate portal position whenever the dropdown opens
|
// Recalculate portal position on open, scroll, and resize so the panel tracks
|
||||||
useLayoutEffect(() => {
|
// the trigger even when the page (or any ancestor) is scrolled.
|
||||||
if (!open || !containerRef.current) return;
|
const updatePortalPos = useCallback(() => {
|
||||||
|
if (!containerRef.current) return;
|
||||||
const rect = containerRef.current.getBoundingClientRect();
|
const rect = containerRef.current.getBoundingClientRect();
|
||||||
const PANEL_WIDTH = 420;
|
const PANEL_WIDTH = 420;
|
||||||
// Prefer right-aligning with the trigger; clamp so it doesn't go off-screen left
|
|
||||||
const right = window.innerWidth - rect.right;
|
|
||||||
const left = Math.max(8, rect.right - PANEL_WIDTH);
|
const left = Math.max(8, rect.right - PANEL_WIDTH);
|
||||||
setPortalStyle({
|
setPortalStyle({
|
||||||
position: "fixed",
|
position: "fixed",
|
||||||
|
|
@ -49,7 +48,23 @@ export function SearchableSelect({
|
||||||
width: PANEL_WIDTH,
|
width: PANEL_WIDTH,
|
||||||
zIndex: 9999,
|
zIndex: 9999,
|
||||||
});
|
});
|
||||||
}, [open]);
|
}, []);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
updatePortalPos();
|
||||||
|
}, [open, updatePortalPos]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
// capture:true catches scroll on any ancestor, not just window
|
||||||
|
window.addEventListener("scroll", updatePortalPos, true);
|
||||||
|
window.addEventListener("resize", updatePortalPos);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("scroll", updatePortalPos, true);
|
||||||
|
window.removeEventListener("resize", updatePortalPos);
|
||||||
|
};
|
||||||
|
}, [open, updatePortalPos]);
|
||||||
|
|
||||||
// Close on outside click / Escape
|
// Close on outside click / Escape
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,50 @@ const VESSELS: { code: string; name: string }[] = [
|
||||||
{ name: "GD3000", code: "GD30" },
|
{ name: "GD3000", code: "GD30" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// ─── Companies ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type CompanyEntry = {
|
||||||
|
code: string; name: string; gstNumber: string;
|
||||||
|
address: string; telephone: string; mobile: string;
|
||||||
|
email: string; invoiceEmail: string; invoiceAddress: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const COMPANIES: CompanyEntry[] = [
|
||||||
|
{
|
||||||
|
code: "PMS",
|
||||||
|
name: "Pelagia Marine Services Pvt Ltd",
|
||||||
|
gstNumber: "27AAHCP5787B1Z6",
|
||||||
|
address: "409-410, ZION, Plot 273, Sector-10, Kharghar, Navi Mumbai- 410210",
|
||||||
|
telephone: "+91-22-6909 9028",
|
||||||
|
mobile: "+91 74000 60772",
|
||||||
|
email: "technical@pelagiamarine.com",
|
||||||
|
invoiceEmail: "accounts@pelagiamarine.com",
|
||||||
|
invoiceAddress: "Pelagia Marine Services Pvt Ltd, 409-410, ZION, Plot 273, Sector-10, Kharghar, Navi Mumbai- 410210 (MH)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "HNR",
|
||||||
|
name: "H&R Offshore Pvt Ltd",
|
||||||
|
gstNumber: "27AAECH2810G1ZX",
|
||||||
|
address: "409-410, ZION, Plot 273, Sector-10, Kharghar, Navi Mumbai- 410210",
|
||||||
|
telephone: "+91-22-6909 9028",
|
||||||
|
mobile: "+91 74000 60772",
|
||||||
|
email: "technical@pelagiamarine.com",
|
||||||
|
invoiceEmail: "accounts@pelagiamarine.com",
|
||||||
|
invoiceAddress: "H&R Offshore Pvt Ltd, 409-410, ZION, Plot 273, Sector-10, Kharghar, Navi Mumbai- 410210 (MH)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "DEI",
|
||||||
|
name: "Dredge Experts India Pvt Ltd",
|
||||||
|
gstNumber: "27AAGCD3114P1ZH",
|
||||||
|
address: "409-410, ZION, Plot 273, Sector-10, Kharghar, Navi Mumbai- 410210",
|
||||||
|
telephone: "+91-22-6909 9028",
|
||||||
|
mobile: "+91 74000 60772",
|
||||||
|
email: "technical@pelagiamarine.com",
|
||||||
|
invoiceEmail: "accounts@pelagiamarine.com",
|
||||||
|
invoiceAddress: "Dredge Experts India Pvt Ltd, 409-410, ZION, Plot 273, Sector-10, Kharghar, Navi Mumbai- 410210 (MH)",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
// ─── Main ─────────────────────────────────────────────────────────────────────
|
// ─── Main ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
|
|
@ -139,6 +183,25 @@ async function main() {
|
||||||
console.log(` ✓ ${v.name} (${v.code})`);
|
console.log(` ✓ ${v.name} (${v.code})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Companies ──────────────────────────────────────────────────────────────
|
||||||
|
console.log("\n🏢 Seeding companies…");
|
||||||
|
for (const c of COMPANIES) {
|
||||||
|
await db.company.upsert({
|
||||||
|
where: { code: c.code },
|
||||||
|
update: {
|
||||||
|
name: c.name, gstNumber: c.gstNumber, address: c.address,
|
||||||
|
telephone: c.telephone, mobile: c.mobile, email: c.email,
|
||||||
|
invoiceEmail: c.invoiceEmail, invoiceAddress: c.invoiceAddress,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
code: c.code, name: c.name, gstNumber: c.gstNumber, address: c.address,
|
||||||
|
telephone: c.telephone, mobile: c.mobile, email: c.email,
|
||||||
|
invoiceEmail: c.invoiceEmail, invoiceAddress: c.invoiceAddress,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log(` ✓ ${c.name} (${c.code})`);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Accounting Codes ───────────────────────────────────────────────────────
|
// ── Accounting Codes ───────────────────────────────────────────────────────
|
||||||
console.log("\n📊 Seeding accounting codes…");
|
console.log("\n📊 Seeding accounting codes…");
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue