mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 03:05:42 +00:00
feat(portal): Add Profile page, refactor booking details, and remove documents module
This commit is contained in:
@@ -13,10 +13,12 @@ import {
|
|||||||
FileText,
|
FileText,
|
||||||
Home,
|
Home,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
User,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import useAuth from "./hooks/useAuth";
|
import useAuth from "./hooks/useAuth";
|
||||||
|
|
||||||
|
import ProfilePage from "./pages/ProfilePage";
|
||||||
import MyPortalPage from "./pages/MyPortalPage";
|
import MyPortalPage from "./pages/MyPortalPage";
|
||||||
import EDRFreightLandingPage from "./pages/EDRFreightLandingPage";
|
import EDRFreightLandingPage from "./pages/EDRFreightLandingPage";
|
||||||
import SignupPage from "./pages/accounts/SignupPage";
|
import SignupPage from "./pages/accounts/SignupPage";
|
||||||
@@ -29,7 +31,6 @@ import BookingDetailPage from "./pages/bookings/BookingDetailPage";
|
|||||||
import NewBookingPage from "./pages/bookings/NewBookingPage";
|
import NewBookingPage from "./pages/bookings/NewBookingPage";
|
||||||
import TrackingPage from "./pages/tracking/TrackingPage";
|
import TrackingPage from "./pages/tracking/TrackingPage";
|
||||||
import BillingPage from "./pages/billing/BillingPage";
|
import BillingPage from "./pages/billing/BillingPage";
|
||||||
import DocumentsPage from "./pages/documents/DocumentsPage";
|
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
|
||||||
const sidebarItems: SidebarItem[] = [
|
const sidebarItems: SidebarItem[] = [
|
||||||
@@ -37,7 +38,7 @@ const sidebarItems: SidebarItem[] = [
|
|||||||
{ label: "My Bookings", href: "/bookings", icon: <CalendarCheck /> },
|
{ label: "My Bookings", href: "/bookings", icon: <CalendarCheck /> },
|
||||||
{ label: "Tracking", href: "/tracking", icon: <MapPin /> },
|
{ label: "Tracking", href: "/tracking", icon: <MapPin /> },
|
||||||
{ label: "Billing", href: "/billing", icon: <Receipt /> },
|
{ label: "Billing", href: "/billing", icon: <Receipt /> },
|
||||||
{ label: "Documents", href: "/documents", icon: <FileText /> },
|
{ label: "Profile", href: "/profile", icon: <User /> },
|
||||||
];
|
];
|
||||||
|
|
||||||
const App = () => {
|
const App = () => {
|
||||||
@@ -97,7 +98,7 @@ const App = () => {
|
|||||||
<Route path="/bookings/:id" element={<BookingDetailPage />} />
|
<Route path="/bookings/:id" element={<BookingDetailPage />} />
|
||||||
<Route path="/tracking" element={<TrackingPage />} />
|
<Route path="/tracking" element={<TrackingPage />} />
|
||||||
<Route path="/billing" element={<BillingPage />} />
|
<Route path="/billing" element={<BillingPage />} />
|
||||||
<Route path="/documents" element={<DocumentsPage />} />
|
<Route path="/profile" element={<ProfilePage />} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</DashboardLayout>
|
</DashboardLayout>
|
||||||
|
|||||||
326
apps/edr-freight-web/portal/src/pages/ProfilePage.tsx
Normal file
326
apps/edr-freight-web/portal/src/pages/ProfilePage.tsx
Normal file
@@ -0,0 +1,326 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import {
|
||||||
|
User,
|
||||||
|
Building2,
|
||||||
|
Phone,
|
||||||
|
Mail,
|
||||||
|
MapPin,
|
||||||
|
ShieldCheck,
|
||||||
|
Briefcase,
|
||||||
|
UserCheck,
|
||||||
|
Building,
|
||||||
|
Globe,
|
||||||
|
Fingerprint,
|
||||||
|
FileCheck,
|
||||||
|
Settings2,
|
||||||
|
ExternalLink,
|
||||||
|
} from "lucide-react";
|
||||||
|
import useAuth from "@/hooks/useAuth";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
CardAction,
|
||||||
|
Badge,
|
||||||
|
Separator,
|
||||||
|
SmartFileInput,
|
||||||
|
Button,
|
||||||
|
} from "@edr/ui-common";
|
||||||
|
import type { IFileUploadSetting } from "@edr/types/freight";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export default function ProfilePage() {
|
||||||
|
const { user, customer, isPending } = useAuth();
|
||||||
|
|
||||||
|
const documentSettings = useMemo<IFileUploadSetting>(() => ({
|
||||||
|
id: "profile-docs",
|
||||||
|
code: "customer_documents",
|
||||||
|
label: "Customer Documents",
|
||||||
|
entity: "customer",
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
id: "doc-tin",
|
||||||
|
settingId: "profile-docs",
|
||||||
|
fileKey: "tin_certificate",
|
||||||
|
fileLabel: "TIN Certificate",
|
||||||
|
isRequired: true,
|
||||||
|
isMultiple: false,
|
||||||
|
maxFiles: 1,
|
||||||
|
allowedExtensions: [".pdf", ".jpg", ".jpeg", ".png"],
|
||||||
|
maxSizeMb: 5,
|
||||||
|
order: 1,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "doc-license",
|
||||||
|
settingId: "profile-docs",
|
||||||
|
fileKey: "business_license",
|
||||||
|
fileLabel: "Business/Investment License",
|
||||||
|
isRequired: true,
|
||||||
|
isMultiple: false,
|
||||||
|
maxFiles: 1,
|
||||||
|
allowedExtensions: [".pdf", ".jpg", ".jpeg", ".png"],
|
||||||
|
maxSizeMb: 5,
|
||||||
|
order: 2,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "doc-reg",
|
||||||
|
settingId: "profile-docs",
|
||||||
|
fileKey: "registration_certificate",
|
||||||
|
fileLabel: "Business Registration Certificate",
|
||||||
|
isRequired: true,
|
||||||
|
isMultiple: false,
|
||||||
|
maxFiles: 1,
|
||||||
|
allowedExtensions: [".pdf", ".jpg", ".jpeg", ".png"],
|
||||||
|
maxSizeMb: 5,
|
||||||
|
order: 3,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "doc-id",
|
||||||
|
settingId: "profile-docs",
|
||||||
|
fileKey: "national_id",
|
||||||
|
fileLabel: "National ID",
|
||||||
|
isRequired: true,
|
||||||
|
isMultiple: false,
|
||||||
|
maxFiles: 1,
|
||||||
|
allowedExtensions: [".pdf", ".jpg", ".jpeg", ".png"],
|
||||||
|
maxSizeMb: 5,
|
||||||
|
order: 4,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "doc-poa",
|
||||||
|
settingId: "profile-docs",
|
||||||
|
fileKey: "power_of_attorney",
|
||||||
|
fileLabel: "Power of Attorney",
|
||||||
|
isRequired: false,
|
||||||
|
isMultiple: false,
|
||||||
|
maxFiles: 1,
|
||||||
|
allowedExtensions: [".pdf", ".jpg", ".jpeg", ".png"],
|
||||||
|
maxSizeMb: 5,
|
||||||
|
order: 5,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}), []);
|
||||||
|
|
||||||
|
if (isPending) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center">
|
||||||
|
<div className="h-8 w-8 animate-spin rounded-full border-b-2 border-primary"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayName = user?.name?.en || user?.username || user?.email || "User";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto max-w-7xl px-4 py-8">
|
||||||
|
<div className="flex flex-col gap-8">
|
||||||
|
{/* Header Section */}
|
||||||
|
<div className="flex flex-col gap-6 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<div className="flex size-24 items-center justify-center rounded-2xl bg-primary/10 text-primary shadow-inner">
|
||||||
|
<User className="size-12" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h1 className="text-3xl font-black tracking-tight text-foreground">
|
||||||
|
{displayName}
|
||||||
|
</h1>
|
||||||
|
<Badge variant="secondary" className="font-bold uppercase tracking-wider">
|
||||||
|
Verified
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="flex items-center gap-2 font-medium text-muted-foreground">
|
||||||
|
<Building className="size-4" />
|
||||||
|
{customer?.companyName || "No Company Linked"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button variant="outline">
|
||||||
|
<Settings2 data-icon="inline-start" />
|
||||||
|
Account Settings
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-8 lg:grid-cols-3">
|
||||||
|
{/* Left Column - Personal & Company Info */}
|
||||||
|
<div className="flex flex-col gap-8 lg:col-span-2">
|
||||||
|
<div className="grid grid-cols-1 gap-8 md:grid-cols-2">
|
||||||
|
{/* Personal Details Card */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Fingerprint className="size-5 text-primary" />
|
||||||
|
Personal Details
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>Your account contact information</CardDescription>
|
||||||
|
<CardAction>
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<ExternalLink />
|
||||||
|
</Button>
|
||||||
|
</CardAction>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-4">
|
||||||
|
<InfoItem icon={<Mail />} label="Email Address" value={user?.email} />
|
||||||
|
<InfoItem icon={<Phone />} label="Phone Number" value={user?.phoneNumber} />
|
||||||
|
<InfoItem icon={<UserCheck />} label="Username" value={user?.username} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Company Details Card */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Building2 className="size-5 text-primary" />
|
||||||
|
Company Details
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>Business registration information</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-4">
|
||||||
|
<InfoItem icon={<Globe />} label="Location" value={customer?.companyLocation} />
|
||||||
|
<InfoItem icon={<MapPin />} label="Address" value={customer?.companyAddress} />
|
||||||
|
<InfoItem icon={<FileCheck />} label="TIN Number" value={customer?.tinNumber} />
|
||||||
|
<InfoItem icon={<ShieldCheck />} label="FAN Number" value={customer?.fanNumber} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Personnel Card */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Briefcase className="size-5 text-primary" />
|
||||||
|
Key Personnel
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>Management and contact persons</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid grid-cols-1 gap-8 md:grid-cols-2">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<h3 className="border-l-4 border-primary pl-3 text-sm font-bold uppercase tracking-wide text-foreground">
|
||||||
|
Contact Person
|
||||||
|
</h3>
|
||||||
|
<div className="flex flex-col gap-3 pl-4">
|
||||||
|
<InfoItem label="Name" value={customer?.contactPersonName} />
|
||||||
|
<InfoItem label="Phone" value={customer?.contactPersonPhone} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<h3 className="border-l-4 border-accent pl-3 text-sm font-bold uppercase tracking-wide text-foreground">
|
||||||
|
General Manager
|
||||||
|
</h3>
|
||||||
|
<div className="flex flex-col gap-3 pl-4">
|
||||||
|
<InfoItem label="Name" value={customer?.generalManagerName} />
|
||||||
|
<InfoItem label="Email" value={customer?.generalManagerEmail} />
|
||||||
|
<InfoItem label="Phone" value={customer?.generalManagerPhone} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Power of Attorney Section (Conditional) */}
|
||||||
|
{customer?.poaName && (
|
||||||
|
<Card className="border-dashed">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<UserCheck className="size-5 text-accent" />
|
||||||
|
Power of Attorney
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>Authorized representative details</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<InfoItem label="PoA Name" value={customer.poaName} />
|
||||||
|
<InfoItem label="PoA Email" value={customer.poaEmail} />
|
||||||
|
<InfoItem label="PoA Phone" value={customer.poaPhone} />
|
||||||
|
<InfoItem label="PoA Location" value={customer.poaLocation} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Column - Documents */}
|
||||||
|
<div className="flex flex-col gap-8">
|
||||||
|
<Card className="border-primary/20 bg-primary/[0.02] shadow-md">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<FileCheck className="size-6 text-primary" />
|
||||||
|
Documents
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>Manage required business documents</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="px-6 pb-6 pt-0">
|
||||||
|
<SmartFileInput
|
||||||
|
file={documentSettings}
|
||||||
|
variant="minimal"
|
||||||
|
className="flex flex-col gap-4"
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="relative overflow-hidden border-none bg-foreground text-background shadow-xl">
|
||||||
|
<div className="absolute right-0 top-0 p-4 opacity-10">
|
||||||
|
<ShieldCheck className="size-32" />
|
||||||
|
</div>
|
||||||
|
<CardContent className="relative z-10 flex flex-col gap-4 px-6 py-8">
|
||||||
|
<h3 className="text-xl font-black">Secure Account</h3>
|
||||||
|
<p className="text-sm leading-relaxed text-muted-foreground/80">
|
||||||
|
Your information is protected by enterprise-grade security.
|
||||||
|
Contact support for verified information updates.
|
||||||
|
</p>
|
||||||
|
<div className="pt-2">
|
||||||
|
<Button variant="secondary" size="sm">
|
||||||
|
Contact Support
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InfoItem({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
label: string;
|
||||||
|
value?: string | null;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
{icon && (
|
||||||
|
<div className="mt-1 text-muted-foreground [&_svg]:size-4">
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<p className="text-[10px] font-bold uppercase tracking-tight text-muted-foreground">
|
||||||
|
{label}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm font-bold text-foreground">
|
||||||
|
{value || "—"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,22 +1,69 @@
|
|||||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
|
||||||
ArrowRight,
|
|
||||||
Calendar,
|
Calendar,
|
||||||
Flag,
|
|
||||||
MapPin,
|
MapPin,
|
||||||
Package,
|
Package,
|
||||||
StickyNote,
|
StickyNote,
|
||||||
Trash2,
|
|
||||||
Train,
|
Train,
|
||||||
User,
|
|
||||||
Weight,
|
Weight,
|
||||||
|
Ship,
|
||||||
|
Truck,
|
||||||
|
Anchor,
|
||||||
|
FileText,
|
||||||
|
ShieldCheck,
|
||||||
|
AlertTriangle,
|
||||||
|
Info,
|
||||||
|
Clock,
|
||||||
|
Layers,
|
||||||
|
CheckCircle2,
|
||||||
|
History,
|
||||||
|
ArrowRight,
|
||||||
|
ClipboardCheck,
|
||||||
|
CreditCard,
|
||||||
|
FileSignature,
|
||||||
|
PackageCheck,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import Breadcrumbs from "@/components/Breadcrumbs";
|
import Breadcrumbs from "@/components/Breadcrumbs";
|
||||||
import DeleteBookingDialog from "./DeleteBookingDialog";
|
import { getBookingById } from "./bookings.mock";
|
||||||
import { getBookingById, deleteBooking, type BookingStatus } from "./bookings.mock";
|
import {
|
||||||
import { Button, Card } from "@edr/ui-common";
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
Badge,
|
||||||
|
Separator,
|
||||||
|
} from "@edr/ui-common";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
// Grouping the 15 granular statuses into 6 logical progress stages for the UI tracker
|
||||||
|
const PROGRESS_STAGES = [
|
||||||
|
{ label: "Request", icon: FileText, statuses: ["DRAFT", "RFQ_SUBMITTED"] },
|
||||||
|
{ label: "Quotation", icon: ClipboardCheck, statuses: ["QUOTATION_SENT", "QUOTATION_APPROVED", "QUOTATION_REJECTED"] },
|
||||||
|
{ label: "Approval", icon: ShieldCheck, statuses: ["PENDING_APPROVAL", "APPROVED"] },
|
||||||
|
{ label: "Execution", icon: FileSignature, statuses: ["SIGNED_CUSTOMER", "FULLY_EXECUTED", "PAID"] },
|
||||||
|
{ label: "In Transit", icon: Train, statuses: ["IN_TRANSIT", "PENDING_CONSOLIDATION", "CONSOLIDATED"] },
|
||||||
|
{ label: "Complete", icon: PackageCheck, statuses: ["COMPLETED"] },
|
||||||
|
];
|
||||||
|
|
||||||
|
const STATUS_MAP: Record<string, { title: string; description: string; color: string; stage: number }> = {
|
||||||
|
DRAFT: { title: "Drafting Request", description: "Booking is being prepared and has not been submitted.", color: "text-slate-500", stage: 0 },
|
||||||
|
RFQ_SUBMITTED: { title: "RFQ Submitted", description: "Request for Quotation has been sent to the operations team.", color: "text-amber-600", stage: 0 },
|
||||||
|
QUOTATION_SENT: { title: "Quotation Received", description: "EDR has sent a formal quotation for your review.", color: "text-sky-600", stage: 1 },
|
||||||
|
QUOTATION_APPROVED: { title: "Quotation Approved", description: "You have accepted the quotation terms.", color: "text-emerald-600", stage: 1 },
|
||||||
|
QUOTATION_REJECTED: { title: "Quotation Rejected", description: "The quotation was not accepted.", color: "text-red-600", stage: 1 },
|
||||||
|
PENDING_APPROVAL: { title: "Internal Approval", description: "Booking is undergoing final administrative review.", color: "text-amber-600", stage: 2 },
|
||||||
|
APPROVED: { title: "Booking Approved", description: "Request is fully approved and ready for execution.", color: "text-emerald-600", stage: 2 },
|
||||||
|
SIGNED_CUSTOMER: { title: "Customer Signed", description: "Contract has been signed by the customer.", color: "text-sky-600", stage: 3 },
|
||||||
|
FULLY_EXECUTED: { title: "Contract Executed", description: "All parties have signed. Operational setup in progress.", color: "text-indigo-600", stage: 3 },
|
||||||
|
PAID: { title: "Payment Received", description: "Initial payments confirmed. Cargo ready for dispatch.", color: "text-emerald-600", stage: 3 },
|
||||||
|
IN_TRANSIT: { title: "Cargo Moving", description: "Shipment is currently moving through the rail network.", color: "text-sky-600", stage: 4 },
|
||||||
|
PENDING_CONSOLIDATION: { title: "Consolidation Node", description: "Cargo is waiting to be consolidated with other shipments.", color: "text-amber-500", stage: 4 },
|
||||||
|
CONSOLIDATED: { title: "Load Consolidated", description: "Cargo has been successfully merged into a larger shipment.", color: "text-indigo-500", stage: 4 },
|
||||||
|
COMPLETED: { title: "Service Complete", description: "Cargo delivered and service successfully terminated.", color: "text-emerald-600", stage: 5 },
|
||||||
|
CANCELLED: { title: "Cancelled", description: "This booking process has been terminated.", color: "text-red-600", stage: -1 },
|
||||||
|
};
|
||||||
|
|
||||||
export default function BookingDetailPage() {
|
export default function BookingDetailPage() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
@@ -25,37 +72,29 @@ export default function BookingDetailPage() {
|
|||||||
|
|
||||||
if (!booking) {
|
if (!booking) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen p-6">
|
<div className="container mx-auto p-6">
|
||||||
<div className="space-y-6">
|
<Card className="flex flex-col items-center p-12 text-center">
|
||||||
<Breadcrumbs
|
<div className="flex size-16 items-center justify-center rounded-full bg-slate-100 text-slate-400">
|
||||||
items={[
|
<Package className="size-8" />
|
||||||
{ label: "Bookings", href: "/bookings" },
|
</div>
|
||||||
{ label: "Not found" },
|
<h1 className="mt-4 text-2xl font-bold text-slate-900">
|
||||||
]}
|
Booking not found
|
||||||
/>
|
</h1>
|
||||||
<Card className="p-8 text-center">
|
</Card>
|
||||||
<h1 className="text-2xl font-bold text-slate-900">
|
|
||||||
Booking not found
|
|
||||||
</h1>
|
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
|
||||||
The booking you're looking for doesn't exist or has been removed.
|
|
||||||
</p>
|
|
||||||
<Link
|
|
||||||
to="/bookings"
|
|
||||||
className="mt-6 inline-flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition hover:bg-primary/90"
|
|
||||||
>
|
|
||||||
<ArrowLeft />
|
|
||||||
Back to Bookings
|
|
||||||
</Link>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Normalize status to upper case for mapping
|
||||||
|
const normalizedStatus = (booking.status === "In Transit" ? "IN_TRANSIT" : booking.status === "Pending" ? "RFQ_SUBMITTED" : booking.status.toUpperCase()) as keyof typeof STATUS_MAP;
|
||||||
|
const statusConfig = STATUS_MAP[normalizedStatus] || STATUS_MAP.DRAFT;
|
||||||
|
const currentStageIndex = statusConfig.stage;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen p-6">
|
<div className="container mx-auto max-w-7xl px-4 py-8">
|
||||||
<div className="space-y-6">
|
<div className="flex flex-col gap-8">
|
||||||
|
|
||||||
|
{/* Breadcrumbs Restored */}
|
||||||
<Breadcrumbs
|
<Breadcrumbs
|
||||||
items={[
|
items={[
|
||||||
{ label: "Bookings", href: "/bookings" },
|
{ label: "Bookings", href: "/bookings" },
|
||||||
@@ -63,159 +102,256 @@ export default function BookingDetailPage() {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Card className="p-6">
|
{/* Compact Header Card */}
|
||||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
<Card>
|
||||||
<div className="flex items-center gap-4">
|
<CardHeader>
|
||||||
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-primary text-primary-foreground">
|
<div className="flex items-center gap-6">
|
||||||
<Package />
|
<div className="flex size-14 items-center justify-center rounded-2xl bg-primary text-primary-foreground">
|
||||||
|
<Package className="size-6" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="flex flex-col gap-1">
|
||||||
<h1 className="text-3xl font-bold tracking-tight text-slate-900">
|
<div className="flex items-center gap-3">
|
||||||
{booking.reference}
|
<h1 className="text-2xl font-black tracking-tight text-foreground">
|
||||||
</h1>
|
{booking.reference}
|
||||||
<div className="mt-1 flex items-center gap-3 text-sm text-muted-foreground">
|
</h1>
|
||||||
<span>{booking.customer}</span>
|
<StatusBadge status={normalizedStatus} />
|
||||||
<span className="text-slate-300">•</span>
|
</div>
|
||||||
<span>{booking.requestedDate}</span>
|
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||||
<span className="text-slate-300">•</span>
|
<span className="font-semibold">{booking.customer}</span>
|
||||||
<StatusBadge status={booking.status} />
|
<Separator orientation="vertical" className="h-3" />
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Calendar className="size-3" />
|
||||||
|
{booking.requestedDate}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</CardHeader>
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Button>Edit Booking</Button>
|
|
||||||
|
|
||||||
<DeleteBookingDialog
|
|
||||||
bookingReference={booking.reference}
|
|
||||||
onConfirm={() => {
|
|
||||||
deleteBooking(booking.id);
|
|
||||||
navigate("/bookings");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button variant="outline">
|
|
||||||
<Trash2 />
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</DeleteBookingDialog>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="p-6">
|
{/* Granular Status Lifecycle */}
|
||||||
<div className="flex flex-col items-center justify-between gap-4 md:flex-row">
|
<Card>
|
||||||
<RouteEndpoint
|
<CardHeader>
|
||||||
label="Origin"
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
station={booking.originStation}
|
<History className="size-4 text-primary" />
|
||||||
icon={<MapPin />}
|
Booking Status Lifecycle
|
||||||
/>
|
</CardTitle>
|
||||||
<div className="flex items-center gap-2 text-primary">
|
<CardDescription>Track the journey from request to completion</CardDescription>
|
||||||
<Train />
|
</CardHeader>
|
||||||
<ArrowRight />
|
<CardContent className="flex flex-col gap-8">
|
||||||
|
<div className="relative flex w-full justify-between px-2">
|
||||||
|
{/* Progress Line */}
|
||||||
|
<div className="absolute top-4 left-0 h-0.5 w-full bg-muted">
|
||||||
|
<div
|
||||||
|
className="h-full bg-primary transition-all duration-500"
|
||||||
|
style={{ width: currentStageIndex >= 0 ? `${(currentStageIndex / (PROGRESS_STAGES.length - 1)) * 100}%` : '0%' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{PROGRESS_STAGES.map((stage, idx) => {
|
||||||
|
const isCompleted = idx < currentStageIndex;
|
||||||
|
const isActive = idx === currentStageIndex;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={stage.label} className="relative z-10 flex flex-col items-center gap-2">
|
||||||
|
<div className={cn(
|
||||||
|
"flex size-8 items-center justify-center rounded-full border-2 transition-all duration-300 bg-background",
|
||||||
|
isCompleted ? "border-primary text-primary" :
|
||||||
|
isActive ? "border-primary text-primary shadow-[0_0_10px_rgba(16,185,129,0.3)] scale-110" :
|
||||||
|
"border-muted text-muted-foreground"
|
||||||
|
)}>
|
||||||
|
{isCompleted ? <CheckCircle2 className="size-4" /> : <stage.icon className="size-4" />}
|
||||||
|
</div>
|
||||||
|
<span className={cn(
|
||||||
|
"text-[9px] font-bold uppercase tracking-widest",
|
||||||
|
isActive ? "text-primary" : "text-muted-foreground"
|
||||||
|
)}>
|
||||||
|
{stage.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
<RouteEndpoint
|
|
||||||
label="Destination"
|
<div className="flex flex-col gap-4 rounded-xl border border-border/50 bg-muted/20 p-5 md:flex-row md:items-center">
|
||||||
station={booking.destinationStation}
|
<div className="flex size-10 shrink-0 items-center justify-center rounded-full bg-background shadow-sm">
|
||||||
icon={<MapPin />}
|
{normalizedStatus === "CANCELLED" ? <AlertTriangle className="size-5 text-red-500" /> : <Info className="size-5 text-primary" />}
|
||||||
/>
|
</div>
|
||||||
</div>
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<h4 className={cn("text-sm font-black uppercase tracking-tight", statusConfig.color)}>
|
||||||
|
{statusConfig.title}
|
||||||
|
</h4>
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">
|
||||||
|
{statusConfig.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{normalizedStatus !== "CANCELLED" && normalizedStatus !== "COMPLETED" && (
|
||||||
|
<div className="ml-auto flex items-center gap-4 border-l border-border pl-6">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<p className="text-[9px] font-bold uppercase text-muted-foreground">Est. Waiting</p>
|
||||||
|
<p className="text-xs font-black text-foreground">1-2 Working Days</p>
|
||||||
|
</div>
|
||||||
|
<CreditCard className="size-5 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{booking.transportMode === "Multimodal" &&
|
<div className="grid grid-cols-1 gap-8 lg:grid-cols-3">
|
||||||
booking.legs &&
|
<div className="flex flex-col gap-8 lg:col-span-2">
|
||||||
booking.legs.length > 0 ? (
|
{/* Route & Core Service Card */}
|
||||||
<Card className="p-6">
|
<Card>
|
||||||
<div className="mb-4 flex items-center gap-2">
|
<CardHeader>
|
||||||
<Train />
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
<h2 className="text-lg font-semibold text-slate-900">
|
<Anchor className="size-4 text-primary" />
|
||||||
Transport Legs
|
Route & Service
|
||||||
</h2>
|
</CardTitle>
|
||||||
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
|
</CardHeader>
|
||||||
{booking.legs.length} legs
|
<CardContent className="flex flex-col gap-6">
|
||||||
</span>
|
<div className="flex flex-col items-center justify-between gap-4 rounded-xl border bg-muted/30 p-4 md:flex-row">
|
||||||
</div>
|
<RouteEndpoint
|
||||||
<div className="space-y-3">
|
label="Origin Yard"
|
||||||
{booking.legs.map((leg, i) => (
|
station={booking.originStation}
|
||||||
<div
|
icon={<MapPin />}
|
||||||
key={i}
|
/>
|
||||||
className="flex flex-col gap-3 rounded-2xl border bg-primary/5 p-4 md:flex-row md:items-center md:justify-between"
|
<div className="flex flex-col items-center gap-1 text-primary">
|
||||||
>
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex items-center gap-3">
|
<Train className="size-5" />
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-primary text-sm font-bold text-primary-foreground">
|
<ArrowRight className="size-4" />
|
||||||
{i + 1}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">
|
|
||||||
Leg {i + 1} · {leg.mode}
|
|
||||||
</p>
|
|
||||||
<p className="font-semibold text-slate-900">
|
|
||||||
{leg.from || "—"}
|
|
||||||
<ArrowRight className="mx-2 inline text-primary" />
|
|
||||||
{leg.to || "—"}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<Badge variant="outline" className="border-primary/20 bg-primary/5 text-[9px] font-bold uppercase">
|
||||||
|
Rail
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<RouteEndpoint
|
||||||
|
label="Destination Yard"
|
||||||
|
station={booking.destinationStation}
|
||||||
|
icon={<MapPin />}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||||
|
<InfoItem icon={<Layers />} label="Service" value="Rail & Forwarding" />
|
||||||
|
<InfoItem icon={<ShieldCheck />} label="Return" value="With Return" />
|
||||||
|
<InfoItem icon={<FileText />} label="Customs" value="Enabled" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Mile Services Card */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Truck className="size-4 text-primary" />
|
||||||
|
Mile Services
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<h3 className="border-l-4 border-primary pl-3 text-xs font-bold uppercase tracking-wide text-foreground">
|
||||||
|
First Mile
|
||||||
|
</h3>
|
||||||
|
<InfoItem label="Address" value="Inside Addis Ababa Yard, Gate 2" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<h3 className="border-l-4 border-primary pl-3 text-xs font-bold uppercase tracking-wide text-foreground">
|
||||||
|
Last Mile
|
||||||
|
</h3>
|
||||||
|
<p className="pl-4 text-xs text-muted-foreground italic">Not requested</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Cargo Specifications Card */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Package className="size-4 text-primary" />
|
||||||
|
Cargo Specifications
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-6">
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||||
|
<InfoItem icon={<Package />} label="Category" value={booking.cargoType} />
|
||||||
|
<InfoItem icon={<Weight />} label="Weight" value={`${booking.weightTons} Tons`} />
|
||||||
|
<InfoItem icon={<Ship />} label="Shipping Line" value="MSC" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<h3 className="text-xs font-bold uppercase tracking-wide text-foreground">Load Details</h3>
|
||||||
|
<div className="rounded-lg border overflow-hidden">
|
||||||
|
<table className="w-full text-left text-xs">
|
||||||
|
<thead className="bg-muted text-muted-foreground">
|
||||||
|
<tr>
|
||||||
|
<th className="px-3 py-2 font-semibold">Description</th>
|
||||||
|
<th className="px-3 py-2 font-semibold text-center">Unit</th>
|
||||||
|
<th className="px-3 py-2 font-semibold text-right">Value</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y">
|
||||||
|
<tr>
|
||||||
|
<td className="px-3 py-2 font-medium">Main Equipment</td>
|
||||||
|
<td className="px-3 py-2 text-center">20FT Container</td>
|
||||||
|
<td className="px-3 py-2 text-right">4 Units</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
</Card>
|
</div>
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="grid gap-6 md:grid-cols-2">
|
<div className="flex flex-col gap-8">
|
||||||
<DetailCard title="Customer & Cargo">
|
{/* Contract Card */}
|
||||||
<DetailRow
|
<Card className="border-primary/20 bg-primary/[0.02]">
|
||||||
icon={<User />}
|
<CardHeader>
|
||||||
label="Customer"
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
value={booking.customer}
|
<FileText className="size-4 text-primary" />
|
||||||
/>
|
Contract Info
|
||||||
<DetailRow
|
</CardTitle>
|
||||||
icon={<Package />}
|
</CardHeader>
|
||||||
label="Cargo Type"
|
<CardContent className="flex flex-col gap-4">
|
||||||
value={booking.cargoType}
|
<InfoItem label="Type" value="Renewal" />
|
||||||
/>
|
<InfoItem label="Ref" value="EDR-2024-88123" />
|
||||||
<DetailRow
|
<Separator />
|
||||||
icon={<Package />}
|
<div className="flex flex-wrap gap-2">
|
||||||
label="Container"
|
<Badge variant="outline" className="bg-background text-[9px]">
|
||||||
value={`${booking.containerCount} × ${booking.containerType}`}
|
Hazardous: No
|
||||||
/>
|
</Badge>
|
||||||
<DetailRow
|
<Badge variant="outline" className="bg-background text-[9px]">
|
||||||
icon={<Weight />}
|
Refrigerated: No
|
||||||
label="Weight"
|
</Badge>
|
||||||
value={`${booking.weightTons} tons`}
|
</div>
|
||||||
/>
|
</CardContent>
|
||||||
</DetailCard>
|
</Card>
|
||||||
|
|
||||||
<DetailCard title="Schedule & Mode">
|
{/* Notes Card */}
|
||||||
<DetailRow
|
<Card>
|
||||||
icon={<Train />}
|
<CardHeader>
|
||||||
label="Transport Mode"
|
<CardTitle className="text-base">Additional Info</CardTitle>
|
||||||
value={booking.transportMode}
|
</CardHeader>
|
||||||
/>
|
<CardContent className="flex flex-col gap-4">
|
||||||
<DetailRow
|
<div className="flex flex-col gap-1">
|
||||||
icon={<Calendar />}
|
<p className="text-[9px] font-bold uppercase tracking-tight text-muted-foreground">Description</p>
|
||||||
label="Requested Date"
|
<p className="text-xs text-foreground leading-relaxed italic">"{booking.cargoDescription}"</p>
|
||||||
value={booking.requestedDate}
|
</div>
|
||||||
/>
|
<Separator />
|
||||||
<DetailRow
|
<div className="flex flex-col gap-1">
|
||||||
icon={<Flag />}
|
<p className="text-[9px] font-bold uppercase tracking-tight text-muted-foreground">Instructions</p>
|
||||||
label="Priority"
|
<div className="rounded-lg bg-amber-50/50 border border-amber-100 p-2">
|
||||||
value={booking.priority}
|
<p className="text-xs text-amber-900 flex gap-2">
|
||||||
/>
|
<StickyNote className="size-3 shrink-0 mt-0.5 text-amber-500" />
|
||||||
</DetailCard>
|
{booking.specialInstructions}
|
||||||
|
</p>
|
||||||
<DetailCard title="Cargo Description">
|
</div>
|
||||||
<div className="flex items-start gap-3 text-sm text-slate-700">
|
</div>
|
||||||
<Package />
|
</CardContent>
|
||||||
<p className="leading-relaxed">{booking.cargoDescription}</p>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</DetailCard>
|
|
||||||
|
|
||||||
<DetailCard title="Special Instructions">
|
|
||||||
<div className="flex items-start gap-3 text-sm text-slate-700">
|
|
||||||
<StickyNote />
|
|
||||||
<p className="leading-relaxed">{booking.specialInstructions}</p>
|
|
||||||
</div>
|
|
||||||
</DetailCard>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -233,68 +369,64 @@ function RouteEndpoint({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary text-primary-foreground">
|
<div className="flex size-10 items-center justify-center rounded-xl bg-primary text-primary-foreground shadow-sm">
|
||||||
{icon}
|
{icon && <div className="[&_svg]:size-5">{icon}</div>}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="flex flex-col">
|
||||||
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">
|
<p className="text-[9px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||||
{label}
|
{label}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-lg font-semibold text-slate-900">{station}</p>
|
<p className="text-sm font-black text-foreground">{station}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DetailCard({
|
function InfoItem({
|
||||||
title,
|
icon,
|
||||||
children,
|
label,
|
||||||
}: {
|
value
|
||||||
title: string;
|
}: {
|
||||||
children: React.ReactNode;
|
icon?: React.ReactNode;
|
||||||
|
label: string;
|
||||||
|
value?: string | number | null
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Card className="p-6">
|
<div className="flex items-start gap-2">
|
||||||
<h2 className="text-lg font-semibold text-slate-900">{title}</h2>
|
{icon && <div className="mt-0.5 text-muted-foreground [&_svg]:size-3.5">{icon}</div>}
|
||||||
<div className="mt-4 space-y-3">{children}</div>
|
<div className="flex flex-col">
|
||||||
</Card>
|
<p className="text-[9px] font-bold uppercase tracking-tight text-muted-foreground">{label}</p>
|
||||||
);
|
<p className="text-xs font-bold text-foreground">{value || "—"}</p>
|
||||||
}
|
|
||||||
|
|
||||||
function DetailRow({
|
|
||||||
icon,
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
}: {
|
|
||||||
icon: React.ReactNode;
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<div className="mt-0.5 text-primary">{icon}</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<p className="text-xs font-medium text-slate-500">{label}</p>
|
|
||||||
<p className="mt-0.5 text-sm text-slate-900">{value}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatusBadge({ status }: { status: BookingStatus }) {
|
function StatusBadge({ status }: { status: string }) {
|
||||||
const styles: Record<BookingStatus, string> = {
|
const statusColors: Record<string, string> = {
|
||||||
Pending: "bg-amber-100 text-amber-700",
|
DRAFT: "bg-slate-50 text-slate-700 border-slate-200",
|
||||||
Confirmed: "bg-sky-100 text-sky-700",
|
RFQ_SUBMITTED: "bg-amber-50 text-amber-700 border-amber-200",
|
||||||
"In Transit": "bg-indigo-100 text-indigo-700",
|
QUOTATION_SENT: "bg-sky-50 text-sky-700 border-sky-200",
|
||||||
Delivered: "bg-emerald-100 text-emerald-700",
|
QUOTATION_APPROVED: "bg-emerald-50 text-emerald-700 border-emerald-200",
|
||||||
Cancelled: "bg-red-100 text-red-700",
|
QUOTATION_REJECTED: "bg-red-50 text-red-700 border-red-200",
|
||||||
|
PENDING_APPROVAL: "bg-amber-50 text-amber-700 border-amber-200",
|
||||||
|
APPROVED: "bg-emerald-50 text-emerald-700 border-emerald-200",
|
||||||
|
SIGNED_CUSTOMER: "bg-sky-50 text-sky-700 border-sky-200",
|
||||||
|
FULLY_EXECUTED: "bg-indigo-50 text-indigo-700 border-indigo-200",
|
||||||
|
PAID: "bg-emerald-50 text-emerald-700 border-emerald-200",
|
||||||
|
IN_TRANSIT: "bg-sky-50 text-sky-700 border-sky-200",
|
||||||
|
COMPLETED: "bg-indigo-50 text-indigo-700 border-indigo-200",
|
||||||
|
CANCELLED: "bg-red-50 text-red-700 border-red-200",
|
||||||
|
PENDING_CONSOLIDATION: "bg-amber-50 text-amber-700 border-amber-200",
|
||||||
|
CONSOLIDATED: "bg-indigo-50 text-indigo-700 border-indigo-200",
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<Badge
|
||||||
className={`inline-flex rounded-full px-3 py-1 text-xs font-medium ${styles[status]}`}
|
variant="outline"
|
||||||
|
className={cn("px-2 py-0.5 font-bold uppercase tracking-wider text-[9px]", statusColors[status] || "bg-muted")}
|
||||||
>
|
>
|
||||||
{status}
|
{status.replace(/_/g, ' ')}
|
||||||
</span>
|
</Badge>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
import type { ReactNode } from "react";
|
|
||||||
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogClose,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
|
|
||||||
export interface DeleteDocumentDialogProps {
|
|
||||||
documentName: string;
|
|
||||||
onConfirm?: () => void;
|
|
||||||
children: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function DeleteDocumentDialog({
|
|
||||||
documentName,
|
|
||||||
onConfirm,
|
|
||||||
children,
|
|
||||||
}: DeleteDocumentDialogProps) {
|
|
||||||
return (
|
|
||||||
<Dialog>
|
|
||||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
|
||||||
|
|
||||||
<DialogContent className="sm:max-w-md rounded-3xl">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="text-xl font-bold">
|
|
||||||
Delete document?
|
|
||||||
</DialogTitle>
|
|
||||||
|
|
||||||
<DialogDescription>
|
|
||||||
This will permanently delete{" "}
|
|
||||||
<span className="font-semibold text-slate-900">{documentName}</span>{" "}
|
|
||||||
and remove it from object storage. This action cannot be undone.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<DialogFooter className="mt-2">
|
|
||||||
<DialogClose asChild>
|
|
||||||
<Button variant="outline">Cancel</Button>
|
|
||||||
</DialogClose>
|
|
||||||
|
|
||||||
<DialogClose asChild>
|
|
||||||
<Button
|
|
||||||
onClick={onConfirm}
|
|
||||||
className="bg-red-600 text-white hover:bg-red-700"
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
</DialogClose>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,576 +0,0 @@
|
|||||||
import { useMemo, useState } from "react";
|
|
||||||
import {
|
|
||||||
CheckCircle2,
|
|
||||||
Clock,
|
|
||||||
Download,
|
|
||||||
Eye,
|
|
||||||
File,
|
|
||||||
FileImage,
|
|
||||||
FileSpreadsheet,
|
|
||||||
FileText,
|
|
||||||
Filter,
|
|
||||||
HardDrive,
|
|
||||||
LayoutGrid,
|
|
||||||
List,
|
|
||||||
MoreHorizontal,
|
|
||||||
Pencil,
|
|
||||||
Plus,
|
|
||||||
Search,
|
|
||||||
Trash2,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
import Breadcrumbs from "@/components/Breadcrumbs";
|
|
||||||
import NewDocumentPage from "./NewDocumentPage";
|
|
||||||
import DeleteDocumentDialog from "./DeleteDocumentDialog";
|
|
||||||
import {
|
|
||||||
documents,
|
|
||||||
formatBytes,
|
|
||||||
type DocumentFormat,
|
|
||||||
type DocumentRecord,
|
|
||||||
type DocumentStatus,
|
|
||||||
} from "./documents.mock";
|
|
||||||
import {
|
|
||||||
DataTable,
|
|
||||||
DataTableFooter,
|
|
||||||
type ColumnDef,
|
|
||||||
usePagination,
|
|
||||||
Button,
|
|
||||||
Card,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
CardDescription,
|
|
||||||
CardContent,
|
|
||||||
Input,
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
} from "@edr/ui-common";
|
|
||||||
|
|
||||||
type FilterValue = "All" | DocumentStatus;
|
|
||||||
type ViewMode = "grid" | "table";
|
|
||||||
|
|
||||||
const FILTERS: FilterValue[] = [
|
|
||||||
"All",
|
|
||||||
"Draft",
|
|
||||||
"Pending Review",
|
|
||||||
"Approved",
|
|
||||||
"Rejected",
|
|
||||||
"Expired",
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function DocumentsPage() {
|
|
||||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
|
||||||
const [filter, setFilter] = useState<FilterValue>("All");
|
|
||||||
const [query, setQuery] = useState("");
|
|
||||||
const [view, setView] = useState<ViewMode>("table");
|
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
|
||||||
const q = query.trim().toLowerCase();
|
|
||||||
return documents.filter((d) => {
|
|
||||||
if (filter !== "All" && d.status !== filter) return false;
|
|
||||||
if (!q) return true;
|
|
||||||
return (
|
|
||||||
d.name.toLowerCase().includes(q) ||
|
|
||||||
d.type.toLowerCase().includes(q) ||
|
|
||||||
d.linkedReference.toLowerCase().includes(q) ||
|
|
||||||
d.uploadedBy.toLowerCase().includes(q)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}, [filter, query]);
|
|
||||||
|
|
||||||
const total = filtered.length;
|
|
||||||
const pageCount = Math.ceil(total / pagination.pageSize);
|
|
||||||
const start = pagination.pageIndex * pagination.pageSize;
|
|
||||||
const end = Math.min(start + pagination.pageSize, total);
|
|
||||||
|
|
||||||
const paginatedData = useMemo(
|
|
||||||
() => filtered.slice(start, end),
|
|
||||||
[start, end, filtered],
|
|
||||||
);
|
|
||||||
|
|
||||||
const totalSize = documents.reduce((sum, d) => sum + d.sizeBytes, 0);
|
|
||||||
const approvedCount = documents.filter((d) => d.status === "Approved").length;
|
|
||||||
const pendingCount = documents.filter(
|
|
||||||
(d) => d.status === "Pending Review",
|
|
||||||
).length;
|
|
||||||
|
|
||||||
const columns: ColumnDef<DocumentRecord>[] = [
|
|
||||||
{
|
|
||||||
id: "document",
|
|
||||||
header: "Document",
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const doc = row.original;
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10">
|
|
||||||
<FormatIcon format={doc.format} />
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="truncate font-medium text-slate-900">{doc.name}</p>
|
|
||||||
<p className="text-xs text-slate-500">
|
|
||||||
{doc.format} · By {doc.uploadedBy}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "type",
|
|
||||||
header: "Type",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "linkedTo",
|
|
||||||
header: "Linked To",
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<div className="text-sm text-slate-700">
|
|
||||||
<p>{row.original.linkedReference}</p>
|
|
||||||
<p className="text-xs text-slate-500">{row.original.linkedType}</p>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "size",
|
|
||||||
header: "Size",
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<span className="text-sm text-slate-700">
|
|
||||||
{formatBytes(row.original.sizeBytes)}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "uploadedAt",
|
|
||||||
header: "Uploaded",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "status",
|
|
||||||
header: "Status",
|
|
||||||
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "actions",
|
|
||||||
size: 40,
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const doc = row.original;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="flex justify-end"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="outline" size="icon">
|
|
||||||
<MoreHorizontal />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem>
|
|
||||||
<Eye />
|
|
||||||
Preview
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem>
|
|
||||||
<Download />
|
|
||||||
Download
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<NewDocumentPage
|
|
||||||
mode="edit"
|
|
||||||
document={{
|
|
||||||
name: doc.name,
|
|
||||||
type: doc.type,
|
|
||||||
status: doc.status,
|
|
||||||
linkedType: doc.linkedType,
|
|
||||||
linkedReference: doc.linkedReference,
|
|
||||||
notes: doc.notes,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenuItem onSelect={(e: Event) => e.preventDefault()}>
|
|
||||||
<Pencil />
|
|
||||||
Edit
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</NewDocumentPage>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DeleteDocumentDialog documentName={doc.name}>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onSelect={(e: Event) => e.preventDefault()}
|
|
||||||
variant="destructive"
|
|
||||||
>
|
|
||||||
<Trash2 />
|
|
||||||
Delete
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DeleteDocumentDialog>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen p-6">
|
|
||||||
<div className="space-y-6">
|
|
||||||
<Breadcrumbs items={[{ label: "Documents" }]} />
|
|
||||||
|
|
||||||
<Card className="p-6 flex-row justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold tracking-tight text-slate-900">
|
|
||||||
Documents
|
|
||||||
</h1>
|
|
||||||
<p className="mt-1 text-sm text-secondary-foreground">
|
|
||||||
Manage freight documents linked to bookings, consignments, and
|
|
||||||
invoices.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col items-stretch gap-3 sm:flex-row sm:items-center">
|
|
||||||
<div className="relative w-full sm:w-80">
|
|
||||||
<Search className="pointer-events-none absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
|
||||||
<Input
|
|
||||||
type="search"
|
|
||||||
value={query}
|
|
||||||
onChange={(e) => {
|
|
||||||
setQuery(e.target.value);
|
|
||||||
setPagination({
|
|
||||||
pageIndex: 0,
|
|
||||||
pageSize: pagination.pageSize,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
placeholder="Search documents..."
|
|
||||||
className="pl-8!"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<NewDocumentPage>
|
|
||||||
<Button>
|
|
||||||
<Plus />
|
|
||||||
Upload Document
|
|
||||||
</Button>
|
|
||||||
</NewDocumentPage>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-4">
|
|
||||||
<Card>
|
|
||||||
<CardContent className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-slate-500">Total Documents</p>
|
|
||||||
<h3 className="mt-2 text-3xl font-bold text-slate-900">
|
|
||||||
{documents.length}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
|
||||||
<FileText />
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardContent className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-slate-500">Approved</p>
|
|
||||||
<h3 className="mt-2 text-3xl font-bold text-slate-900">
|
|
||||||
{approvedCount}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
|
||||||
<CheckCircle2 />
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardContent className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-slate-500">Pending Review</p>
|
|
||||||
<h3 className="mt-2 text-3xl font-bold text-slate-900">
|
|
||||||
{pendingCount}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
|
||||||
<Clock />
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardContent className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-slate-500">Storage Used</p>
|
|
||||||
<h3 className="mt-2 text-3xl font-bold text-slate-900">
|
|
||||||
{formatBytes(totalSize)}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
|
||||||
<HardDrive />
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card className="p-2">
|
|
||||||
<div className="flex flex-col gap-3 sm:flex-row md:items-center md:justify-between">
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{FILTERS.map((f) => {
|
|
||||||
const isActive = f === filter;
|
|
||||||
const count =
|
|
||||||
f === "All"
|
|
||||||
? documents.length
|
|
||||||
: documents.filter((d) => d.status === f).length;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={f}
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setFilter(f);
|
|
||||||
setPagination({
|
|
||||||
pageIndex: 0,
|
|
||||||
pageSize: pagination.pageSize,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
className={
|
|
||||||
isActive
|
|
||||||
? "inline-flex items-center gap-2 rounded-2xl bg-primary px-4 py-2 text-sm font-medium text-primary-foreground"
|
|
||||||
: "inline-flex items-center gap-2 rounded-2xl px-4 py-2 text-sm font-medium text-slate-600 transition hover:bg-primary/10 hover:text-primary"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{f}
|
|
||||||
<span
|
|
||||||
className={
|
|
||||||
isActive
|
|
||||||
? "rounded-full bg-white/20 px-2 py-0.5 text-xs"
|
|
||||||
: "rounded-full bg-slate-100 px-2 py-0.5 text-xs text-slate-600"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{count}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-1 self-start rounded-xl bg-slate-100 p-1 md:self-auto">
|
|
||||||
<ViewToggleButton
|
|
||||||
active={view === "grid"}
|
|
||||||
onClick={() => setView("grid")}
|
|
||||||
label="Grid view"
|
|
||||||
>
|
|
||||||
<LayoutGrid className="size-4" />
|
|
||||||
<span className="sm:inline">Grid</span>
|
|
||||||
</ViewToggleButton>
|
|
||||||
<ViewToggleButton
|
|
||||||
active={view === "table"}
|
|
||||||
onClick={() => setView("table")}
|
|
||||||
label="Table view"
|
|
||||||
>
|
|
||||||
<List className="size-4" />
|
|
||||||
<span className="sm:inline">Table</span>
|
|
||||||
</ViewToggleButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{paginatedData.length === 0 ? (
|
|
||||||
<Card className="p-12 text-center">
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
No documents match your filters.
|
|
||||||
</p>
|
|
||||||
</Card>
|
|
||||||
) : view === "grid" ? (
|
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
|
||||||
{paginatedData.map((doc) => (
|
|
||||||
<DocumentCard key={doc.id} doc={doc} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Card className="gap-0">
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between border-b">
|
|
||||||
<div>
|
|
||||||
<CardTitle>Document Library</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
All freight documents stored in the system.
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
|
||||||
<Button variant="secondary" size="sm">
|
|
||||||
<Filter />
|
|
||||||
Filter
|
|
||||||
</Button>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent className="px-0">
|
|
||||||
<DataTable
|
|
||||||
columns={columns}
|
|
||||||
data={paginatedData}
|
|
||||||
status="success"
|
|
||||||
onRowClick={() => { }}
|
|
||||||
pagination={{
|
|
||||||
pageIndex: pagination.pageIndex,
|
|
||||||
pageSize: pagination.pageSize,
|
|
||||||
pageCount: pageCount,
|
|
||||||
totalCount: total,
|
|
||||||
}}
|
|
||||||
tableOptions={{
|
|
||||||
state: { pagination },
|
|
||||||
onPaginationChange: setPagination,
|
|
||||||
}}
|
|
||||||
containerClassName="border-b shadow-none"
|
|
||||||
footer={DataTableFooter}
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ViewToggleButton({
|
|
||||||
active,
|
|
||||||
onClick,
|
|
||||||
label,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
active: boolean;
|
|
||||||
onClick: () => void;
|
|
||||||
label: string;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClick}
|
|
||||||
aria-label={label}
|
|
||||||
aria-pressed={active}
|
|
||||||
className={
|
|
||||||
active
|
|
||||||
? "inline-flex items-center gap-2 rounded-lg bg-white px-3 py-1.5 text-sm font-medium text-primary shadow-sm"
|
|
||||||
: "inline-flex items-center gap-2 rounded-lg px-3 py-1.5 text-sm font-medium text-slate-600 transition hover:text-primary"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function FormatIcon({ format }: { format: DocumentFormat }) {
|
|
||||||
if (format === "PDF") return <FileText />;
|
|
||||||
if (format === "DOCX") return <FileText />;
|
|
||||||
if (format === "XLSX") return <FileSpreadsheet />;
|
|
||||||
if (format === "PNG" || format === "JPG") return <FileImage />;
|
|
||||||
return <File />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function DocumentCard({ doc }: { doc: DocumentRecord }) {
|
|
||||||
return (
|
|
||||||
<Card className="p-5">
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<div className="flex items-start justify-between gap-3">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10">
|
|
||||||
<FormatIcon format={doc.format} />
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<p className="truncate text-sm font-semibold text-slate-900">
|
|
||||||
{doc.name}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-slate-500">{doc.type}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<StatusBadge status={doc.status} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
|
||||||
<MetaRow
|
|
||||||
label="Linked to"
|
|
||||||
value={`${doc.linkedType} · ${doc.linkedReference}`}
|
|
||||||
/>
|
|
||||||
<MetaRow label="Format" value={doc.format} />
|
|
||||||
<MetaRow label="Size" value={formatBytes(doc.sizeBytes)} />
|
|
||||||
<MetaRow label="Uploaded" value={doc.uploadedAt} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-xs text-slate-500">By {doc.uploadedBy}</p>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className="flex items-center justify-end gap-2 border-t pt-3"
|
|
||||||
onClick={(e: React.MouseEvent) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="outline" size="sm">
|
|
||||||
<MoreHorizontal />
|
|
||||||
Actions
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem>
|
|
||||||
<Eye />
|
|
||||||
Preview
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem>
|
|
||||||
<Download />
|
|
||||||
Download
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<NewDocumentPage
|
|
||||||
mode="edit"
|
|
||||||
document={{
|
|
||||||
name: doc.name,
|
|
||||||
type: doc.type,
|
|
||||||
status: doc.status,
|
|
||||||
linkedType: doc.linkedType,
|
|
||||||
linkedReference: doc.linkedReference,
|
|
||||||
notes: doc.notes,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DropdownMenuItem onSelect={(e: Event) => e.preventDefault()}>
|
|
||||||
<Pencil />
|
|
||||||
Edit
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</NewDocumentPage>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DeleteDocumentDialog documentName={doc.name}>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onSelect={(e: Event) => e.preventDefault()}
|
|
||||||
variant="destructive"
|
|
||||||
>
|
|
||||||
<Trash2 />
|
|
||||||
Delete
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DeleteDocumentDialog>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function MetaRow({ label, value }: { label: string; value: string }) {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-slate-500">{label}</p>
|
|
||||||
<p className="mt-0.5 text-sm font-medium text-slate-900">{value}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatusBadge({ status }: { status: DocumentStatus }) {
|
|
||||||
const styles: Record<DocumentStatus, string> = {
|
|
||||||
Draft: "bg-slate-100 text-slate-600",
|
|
||||||
"Pending Review": "bg-amber-100 text-amber-700",
|
|
||||||
Approved: "bg-emerald-100 text-emerald-700",
|
|
||||||
Rejected: "bg-red-100 text-red-700",
|
|
||||||
Expired: "bg-slate-200 text-slate-700",
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={`inline-flex rounded-full px-3 py-1 text-xs font-medium ${styles[status]}`}
|
|
||||||
>
|
|
||||||
{status}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,242 +0,0 @@
|
|||||||
import { useState, type ReactNode } from "react";
|
|
||||||
import { FileUp, Hash } from "lucide-react";
|
|
||||||
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
|
||||||
|
|
||||||
import { bookings } from "../bookings/bookings.mock";
|
|
||||||
import { consignments } from "../consignments/consignments.mock";
|
|
||||||
import { customers } from "../customers/customers.mock";
|
|
||||||
import type {
|
|
||||||
DocumentLinkType,
|
|
||||||
DocumentStatus,
|
|
||||||
DocumentType,
|
|
||||||
} from "./documents.mock";
|
|
||||||
|
|
||||||
export interface DocumentFormData {
|
|
||||||
name?: string;
|
|
||||||
type?: DocumentType;
|
|
||||||
status?: DocumentStatus;
|
|
||||||
linkedType?: DocumentLinkType;
|
|
||||||
linkedReference?: string;
|
|
||||||
notes?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NewDocumentPageProps {
|
|
||||||
mode?: "create" | "edit";
|
|
||||||
document?: DocumentFormData;
|
|
||||||
children?: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectClass =
|
|
||||||
"flex h-10 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 shadow-xs outline-none transition hover:border-slate-300 focus:border-[#10B981]/50 focus:ring-2 focus:ring-[#10B981]/20";
|
|
||||||
|
|
||||||
export default function NewDocumentPage({
|
|
||||||
mode = "create",
|
|
||||||
document,
|
|
||||||
children,
|
|
||||||
}: NewDocumentPageProps = {}) {
|
|
||||||
const isEdit = mode === "edit";
|
|
||||||
const title = isEdit ? "Edit Document" : "Upload Document";
|
|
||||||
const description = isEdit
|
|
||||||
? "Update document metadata."
|
|
||||||
: "Upload a freight document and link it to a booking, consignment, or invoice.";
|
|
||||||
const submitLabel = isEdit ? "Save Changes" : "Upload";
|
|
||||||
|
|
||||||
const [linkedType, setLinkedType] = useState<DocumentLinkType>(
|
|
||||||
document?.linkedType ?? "Booking",
|
|
||||||
);
|
|
||||||
const [fileName, setFileName] = useState<string>("");
|
|
||||||
|
|
||||||
const referenceOptions = (() => {
|
|
||||||
if (linkedType === "Booking") {
|
|
||||||
return bookings.map((b) => ({
|
|
||||||
value: b.reference,
|
|
||||||
label: `${b.reference} — ${b.customer}`,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
if (linkedType === "Consignment") {
|
|
||||||
return consignments.map((c) => ({
|
|
||||||
value: c.trackingNumber,
|
|
||||||
label: `${c.trackingNumber} — ${c.customer}`,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
if (linkedType === "Customer") {
|
|
||||||
return customers.map((c) => ({
|
|
||||||
value: c.company,
|
|
||||||
label: c.company,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
return [] as Array<{ value: string; label: string }>;
|
|
||||||
})();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
{children ?? <Button>{isEdit ? "Edit" : "Upload Document"}</Button>}
|
|
||||||
</DialogTrigger>
|
|
||||||
|
|
||||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-3xl rounded-3xl">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="text-2xl font-bold">{title}</DialogTitle>
|
|
||||||
<DialogDescription>{description}</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="grid gap-5 py-4 md:grid-cols-2">
|
|
||||||
{/* File picker — drop zone */}
|
|
||||||
{!isEdit ? (
|
|
||||||
<div className="md:col-span-2">
|
|
||||||
<Label>File</Label>
|
|
||||||
<label
|
|
||||||
htmlFor="document-file-input"
|
|
||||||
className="mt-1 flex cursor-pointer flex-col items-center justify-center gap-2 rounded-2xl border-2 border-dashed border-slate-200 bg-[#10B981]/5 p-8 text-center transition hover:border-[#10B981]/40 hover:bg-[#10B981]/10"
|
|
||||||
>
|
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[#10B981] text-white">
|
|
||||||
<FileUp className="h-6 w-6" />
|
|
||||||
</div>
|
|
||||||
<p className="text-sm font-medium text-slate-900">
|
|
||||||
{fileName || "Click to choose a file or drag it here"}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-slate-500">
|
|
||||||
PDF, DOCX, XLSX, PNG, JPG · max 25 MB
|
|
||||||
</p>
|
|
||||||
<input
|
|
||||||
id="document-file-input"
|
|
||||||
type="file"
|
|
||||||
className="hidden"
|
|
||||||
accept=".pdf,.docx,.xlsx,.png,.jpg,.jpeg"
|
|
||||||
onChange={(e) =>
|
|
||||||
setFileName(e.target.files?.[0]?.name ?? "")
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{/* Document Name */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Document Name *</Label>
|
|
||||||
<div className="relative">
|
|
||||||
<Hash className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
|
|
||||||
<Input
|
|
||||||
defaultValue={document?.name ?? ""}
|
|
||||||
placeholder="e.g. bill-of-lading-bk-026003.pdf"
|
|
||||||
className="pl-10"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Document Type */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Document Type *</Label>
|
|
||||||
<select
|
|
||||||
defaultValue={document?.type ?? "Bill of Lading"}
|
|
||||||
className={selectClass}
|
|
||||||
>
|
|
||||||
<option>Bill of Lading</option>
|
|
||||||
<option>Commercial Invoice</option>
|
|
||||||
<option>Packing List</option>
|
|
||||||
<option>Customs Declaration</option>
|
|
||||||
<option>Certificate of Origin</option>
|
|
||||||
<option>Insurance Certificate</option>
|
|
||||||
<option>Delivery Receipt</option>
|
|
||||||
<option>Proof of Delivery</option>
|
|
||||||
<option>Contract</option>
|
|
||||||
<option>Other</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Linked To */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Linked To</Label>
|
|
||||||
<select
|
|
||||||
value={linkedType}
|
|
||||||
onChange={(e) =>
|
|
||||||
setLinkedType(e.target.value as DocumentLinkType)
|
|
||||||
}
|
|
||||||
className={selectClass}
|
|
||||||
>
|
|
||||||
<option>Booking</option>
|
|
||||||
<option>Consignment</option>
|
|
||||||
<option>Shipment</option>
|
|
||||||
<option>Customer</option>
|
|
||||||
<option>Invoice</option>
|
|
||||||
<option>None</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Reference */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Reference</Label>
|
|
||||||
{referenceOptions.length > 0 ? (
|
|
||||||
<select
|
|
||||||
defaultValue={document?.linkedReference ?? ""}
|
|
||||||
className={selectClass}
|
|
||||||
>
|
|
||||||
<option value="" disabled>
|
|
||||||
Select {linkedType.toLowerCase()}
|
|
||||||
</option>
|
|
||||||
{referenceOptions.map((opt) => (
|
|
||||||
<option key={opt.value} value={opt.value}>
|
|
||||||
{opt.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
) : (
|
|
||||||
<Input
|
|
||||||
defaultValue={document?.linkedReference ?? ""}
|
|
||||||
placeholder={
|
|
||||||
linkedType === "None"
|
|
||||||
? "Not linked"
|
|
||||||
: `Enter ${linkedType.toLowerCase()} reference`
|
|
||||||
}
|
|
||||||
disabled={linkedType === "None"}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Status */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>Status</Label>
|
|
||||||
<select
|
|
||||||
defaultValue={document?.status ?? "Draft"}
|
|
||||||
className={selectClass}
|
|
||||||
>
|
|
||||||
<option>Draft</option>
|
|
||||||
<option>Pending Review</option>
|
|
||||||
<option>Approved</option>
|
|
||||||
<option>Rejected</option>
|
|
||||||
<option>Expired</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Notes */}
|
|
||||||
<div className="space-y-2 md:col-span-2">
|
|
||||||
<Label>Notes</Label>
|
|
||||||
<Textarea
|
|
||||||
defaultValue={document?.notes ?? ""}
|
|
||||||
placeholder="Any extra context for this document..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-3">
|
|
||||||
<Button variant="outline">Cancel</Button>
|
|
||||||
<Button className="bg-[#10B981] text-white hover:bg-[#10B981]/90">
|
|
||||||
{submitLabel}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
import { bookings } from "../bookings/bookings.mock";
|
|
||||||
|
|
||||||
export type DocumentType =
|
|
||||||
| "Bill of Lading"
|
|
||||||
| "Commercial Invoice"
|
|
||||||
| "Packing List"
|
|
||||||
| "Customs Declaration"
|
|
||||||
| "Certificate of Origin"
|
|
||||||
| "Insurance Certificate"
|
|
||||||
| "Delivery Receipt"
|
|
||||||
| "Proof of Delivery"
|
|
||||||
| "Contract"
|
|
||||||
| "Other";
|
|
||||||
|
|
||||||
export type DocumentFormat = "PDF" | "DOCX" | "XLSX" | "PNG" | "JPG";
|
|
||||||
|
|
||||||
export type DocumentStatus =
|
|
||||||
| "Draft"
|
|
||||||
| "Pending Review"
|
|
||||||
| "Approved"
|
|
||||||
| "Rejected"
|
|
||||||
| "Expired";
|
|
||||||
|
|
||||||
export type DocumentLinkType =
|
|
||||||
| "Booking"
|
|
||||||
| "Consignment"
|
|
||||||
| "Shipment"
|
|
||||||
| "Customer"
|
|
||||||
| "Invoice"
|
|
||||||
| "None";
|
|
||||||
|
|
||||||
export interface DocumentRecord {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
type: DocumentType;
|
|
||||||
format: DocumentFormat;
|
|
||||||
sizeBytes: number;
|
|
||||||
linkedType: DocumentLinkType;
|
|
||||||
linkedReference: string;
|
|
||||||
uploadedBy: string;
|
|
||||||
uploadedAt: string;
|
|
||||||
status: DocumentStatus;
|
|
||||||
notes: string;
|
|
||||||
/** Object key — meant for the future MinIO bucket. */
|
|
||||||
objectKey: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const types: DocumentType[] = [
|
|
||||||
"Bill of Lading",
|
|
||||||
"Commercial Invoice",
|
|
||||||
"Packing List",
|
|
||||||
"Customs Declaration",
|
|
||||||
"Certificate of Origin",
|
|
||||||
"Insurance Certificate",
|
|
||||||
"Delivery Receipt",
|
|
||||||
"Proof of Delivery",
|
|
||||||
"Contract",
|
|
||||||
"Other",
|
|
||||||
];
|
|
||||||
|
|
||||||
const formats: DocumentFormat[] = ["PDF", "DOCX", "XLSX", "PNG", "JPG"];
|
|
||||||
const statuses: DocumentStatus[] = [
|
|
||||||
"Draft",
|
|
||||||
"Pending Review",
|
|
||||||
"Approved",
|
|
||||||
"Rejected",
|
|
||||||
"Expired",
|
|
||||||
];
|
|
||||||
const uploaders = [
|
|
||||||
"John Doe",
|
|
||||||
"Sarah Bekele",
|
|
||||||
"Michael Chen",
|
|
||||||
"Aisha Mohamed",
|
|
||||||
"Daniel Worku",
|
|
||||||
];
|
|
||||||
|
|
||||||
function fileNameFor(type: DocumentType, ref: string, format: DocumentFormat) {
|
|
||||||
const slug = type.toLowerCase().replace(/\s+/g, "-");
|
|
||||||
return `${slug}-${ref.toLowerCase()}.${format.toLowerCase()}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const documents: DocumentRecord[] = Array.from(
|
|
||||||
{ length: 24 },
|
|
||||||
(_, i) => {
|
|
||||||
const id = i + 1;
|
|
||||||
const type = types[i % types.length] as DocumentType;
|
|
||||||
const format = formats[i % formats.length] as DocumentFormat;
|
|
||||||
const status = statuses[i % statuses.length] as DocumentStatus;
|
|
||||||
const uploadedAt = new Date(2026, 4, 1 + (i % 14));
|
|
||||||
|
|
||||||
const linkPick = i % 3;
|
|
||||||
let linkedType: DocumentLinkType;
|
|
||||||
let linkedReference: string;
|
|
||||||
if (linkPick === 0) {
|
|
||||||
const booking = bookings[
|
|
||||||
i % bookings.length
|
|
||||||
] as (typeof bookings)[number];
|
|
||||||
linkedType = "Booking";
|
|
||||||
linkedReference = booking.reference;
|
|
||||||
} else if (linkPick === 1) {
|
|
||||||
linkedType = "Consignment";
|
|
||||||
linkedReference = "s";
|
|
||||||
} else {
|
|
||||||
linkedType = "Invoice";
|
|
||||||
linkedReference = `INV-2026-${String(id).padStart(4, "0")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const name = fileNameFor(type, linkedReference, format);
|
|
||||||
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
name,
|
|
||||||
type,
|
|
||||||
format,
|
|
||||||
sizeBytes: 50_000 + ((i * 73_421) % 4_000_000),
|
|
||||||
linkedType,
|
|
||||||
linkedReference,
|
|
||||||
uploadedBy: uploaders[i % uploaders.length] as string,
|
|
||||||
uploadedAt: uploadedAt.toISOString().slice(0, 10),
|
|
||||||
status,
|
|
||||||
notes:
|
|
||||||
i % 3 === 0
|
|
||||||
? "Original signed copy."
|
|
||||||
: i % 3 === 1
|
|
||||||
? "Scanned from physical document."
|
|
||||||
: "Generated by system.",
|
|
||||||
objectKey: `edr-freight/${linkedType.toLowerCase()}/${linkedReference}/${name}`,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export function getDocumentById(
|
|
||||||
id: number | string,
|
|
||||||
): DocumentRecord | undefined {
|
|
||||||
const numericId = typeof id === "string" ? Number(id) : id;
|
|
||||||
return documents.find((d) => d.id === numericId);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatBytes(bytes: number): string {
|
|
||||||
if (bytes < 1024) return `${bytes} B`;
|
|
||||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
||||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
||||||
}
|
|
||||||
@@ -73,17 +73,29 @@ export type CustomerStatus = "Active" | "Pending" | "Inactive";
|
|||||||
export type CustomerType = "Importer" | "Exporter" | "Supplier";
|
export type CustomerType = "Importer" | "Exporter" | "Supplier";
|
||||||
|
|
||||||
export interface ICustomer extends BaseEntity {
|
export interface ICustomer extends BaseEntity {
|
||||||
name: string;
|
userId: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
email: string;
|
email: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
company?: string | null;
|
companyName: string;
|
||||||
customerType: CustomerType;
|
companyEmail: string;
|
||||||
status: CustomerStatus;
|
companyPhone: string;
|
||||||
tinNumber?: string | null;
|
companyLocation: string;
|
||||||
city?: string | null;
|
companyAddress: string;
|
||||||
country?: string | null;
|
contactPersonName: string;
|
||||||
address?: string | null;
|
contactPersonPhone: string;
|
||||||
taxId?: string | null;
|
tinNumber: string;
|
||||||
|
vatNumber?: string | null;
|
||||||
|
fanNumber: string;
|
||||||
|
generalManagerName: string;
|
||||||
|
generalManagerEmail: string;
|
||||||
|
generalManagerPhone: string;
|
||||||
|
poaName?: string | null;
|
||||||
|
poaPhone?: string | null;
|
||||||
|
poaAddress?: string | null;
|
||||||
|
poaEmail?: string | null;
|
||||||
|
poaLocation?: string | null;
|
||||||
notes?: string | null;
|
notes?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user