diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index aae725618..053ce0337 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -1,5 +1,6 @@ import { Boxes, + Building2, Container, FileText, LayoutDashboard, @@ -27,6 +28,8 @@ import BookingContractPage from "./pages/bookings/BookingContractPage"; import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage"; import BookingRequestsPage from "./pages/bookings/BookingRequestsPage"; import NewBookingPage from "./pages/bookings/NewBookingPage"; +import CustomerDetailPage from "./pages/customers/CustomerDetailPage"; +import CustomersPage from "./pages/customers/CustomersPage"; import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page"; import DemoUser2Page from "./pages/dashboard/demo/DemoUser2Page"; import MyProfilePage from "./pages/dashboard/MyProfilePage"; @@ -49,13 +52,13 @@ import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources" import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect"; import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage"; import TrainDetailPage from "./pages/trains/TrainDetailPage"; -import CargoTypesPage from "./pages/ruleEngine/CargoTypesPage"; -import TrainScheduleV2ListPage from "./pages/trainScheduling/TrainScheduleV2ListPage"; -import BatchBoardPage from "./pages/trainScheduling/BatchBoardPage"; -import BatchScheduleDetailPage from "./pages/trainScheduling/BatchScheduleDetailPage"; -import TrainScheduleTrackPage from "./pages/trainScheduling/TrainScheduleTrackPage"; -import TrainScheduleV2DetailPage from "./pages/trainScheduling/TrainScheduleV2DetailPage"; -import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage"; +import CargoTypesPage from "./pages/ruleEngine/CargoTypesPage"; +import TrainScheduleV2ListPage from "./pages/trainScheduling/TrainScheduleV2ListPage"; +import BatchBoardPage from "./pages/trainScheduling/BatchBoardPage"; +import BatchScheduleDetailPage from "./pages/trainScheduling/BatchScheduleDetailPage"; +import TrainScheduleTrackPage from "./pages/trainScheduling/TrainScheduleTrackPage"; +import TrainScheduleV2DetailPage from "./pages/trainScheduling/TrainScheduleV2DetailPage"; +import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage"; import ArrivalQueuePage from "./pages/warehouses/ArrivalQueuePage"; import DispatchQueuePage from "./pages/warehouses/DispatchQueuePage"; import InventoryInquiryPage from "./pages/warehouses/InventoryInquiryPage"; @@ -87,6 +90,11 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ href: "/dashboard/booking-requests", icon: , }, + { + label: "Customers", + href: "/dashboard/customers", + icon: , + }, { label: "Payments", href: "/dashboard/payments", @@ -338,6 +346,8 @@ const App = () => { } /> + } /> + } /> } /> } /> + + {children} + + + ); +} + +export default TableCard; diff --git a/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx b/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx new file mode 100644 index 000000000..4ebdc675d --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx @@ -0,0 +1,209 @@ +import { Badge, Group, Tooltip } from "@mantine/core"; + +import type { + CompanyProfile, + CompanyStatus, + CompanyType, + CustomerBookingStatus, + CustomerPaymentStatus, + ProfileStatus, + ProfileType, +} from "@/types/customer"; + +import { humanize } from "./format"; + +const badgeStyle = { + fontSize: "0.7rem", + letterSpacing: "0.04em", + whiteSpace: "nowrap" as const, +}; + +/** Shared status palette — active/paid green, pending amber, terminal red. */ +const STATUS_COLOR: Record = { + active: "edr-green", + pending: "yellow", + suspended: "orange", + blacklisted: "red", +}; + +const COMPANY_TYPE_COLOR: Record = { + customer: "edr-green", + freight_forwarder: "blue", + dj_freight_forwarder: "indigo", + transporter: "grape", +}; + +const PROFILE_TYPE_COLOR: Record = { + importer: "teal", + exporter: "cyan", + freight_forwarder: "blue", + dj_freight_forwarder: "indigo", + transporter: "grape", +}; + +export function CompanyStatusBadge({ status }: { status: CompanyStatus }) { + return ( + + {status} + + ); +} + +export function CompanyTypeBadge({ type }: { type: CompanyType }) { + return ( + + {humanize(type)} + + ); +} + +/** + * Profile chips for a company row: one chip per role (Importer / Exporter / …) + * carrying its reference code. Caps at three (a company has at most three + * profiles); any extra collapse into a `+N` chip. + */ +export function ProfileChips({ + profiles, + max = 3, +}: { + profiles: CompanyProfile[]; + max?: number; +}) { + if (!profiles.length) { + return ( + + No profiles + + ); + } + + const shown = profiles.slice(0, max); + const extra = profiles.length - shown.length; + + return ( + + {shown.map((profile) => ( + + + {humanize(profile.type)} · {profile.reference} + + + ))} + {extra > 0 ? ( + + +{extra} + + ) : null} + + ); +} + +export function ProfileTypeBadge({ type }: { type: ProfileType }) { + return ( + + {humanize(type)} + + ); +} + +export function ProfileStatusBadge({ status }: { status: ProfileStatus }) { + return ( + + {status} + + ); +} + +const BOOKING_STATUS_COLOR: Record = { + DRAFT: "gray", + SUBMITTED: "yellow", + PENDING_APPROVAL: "yellow", + APPROVED: "cyan", + PAID: "edr-green", + IN_TRANSIT: "blue", + COMPLETED: "indigo", + REJECTED: "red", + CANCELLED: "red", +}; + +export function BookingStatusBadge({ status }: { status: CustomerBookingStatus }) { + return ( + + {humanize(status)} + + ); +} + +const PAYMENT_STATUS_COLOR: Record = { + "action-required": "orange", + processing: "yellow", + success: "edr-green", + failed: "red", + canceled: "gray", + refunded: "grape", +}; + +export function PaymentStatusBadge({ status }: { status: CustomerPaymentStatus }) { + return ( + + {humanize(status)} + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/customers/format.ts b/apps/edr-freight-web/backoffice/src/components/customers/format.ts new file mode 100644 index 000000000..0397c1cee --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/customers/format.ts @@ -0,0 +1,38 @@ +/** Shared formatting helpers for the customer-management pages. */ + +/** snake_case / SCREAMING_CASE → Title Case. */ +export function humanize(value: string): string { + return value + .toLowerCase() + .split(/[_\s]+/) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); +} + +export function formatDate(value: string | null | undefined): string { + if (!value) return "—"; + const d = new Date(value); + return Number.isNaN(d.getTime()) + ? "—" + : d.toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + }); +} + +export function formatMoney(amount: number, currency: string): string { + return new Intl.NumberFormat(undefined, { + style: "currency", + currency, + maximumFractionDigits: 0, + }).format(amount); +} + +export function formatBytes(bytes: number): string { + if (!bytes) return "0 B"; + const units = ["B", "KB", "MB", "GB"]; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + const value = bytes / Math.pow(1024, i); + return `${value.toFixed(i === 0 ? 0 : 1)} ${units[i]}`; +} diff --git a/apps/edr-freight-web/backoffice/src/components/customers/index.ts b/apps/edr-freight-web/backoffice/src/components/customers/index.ts new file mode 100644 index 000000000..f94ca5a73 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/customers/index.ts @@ -0,0 +1,11 @@ +export { + BookingStatusBadge, + CompanyStatusBadge, + CompanyTypeBadge, + PaymentStatusBadge, + ProfileChips, + ProfileStatusBadge, + ProfileTypeBadge, +} from "./badges"; +export { formatBytes, formatDate, formatMoney, humanize } from "./format"; +export { TableCard, type TableCardProps } from "./TableCard"; diff --git a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts index e3e1921de..65667fe19 100644 --- a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts @@ -1,4 +1,5 @@ import type { BookingListFilter } from "@/services/bookings.service"; +import type { CompanyListFilter } from "@/types/customer"; import type { RuleEngineListParams } from "@/services/ruleEngine/ruleEngine.service"; import type { TrainScheduleFilters } from "@/types/trainScheduling"; import type { FleetResourceSlug } from "@/pages/fleet/config/resources"; @@ -26,8 +27,12 @@ export const QUERY_KEYS = { CUSTOMERS: { ROOT: ["customers"] as const, - list: () => ["customers", "list"] as const, + list: (filter?: CompanyListFilter) => + ["customers", "list", filter ?? {}] as const, byId: (id: string) => ["customers", "detail", id] as const, + bookings: (id: string) => ["customers", "detail", id, "bookings"] as const, + documents: (id: string) => ["customers", "detail", id, "documents"] as const, + payments: (id: string) => ["customers", "detail", id, "payments"] as const, }, BOOKINGS: { diff --git a/apps/edr-freight-web/backoffice/src/hooks/customers/useCustomers.ts b/apps/edr-freight-web/backoffice/src/hooks/customers/useCustomers.ts new file mode 100644 index 000000000..11af924ca --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/hooks/customers/useCustomers.ts @@ -0,0 +1,44 @@ +import { useQuery } from "@tanstack/react-query"; + +import { QUERY_KEYS } from "@/constants/QUERY_KEYS"; +import { customersService } from "@/services/customers.service"; +import type { CompanyListFilter } from "@/types/customer"; + +export function useCustomerList(filter: CompanyListFilter) { + return useQuery({ + queryKey: QUERY_KEYS.CUSTOMERS.list(filter), + queryFn: () => customersService.list(filter), + }); +} + +export function useCustomerDetail(id: string | undefined) { + return useQuery({ + queryKey: QUERY_KEYS.CUSTOMERS.byId(id ?? ""), + queryFn: () => customersService.getById(id!), + enabled: Boolean(id), + }); +} + +export function useCustomerBookings(id: string | undefined) { + return useQuery({ + queryKey: QUERY_KEYS.CUSTOMERS.bookings(id ?? ""), + queryFn: () => customersService.bookingsFor(id!), + enabled: Boolean(id), + }); +} + +export function useCustomerDocuments(id: string | undefined) { + return useQuery({ + queryKey: QUERY_KEYS.CUSTOMERS.documents(id ?? ""), + queryFn: () => customersService.documentsFor(id!), + enabled: Boolean(id), + }); +} + +export function useCustomerPayments(id: string | undefined) { + return useQuery({ + queryKey: QUERY_KEYS.CUSTOMERS.payments(id ?? ""), + queryFn: () => customersService.paymentsFor(id!), + enabled: Boolean(id), + }); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx index 1c1571966..dd492281b 100644 --- a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx @@ -1,12 +1,555 @@ -import FeaturePlaceholder from "@/components/FeaturePlaceholder"; +import { + ActionIcon, + Box, + Button, + Card, + Center, + Container, + Group, + Loader, + SimpleGrid, + Stack, + Tabs, + Text, +} from "@mantine/core"; +import { + ArrowLeft, + ArrowRight, + Banknote, + Download, + FileText, + IdCard, + LayoutGrid, + Package, +} from "lucide-react"; +import { useMemo } from "react"; +import { useNavigate, useParams } from "react-router-dom"; -const CustomerDetailPage = () => { +import { + BookingStatusBadge, + CompanyStatusBadge, + CompanyTypeBadge, + PaymentStatusBadge, + ProfileChips, + ProfileStatusBadge, + ProfileTypeBadge, + TableCard, + formatBytes, + formatDate, + formatMoney, + humanize, +} from "@/components/customers"; +import { KpiStrip, PageContainer, PageHeader } from "@/components/page"; +import { + useCustomerBookings, + useCustomerDetail, + useCustomerDocuments, + useCustomerPayments, +} from "@/hooks/customers/useCustomers"; +import type { + CompanyProfile, + CustomerBooking, + CustomerDocument, + CustomerPayment, +} from "@/types/customer"; +import { DataTable, type ColumnDef } from "@edr/ui-common"; + +function InfoField({ label, value }: { label: string; value?: string | null }) { return ( - + + + {label} + + + {value && value.trim() ? value : "—"} + + ); -}; +} -export default CustomerDetailPage; +function tableStatus(query: { isLoading: boolean; isError: boolean }) { + return query.isLoading ? "loading" : query.isError ? "error" : "success"; +} + +export default function CustomerDetailPage() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + + const { data: company, isLoading } = useCustomerDetail(id); + const bookingsQuery = useCustomerBookings(id); + const documentsQuery = useCustomerDocuments(id); + const paymentsQuery = useCustomerPayments(id); + + const bookings = bookingsQuery.data ?? []; + const documents = documentsQuery.data ?? []; + const payments = paymentsQuery.data ?? []; + + const totalPaid = useMemo( + () => + payments + .filter((p) => p.status === "success") + .reduce((sum, p) => sum + p.amount, 0), + [payments], + ); + const paidCurrency = payments[0]?.currency ?? "ETB"; + + const profileColumns: ColumnDef[] = useMemo( + () => [ + { + id: "type", + header: "Role", + cell: ({ row }) => , + }, + { + id: "reference", + header: "Reference", + cell: ({ row }) => ( + + {row.original.reference} + + ), + }, + { + id: "businessLicense", + header: "Business license", + cell: ({ row }) => ( + + {row.original.businessLicense || "—"} + + ), + }, + { + id: "status", + header: "Status", + cell: ({ row }) => , + }, + { + id: "createdAt", + header: "Registered", + meta: { headerClassName: "text-right", cellClassName: "text-right" }, + cell: ({ row }) => ( + + {formatDate(row.original.createdAt)} + + ), + }, + ], + [], + ); + + const bookingColumns: ColumnDef[] = useMemo( + () => [ + { + id: "reference", + header: "Booking", + cell: ({ row }) => ( + + {row.original.reference} + + ), + }, + { + id: "route", + header: "Route", + cell: ({ row }) => { + const b = row.original; + return ( + + + {b.originLabel} + + + + {b.destinationLabel} + + + ); + }, + }, + { + id: "type", + header: "Type", + cell: ({ row }) => ( + + {humanize(row.original.tradeDirection)} ·{" "} + {humanize(row.original.freightType)} + + ), + }, + { + id: "status", + header: "Status", + cell: ({ row }) => , + }, + { + id: "amount", + header: "Amount", + meta: { headerClassName: "text-right", cellClassName: "text-right" }, + cell: ({ row }) => ( + + {formatMoney(row.original.totalAmount, row.original.currency)} + + ), + }, + { + id: "createdAt", + header: "Created", + meta: { headerClassName: "text-right", cellClassName: "text-right" }, + cell: ({ row }) => ( + + {formatDate(row.original.createdAt)} + + ), + }, + ], + [], + ); + + const documentColumns: ColumnDef[] = useMemo( + () => [ + { + id: "name", + header: "Document", + cell: ({ row }) => ( + + + + {row.original.name} + + + ), + }, + { + id: "code", + header: "Type", + cell: ({ row }) => ( + + {humanize(row.original.code)} + + ), + }, + { + id: "size", + header: "Size", + cell: ({ row }) => ( + + {formatBytes(row.original.size)} + + ), + }, + { + id: "uploadedAt", + header: "Uploaded", + cell: ({ row }) => ( + + {formatDate(row.original.uploadedAt)} + + ), + }, + { + id: "actions", + header: "", + meta: { headerClassName: "text-right", cellClassName: "text-right" }, + cell: ({ row }) => ( + + + + ), + }, + ], + [], + ); + + const paymentColumns: ColumnDef[] = useMemo( + () => [ + { + id: "reference", + header: "Payment", + cell: ({ row }) => ( + + {row.original.reference} + + ), + }, + { + id: "booking", + header: "Booking", + cell: ({ row }) => ( + + {row.original.bookingReference} + + ), + }, + { + id: "method", + header: "Method", + cell: ({ row }) => ( + + {humanize(row.original.method)} + + ), + }, + { + id: "status", + header: "Status", + cell: ({ row }) => , + }, + { + id: "paidAt", + header: "Paid", + cell: ({ row }) => ( + + {formatDate(row.original.paidAt)} + + ), + }, + { + id: "amount", + header: "Amount", + meta: { headerClassName: "text-right", cellClassName: "text-right" }, + cell: ({ row }) => ( + + {formatMoney(row.original.amount, row.original.currency)} + + ), + }, + ], + [], + ); + + if (isLoading) { + return ( + + + + ); + } + + if (!company) { + return ( + + + Customer not found + } + onClick={() => navigate("/dashboard/customers")} + > + Back to customers + + + + ); + } + + return ( + + + + + + } + /> + + + + }> + Overview + + }> + Bookings + + }> + Documents + + }> + Payments + + + + {/* OVERVIEW */} + + + + + + + + Company information + + + + + + + + + + + + + + + + + + + + + + + + + + Role profiles + + + + + + + + + + + + + + {/* BOOKINGS */} + + + void bookingsQuery.refetch(), + } + : undefined + } + /> + + + + {/* DOCUMENTS */} + + + void documentsQuery.refetch(), + } + : undefined + } + /> + + + + {/* PAYMENTS */} + + + void paymentsQuery.refetch(), + } + : undefined + } + /> + + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx b/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx index 8d9158381..8d5384cc3 100644 --- a/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx @@ -1,12 +1,271 @@ -import FeaturePlaceholder from "@/components/FeaturePlaceholder"; +import { + ActionIcon, + Box, + Card, + Group, + Stack, + Text, + TextInput, +} from "@mantine/core"; +import { useDebouncedValue } from "@mantine/hooks"; +import { + Building2, + CheckCircle2, + Clock, + Mail, + Phone, + RefreshCw, + Search, + ShieldOff, + Users, + X, +} from "lucide-react"; +import { useMemo, useState } from "react"; +import { useNavigate } from "react-router-dom"; -const CustomersPage = () => { - return ( - - ); +import { + CompanyStatusBadge, + CompanyTypeBadge, + ProfileChips, + formatDate, +} from "@/components/customers"; +import { KpiStrip, PageContainer, PageHeader } from "@/components/page"; +import { useCustomerList } from "@/hooks/customers/useCustomers"; +import { MOCK_COMPANIES } from "@/pages/customers/customers.mock"; +import type { Company } from "@/types/customer"; +import { + DataTable, + DataTableFooter, + usePagination, + type ColumnDef, +} from "@edr/ui-common"; + +/** KPI counts are derived from the full fixture set (mock-only). */ +const KPIS = { + total: MOCK_COMPANIES.length, + active: MOCK_COMPANIES.filter((c) => c.status === "active").length, + pending: MOCK_COMPANIES.filter((c) => c.status === "pending").length, + blacklisted: MOCK_COMPANIES.filter((c) => c.status === "blacklisted").length, }; -export default CustomersPage; +export default function CustomersPage() { + const navigate = useNavigate(); + const { pagination, setPagination } = usePagination({ pageSize: 10 }); + const [query, setQuery] = useState(""); + const [debouncedQuery] = useDebouncedValue(query, 300); + + const filter = useMemo( + () => ({ + page: pagination.pageIndex + 1, + pageSize: pagination.pageSize, + search: debouncedQuery, + }), + [pagination.pageIndex, pagination.pageSize, debouncedQuery], + ); + + const { data, isLoading, isError, refetch, isFetching } = + useCustomerList(filter); + + const rows = data?.items ?? []; + const total = data?.total ?? 0; + const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize)); + + const columns: ColumnDef[] = useMemo( + () => [ + { + id: "company", + header: "Company", + cell: ({ row }) => { + const c = row.original; + return ( + + + + + + + + {c.name} + + + + + TIN {c.tin} + {c.country ? ` · ${c.country}` : ""} + + + + ); + }, + }, + { + id: "profiles", + header: "Profiles", + cell: ({ row }) => , + }, + { + id: "status", + header: "Status", + cell: ({ row }) => , + }, + { + id: "contact", + header: "Contact", + cell: ({ row }) => { + const c = row.original; + return ( + + {c.contactPersonName ? ( + + {c.contactPersonName} + + ) : null} + {c.phone ? ( + + {c.phone} + + ) : null} + {c.email ? ( + + {c.email} + + ) : null} + + ); + }, + }, + { + id: "created", + header: "Registered", + meta: { headerClassName: "text-right", cellClassName: "text-right" }, + cell: ({ row }) => ( + + {formatDate(row.original.createdAt)} + + ), + }, + ], + [], + ); + + return ( + + void refetch()} + > + + + } + /> + + + + + + + + } + value={query} + onChange={(e) => setQuery(e.target.value)} + rightSection={ + query ? ( + setQuery("")} + > + + + ) : null + } + style={{ flex: 1, minWidth: "240px" }} + radius="lg" + /> + + {total} record{total !== 1 ? "s" : ""} + + + + + + + navigate(`/dashboard/customers/${row.id}`)} + emptyMessage={ + debouncedQuery + ? "No companies match your search." + : "No companies yet." + } + error={ + isError + ? { + message: "Failed to load customers.", + onRetry: () => void refetch(), + } + : undefined + } + pagination={{ + pageIndex: pagination.pageIndex, + pageSize: pagination.pageSize, + pageCount, + totalCount: total, + }} + tableOptions={{ + state: { pagination }, + onPaginationChange: setPagination, + manualPagination: true, + pageCount, + }} + containerClassName="border-0 shadow-none bg-transparent" + footer={DataTableFooter} + /> + + + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/customers/customers.mock.ts b/apps/edr-freight-web/backoffice/src/pages/customers/customers.mock.ts new file mode 100644 index 000000000..a0f48996c --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/customers/customers.mock.ts @@ -0,0 +1,722 @@ +/** + * Mock fixtures for the Customer Management dashboard. + * + * Shapes match the backend `Company` / `CompanyProfile` entities and the + * lightweight related-data types in `@/types/customer`. Swap the service layer + * to live endpoints later — these fixtures (and the helpers below) are the only + * thing that has to change. + */ +import type { + Company, + CustomerBooking, + CustomerDocument, + CustomerPayment, +} from "@/types/customer"; + +const iso = (date: string) => new Date(date).toISOString(); + +export const MOCK_COMPANIES: Company[] = [ + { + id: "c1", + name: "Abyssinia Import & Export PLC", + type: "customer", + status: "active", + tin: "0012345678", + vatNumber: "VAT-100245", + fanNumber: "FAN-0099", + country: "Ethiopia", + address: "Bole Road, Addis Ababa", + phone: "+251 911 234 567", + email: "ops@abyssinia-ie.com", + contactPersonName: "Selam Bekele", + contactPersonPhone: "+251 911 234 567", + generalManagerName: "Dawit Tadesse", + generalManagerEmail: "dawit@abyssinia-ie.com", + generalManagerPhone: "+251 911 000 111", + website: "https://abyssinia-ie.com", + attributes: null, + companyProfiles: [ + { + id: "p1", + companyId: "c1", + type: "importer", + reference: "IMP-00123", + status: "active", + businessLicense: "BL-IMP-4521", + createdAt: iso("2024-02-10"), + updatedAt: iso("2024-02-10"), + }, + { + id: "p2", + companyId: "c1", + type: "exporter", + reference: "EXP-00456", + status: "active", + businessLicense: "BL-EXP-7782", + createdAt: iso("2024-03-01"), + updatedAt: iso("2024-03-01"), + }, + ], + createdAt: iso("2024-02-10"), + updatedAt: iso("2025-01-12"), + }, + { + id: "c2", + name: "Horn Logistics Freight Forwarding", + type: "freight_forwarder", + status: "active", + tin: "0023456789", + vatNumber: "VAT-200112", + fanNumber: null, + country: "Ethiopia", + address: "Kality Industrial Zone, Addis Ababa", + phone: "+251 911 765 432", + email: "dispatch@hornlogistics.et", + contactPersonName: "Yonas Girma", + contactPersonPhone: "+251 911 765 432", + generalManagerName: "Hanna Mekonnen", + generalManagerEmail: "hanna@hornlogistics.et", + generalManagerPhone: "+251 911 222 333", + website: "https://hornlogistics.et", + attributes: null, + companyProfiles: [ + { + id: "p3", + companyId: "c2", + type: "freight_forwarder", + reference: "FFW-01001", + status: "active", + businessLicense: "BL-FFW-1190", + createdAt: iso("2023-11-05"), + updatedAt: iso("2023-11-05"), + }, + { + id: "p4", + companyId: "c2", + type: "importer", + reference: "IMP-00890", + status: "pending", + businessLicense: null, + createdAt: iso("2024-06-18"), + updatedAt: iso("2024-06-18"), + }, + { + id: "p5", + companyId: "c2", + type: "exporter", + reference: "EXP-00777", + status: "active", + businessLicense: "BL-EXP-3310", + createdAt: iso("2024-07-01"), + updatedAt: iso("2024-07-01"), + }, + ], + createdAt: iso("2023-11-05"), + updatedAt: iso("2025-02-20"), + }, + { + id: "c3", + name: "Djibouti Gateway Forwarders", + type: "dj_freight_forwarder", + status: "active", + tin: "0034567890", + vatNumber: null, + fanNumber: "FAN-0451", + country: "Djibouti", + address: "Port de Djibouti, Djibouti City", + phone: "+253 21 35 00 00", + email: "ops@djgateway.dj", + contactPersonName: "Amina Idriss", + contactPersonPhone: "+253 77 12 34 56", + generalManagerName: "Omar Farah", + generalManagerEmail: "omar@djgateway.dj", + generalManagerPhone: "+253 77 99 88 77", + website: null, + attributes: null, + companyProfiles: [ + { + id: "p6", + companyId: "c3", + type: "dj_freight_forwarder", + reference: "DJF-02050", + status: "active", + businessLicense: "BL-DJF-0088", + createdAt: iso("2023-09-12"), + updatedAt: iso("2023-09-12"), + }, + ], + createdAt: iso("2023-09-12"), + updatedAt: iso("2024-12-30"), + }, + { + id: "c4", + name: "Rift Valley Transporters", + type: "transporter", + status: "suspended", + tin: "0045678901", + vatNumber: "VAT-300988", + fanNumber: null, + country: "Ethiopia", + address: "Adama Ring Road, Adama", + phone: "+251 912 345 678", + email: "fleet@riftvalley-tr.com", + contactPersonName: "Bereket Alemu", + contactPersonPhone: "+251 912 345 678", + generalManagerName: "Meron Haile", + generalManagerEmail: "meron@riftvalley-tr.com", + generalManagerPhone: "+251 912 111 222", + website: null, + attributes: null, + companyProfiles: [ + { + id: "p7", + companyId: "c4", + type: "transporter", + reference: "TRP-03012", + status: "suspended", + businessLicense: "BL-TRP-2204", + createdAt: iso("2024-01-22"), + updatedAt: iso("2024-10-04"), + }, + ], + createdAt: iso("2024-01-22"), + updatedAt: iso("2024-10-04"), + }, + { + id: "c5", + name: "Nile Trading & General Import", + type: "customer", + status: "pending", + tin: "0056789012", + vatNumber: null, + fanNumber: null, + country: "Ethiopia", + address: "Merkato, Addis Ababa", + phone: "+251 913 456 789", + email: "info@niletrading.et", + contactPersonName: "Sara Tesfaye", + contactPersonPhone: "+251 913 456 789", + generalManagerName: "Kebede Worku", + generalManagerEmail: "kebede@niletrading.et", + generalManagerPhone: "+251 913 000 999", + website: null, + attributes: null, + companyProfiles: [ + { + id: "p8", + companyId: "c5", + type: "importer", + reference: "IMP-01456", + status: "pending", + businessLicense: null, + createdAt: iso("2025-03-14"), + updatedAt: iso("2025-03-14"), + }, + ], + createdAt: iso("2025-03-14"), + updatedAt: iso("2025-03-14"), + }, + { + id: "c6", + name: "Sheba Steel & Cement Importers", + type: "customer", + status: "active", + tin: "0067890123", + vatNumber: "VAT-400777", + fanNumber: "FAN-0512", + country: "Ethiopia", + address: "Lebu, Addis Ababa", + phone: "+251 914 567 890", + email: "procure@shebasteel.com", + contactPersonName: "Tigist Assefa", + contactPersonPhone: "+251 914 567 890", + generalManagerName: "Robel Negash", + generalManagerEmail: "robel@shebasteel.com", + generalManagerPhone: "+251 914 222 444", + website: "https://shebasteel.com", + attributes: null, + companyProfiles: [ + { + id: "p9", + companyId: "c6", + type: "importer", + reference: "IMP-01987", + status: "active", + businessLicense: "BL-IMP-9001", + createdAt: iso("2024-05-09"), + updatedAt: iso("2024-05-09"), + }, + ], + createdAt: iso("2024-05-09"), + updatedAt: iso("2025-04-02"), + }, + { + id: "c7", + name: "Awash Agro Export Union", + type: "customer", + status: "active", + tin: "0078901234", + vatNumber: "VAT-500321", + fanNumber: null, + country: "Ethiopia", + address: "Awash, Afar", + phone: "+251 915 678 901", + email: "export@awashagro.coop", + contactPersonName: "Lensa Diriba", + contactPersonPhone: "+251 915 678 901", + generalManagerName: "Gemechu Bayisa", + generalManagerEmail: "gemechu@awashagro.coop", + generalManagerPhone: "+251 915 333 555", + website: null, + attributes: null, + companyProfiles: [ + { + id: "p10", + companyId: "c7", + type: "exporter", + reference: "EXP-02233", + status: "active", + businessLicense: "BL-EXP-6650", + createdAt: iso("2024-08-19"), + updatedAt: iso("2024-08-19"), + }, + { + id: "p11", + companyId: "c7", + type: "importer", + reference: "IMP-02234", + status: "active", + businessLicense: "BL-IMP-6651", + createdAt: iso("2024-08-19"), + updatedAt: iso("2024-08-19"), + }, + ], + createdAt: iso("2024-08-19"), + updatedAt: iso("2025-05-10"), + }, + { + id: "c8", + name: "Blacklisted Holdings Trading", + type: "customer", + status: "blacklisted", + tin: "0089012345", + vatNumber: null, + fanNumber: null, + country: "Ethiopia", + address: "Unknown", + phone: "+251 916 789 012", + email: "contact@blacklistedholdings.com", + contactPersonName: "N/A", + contactPersonPhone: "+251 916 789 012", + generalManagerName: "N/A", + generalManagerEmail: null, + generalManagerPhone: null, + website: null, + attributes: null, + companyProfiles: [ + { + id: "p12", + companyId: "c8", + type: "importer", + reference: "IMP-02999", + status: "blacklisted", + businessLicense: null, + createdAt: iso("2023-12-01"), + updatedAt: iso("2024-02-15"), + }, + ], + createdAt: iso("2023-12-01"), + updatedAt: iso("2024-02-15"), + }, + { + id: "c9", + name: "Lalibela Coffee Exporters", + type: "customer", + status: "active", + tin: "0090123456", + vatNumber: "VAT-600145", + fanNumber: "FAN-0623", + country: "Ethiopia", + address: "Sidama, Hawassa", + phone: "+251 917 890 123", + email: "trade@lalibelacoffee.com", + contactPersonName: "Eyob Tariku", + contactPersonPhone: "+251 917 890 123", + generalManagerName: "Frehiwot Solomon", + generalManagerEmail: "frehiwot@lalibelacoffee.com", + generalManagerPhone: "+251 917 444 666", + website: "https://lalibelacoffee.com", + attributes: null, + companyProfiles: [ + { + id: "p13", + companyId: "c9", + type: "exporter", + reference: "EXP-03456", + status: "active", + businessLicense: "BL-EXP-8842", + createdAt: iso("2024-04-03"), + updatedAt: iso("2024-04-03"), + }, + ], + createdAt: iso("2024-04-03"), + updatedAt: iso("2025-03-22"), + }, + { + id: "c10", + name: "Unity Multimodal Forwarders", + type: "freight_forwarder", + status: "active", + tin: "0101234567", + vatNumber: "VAT-700998", + fanNumber: null, + country: "Ethiopia", + address: "Sululta, Oromia", + phone: "+251 918 901 234", + email: "ops@unitymultimodal.com", + contactPersonName: "Helen Tsegaye", + contactPersonPhone: "+251 918 901 234", + generalManagerName: "Nahom Berhanu", + generalManagerEmail: "nahom@unitymultimodal.com", + generalManagerPhone: "+251 918 555 777", + website: null, + attributes: null, + companyProfiles: [ + { + id: "p14", + companyId: "c10", + type: "freight_forwarder", + reference: "FFW-04088", + status: "active", + businessLicense: "BL-FFW-5521", + createdAt: iso("2024-09-28"), + updatedAt: iso("2024-09-28"), + }, + { + id: "p15", + companyId: "c10", + type: "transporter", + reference: "TRP-04089", + status: "active", + businessLicense: "BL-TRP-5522", + createdAt: iso("2024-09-28"), + updatedAt: iso("2024-09-28"), + }, + { + id: "p16", + companyId: "c10", + type: "exporter", + reference: "EXP-04090", + status: "pending", + businessLicense: null, + createdAt: iso("2025-01-15"), + updatedAt: iso("2025-01-15"), + }, + ], + createdAt: iso("2024-09-28"), + updatedAt: iso("2025-01-15"), + }, +]; + +/** Bookings keyed by companyId. */ +const BOOKINGS_BY_COMPANY: Record = { + c1: [ + { + id: "b1", + reference: "BK-2025-0481", + status: "PAID", + tradeDirection: "IMPORT", + freightType: "CONTAINER", + originLabel: "Djibouti Port", + destinationLabel: "Mojo Dry Port", + totalAmount: 482000, + currency: "ETB", + scheduledDate: iso("2025-05-20"), + createdAt: iso("2025-05-02"), + }, + { + id: "b2", + reference: "BK-2025-0512", + status: "IN_TRANSIT", + tradeDirection: "IMPORT", + freightType: "CONTAINER", + originLabel: "Djibouti Port", + destinationLabel: "Indode Terminal", + totalAmount: 356500, + currency: "ETB", + scheduledDate: iso("2025-06-01"), + createdAt: iso("2025-05-18"), + }, + { + id: "b3", + reference: "BK-2025-0298", + status: "COMPLETED", + tradeDirection: "EXPORT", + freightType: "BULK", + originLabel: "Mojo Dry Port", + destinationLabel: "Djibouti Port", + totalAmount: 198000, + currency: "ETB", + scheduledDate: iso("2025-03-30"), + createdAt: iso("2025-03-12"), + }, + ], + c2: [ + { + id: "b4", + reference: "BK-2025-0633", + status: "PENDING_APPROVAL", + tradeDirection: "IMPORT", + freightType: "CONTAINER", + originLabel: "Djibouti Port", + destinationLabel: "Mojo Dry Port", + totalAmount: 720000, + currency: "ETB", + scheduledDate: null, + createdAt: iso("2025-06-10"), + }, + { + id: "b5", + reference: "BK-2025-0588", + status: "PAID", + tradeDirection: "EXPORT", + freightType: "CONTAINER", + originLabel: "Indode Terminal", + destinationLabel: "Djibouti Port", + totalAmount: 540000, + currency: "ETB", + scheduledDate: iso("2025-06-15"), + createdAt: iso("2025-05-29"), + }, + ], + c6: [ + { + id: "b6", + reference: "BK-2025-0701", + status: "SUBMITTED", + tradeDirection: "IMPORT", + freightType: "BULK", + originLabel: "Djibouti Port", + destinationLabel: "Mojo Dry Port", + totalAmount: 1250000, + currency: "ETB", + scheduledDate: null, + createdAt: iso("2025-06-18"), + }, + ], + c7: [ + { + id: "b7", + reference: "BK-2025-0344", + status: "COMPLETED", + tradeDirection: "EXPORT", + freightType: "BULK", + originLabel: "Mojo Dry Port", + destinationLabel: "Djibouti Port", + totalAmount: 2100, + currency: "USD", + scheduledDate: iso("2025-04-05"), + createdAt: iso("2025-03-20"), + }, + { + id: "b8", + reference: "BK-2025-0410", + status: "CANCELLED", + tradeDirection: "EXPORT", + freightType: "CONTAINER", + originLabel: "Indode Terminal", + destinationLabel: "Djibouti Port", + totalAmount: 3400, + currency: "USD", + scheduledDate: null, + createdAt: iso("2025-04-22"), + }, + ], +}; + +/** Documents keyed by companyId. */ +const DOCUMENTS_BY_COMPANY: Record = { + c1: [ + { + id: "d1", + name: "Business License 2025.pdf", + code: "business_license", + mimeType: "application/pdf", + size: 482_113, + uploadedAt: iso("2025-01-12"), + url: "#", + }, + { + id: "d2", + name: "VAT Registration.pdf", + code: "vat_certificate", + mimeType: "application/pdf", + size: 211_544, + uploadedAt: iso("2024-02-11"), + url: "#", + }, + { + id: "d3", + name: "Trade Agreement - Signed.pdf", + code: "contract", + mimeType: "application/pdf", + size: 1_204_882, + uploadedAt: iso("2025-05-03"), + url: "#", + }, + ], + c2: [ + { + id: "d4", + name: "Forwarding License.pdf", + code: "business_license", + mimeType: "application/pdf", + size: 365_002, + uploadedAt: iso("2023-11-06"), + url: "#", + }, + { + id: "d5", + name: "Company Profile.docx", + code: "company_profile", + mimeType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + size: 92_440, + uploadedAt: iso("2024-07-02"), + url: "#", + }, + ], + c6: [ + { + id: "d6", + name: "Import License.pdf", + code: "business_license", + mimeType: "application/pdf", + size: 410_223, + uploadedAt: iso("2024-05-10"), + url: "#", + }, + ], + c7: [ + { + id: "d7", + name: "Export Permit.pdf", + code: "export_permit", + mimeType: "application/pdf", + size: 298_770, + uploadedAt: iso("2024-08-20"), + url: "#", + }, + { + id: "d8", + name: "Quality Certificate.jpg", + code: "quality_certificate", + mimeType: "image/jpeg", + size: 1_882_001, + uploadedAt: iso("2025-03-21"), + url: "#", + }, + ], +}; + +/** Payments keyed by companyId. */ +const PAYMENTS_BY_COMPANY: Record = { + c1: [ + { + id: "pay1", + reference: "PMT-77120", + bookingReference: "BK-2025-0481", + amount: 482000, + currency: "ETB", + method: "telebirr", + status: "success", + paidAt: iso("2025-05-04"), + createdAt: iso("2025-05-03"), + }, + { + id: "pay2", + reference: "PMT-77450", + bookingReference: "BK-2025-0512", + amount: 356500, + currency: "ETB", + method: "cbe-birr", + status: "processing", + paidAt: null, + createdAt: iso("2025-05-19"), + }, + { + id: "pay3", + reference: "PMT-71002", + bookingReference: "BK-2025-0298", + amount: 198000, + currency: "ETB", + method: "telebirr", + status: "success", + paidAt: iso("2025-03-14"), + createdAt: iso("2025-03-13"), + }, + ], + c2: [ + { + id: "pay4", + reference: "PMT-78900", + bookingReference: "BK-2025-0588", + amount: 540000, + currency: "ETB", + method: "cbe-birr", + status: "success", + paidAt: iso("2025-05-30"), + createdAt: iso("2025-05-29"), + }, + { + id: "pay5", + reference: "PMT-79120", + bookingReference: "BK-2025-0633", + amount: 720000, + currency: "ETB", + method: "card", + status: "action-required", + paidAt: null, + createdAt: iso("2025-06-10"), + }, + ], + c7: [ + { + id: "pay6", + reference: "PMT-70044", + bookingReference: "BK-2025-0344", + amount: 2100, + currency: "USD", + method: "card", + status: "success", + paidAt: iso("2025-03-22"), + createdAt: iso("2025-03-21"), + }, + { + id: "pay7", + reference: "PMT-70410", + bookingReference: "BK-2025-0410", + amount: 3400, + currency: "USD", + method: "card", + status: "refunded", + paidAt: iso("2025-04-23"), + createdAt: iso("2025-04-22"), + }, + ], +}; + +export function getCompanyById(id: string): Company | undefined { + return MOCK_COMPANIES.find((c) => c.id === id); +} + +export function getBookingsFor(companyId: string): CustomerBooking[] { + return BOOKINGS_BY_COMPANY[companyId] ?? []; +} + +export function getDocumentsFor(companyId: string): CustomerDocument[] { + return DOCUMENTS_BY_COMPANY[companyId] ?? []; +} + +export function getPaymentsFor(companyId: string): CustomerPayment[] { + return PAYMENTS_BY_COMPANY[companyId] ?? []; +} diff --git a/apps/edr-freight-web/backoffice/src/services/customers.service.ts b/apps/edr-freight-web/backoffice/src/services/customers.service.ts new file mode 100644 index 000000000..cc9f47f0b --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/services/customers.service.ts @@ -0,0 +1,72 @@ +/** + * Customers service. + * + * Currently backed by in-memory mock fixtures (`customers.mock.ts`); the public + * surface mirrors the other services (e.g. `bookings.service.ts`) — async + * methods returning `{ items, total }` / detail objects — so it can be pointed + * at the live `/companies` API later without touching the hooks or pages. + */ +import { + getBookingsFor, + getCompanyById, + getDocumentsFor, + getPaymentsFor, + MOCK_COMPANIES, +} from "@/pages/customers/customers.mock"; +import type { + Company, + CompanyListFilter, + CustomerBooking, + CustomerDocument, + CustomerPayment, + PaginatedCompanies, +} from "@/types/customer"; + +/** Simulate network latency so loading states are visible during UI work. */ +const delay = (value: T, ms = 350): Promise => + new Promise((resolve) => setTimeout(() => resolve(value), ms)); + +function matchesSearch(company: Company, search: string): boolean { + const q = search.trim().toLowerCase(); + if (!q) return true; + return ( + company.name.toLowerCase().includes(q) || + company.tin.toLowerCase().includes(q) || + company.email?.toLowerCase().includes(q) === true || + company.companyProfiles.some((p) => p.reference.toLowerCase().includes(q)) + ); +} + +export const customersService = { + list(filter: CompanyListFilter): Promise { + const { page, pageSize, search = "", type, status } = filter; + + const filtered = MOCK_COMPANIES.filter( + (c) => + matchesSearch(c, search) && + (!type || c.type === type) && + (!status || c.status === status), + ); + + const start = (page - 1) * pageSize; + const items = filtered.slice(start, start + pageSize); + + return delay({ items, total: filtered.length }); + }, + + getById(id: string): Promise { + return delay(getCompanyById(id)); + }, + + bookingsFor(companyId: string): Promise { + return delay(getBookingsFor(companyId)); + }, + + documentsFor(companyId: string): Promise { + return delay(getDocumentsFor(companyId)); + }, + + paymentsFor(companyId: string): Promise { + return delay(getPaymentsFor(companyId)); + }, +}; diff --git a/apps/edr-freight-web/backoffice/src/types/customer.ts b/apps/edr-freight-web/backoffice/src/types/customer.ts new file mode 100644 index 000000000..49b2dc850 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/types/customer.ts @@ -0,0 +1,154 @@ +/** + * Customer-management types for the freight backoffice. + * + * These mirror the backend `Company` / `CompanyProfile` entities + * (apps/edr-freight-api/src/modules/companies/entities) plus a few lightweight + * shapes for the related data shown on the detail page (bookings / documents / + * payments). The UI is currently driven by mock data, but the shapes match the + * API so the data layer can be swapped to live endpoints with no UI changes. + */ + +/** Mirrors backend `CompanyType`. */ +export type CompanyType = + | "customer" + | "freight_forwarder" + | "dj_freight_forwarder" + | "transporter"; + +/** Mirrors backend `CompanyStatus`. */ +export type CompanyStatus = "active" | "pending" | "suspended" | "blacklisted"; + +/** Mirrors backend `ProfileType` (the role a company plays). */ +export type ProfileType = + | "importer" + | "exporter" + | "freight_forwarder" + | "dj_freight_forwarder" + | "transporter"; + +/** Mirrors backend `ProfileStatus`. */ +export type ProfileStatus = "active" | "pending" | "suspended" | "blacklisted"; + +/** A single role a company is registered for, with its reference code. */ +export interface CompanyProfile { + id: string; + companyId: string; + type: ProfileType; + reference: string; + status: ProfileStatus; + businessLicense?: string | null; + attributes?: Record | null; + createdAt: string; + updatedAt: string; +} + +/** Mirrors backend `Company` (+ its `companyProfiles`). */ +export interface Company { + id: string; + name: string; + type: CompanyType; + status: CompanyStatus; + tin: string; + vatNumber?: string | null; + fanNumber?: string | null; + country: string; + address?: string | null; + phone?: string | null; + email?: string | null; + contactPersonName?: string | null; + contactPersonPhone?: string | null; + generalManagerName?: string | null; + generalManagerEmail?: string | null; + generalManagerPhone?: string | null; + website?: string | null; + attributes?: Record | null; + companyProfiles: CompanyProfile[]; + createdAt: string; + updatedAt: string; +} + +/** Query parameters for the company list. */ +export interface CompanyListFilter { + page: number; + pageSize: number; + search?: string; + type?: CompanyType; + status?: CompanyStatus; +} + +/** Standard paginated list envelope (matches the bookings service shape). */ +export interface PaginatedCompanies { + items: Company[]; + total: number; +} + +/* ------------------------------------------------------------------ * + * Related data shown on the customer detail page (mocked for now). * + * ------------------------------------------------------------------ */ + +export type CustomerBookingStatus = + | "DRAFT" + | "SUBMITTED" + | "PENDING_APPROVAL" + | "APPROVED" + | "PAID" + | "IN_TRANSIT" + | "COMPLETED" + | "REJECTED" + | "CANCELLED"; + +export interface CustomerBooking { + id: string; + reference: string; + status: CustomerBookingStatus; + tradeDirection: "IMPORT" | "EXPORT"; + freightType: "CONTAINER" | "BULK"; + originLabel: string; + destinationLabel: string; + totalAmount: number; + currency: "ETB" | "USD"; + scheduledDate?: string | null; + createdAt: string; +} + +export interface CustomerDocument { + id: string; + name: string; + /** File-upload setting code, e.g. "business_license", "contract". */ + code: string; + mimeType: string; + /** Size in bytes. */ + size: number; + uploadedAt: string; + url?: string | null; +} + +export type CustomerPaymentStatus = + | "action-required" + | "processing" + | "success" + | "failed" + | "canceled" + | "refunded"; + +export type CustomerPaymentMethod = + | "telebirr" + | "cbe-birr" + | "ebirr" + | "waafi" + | "card" + | "dmoney" + | "cac-bank"; + +export interface CustomerPayment { + id: string; + reference: string; + /** Booking reference the payment settles. */ + bookingReference: string; + amount: number; + currency: "ETB" | "USD"; + method: CustomerPaymentMethod; + status: CustomerPaymentStatus; + paidAt?: string | null; + createdAt: string; +}