Merge branch 'dev' into freight/feat/invoice

This commit is contained in:
Nathnael
2026-06-29 10:58:12 +00:00
425 changed files with 46006 additions and 7705 deletions

View File

@@ -44,14 +44,23 @@ export class ResponseTransformInterceptor<T> implements NestInterceptor<
if (
shouldFlatten &&
data &&
typeof data === "object" &&
!Array.isArray(data)
typeof data === "object"
) {
return {
success: true,
...data,
timestamp: new Date().toISOString(),
};
if(!Array.isArray(data)){
return {
success: true,
...data,
timestamp: new Date().toISOString(),
};
}
else {
return data;
}
}
if(path.startsWith("/api/positions/hierarchy") || path.startsWith("/api/positions/current")){
return data;
}
return {

View File

@@ -210,7 +210,8 @@ export class DMoneyProvider implements PaymentProvider {
business_type: "OnlineMerchant" as const,
title: `${input.orderRef}`,
total_amount: totalAmount,
trans_currency: this.currency,
// Charge the currency the caller already converted to; never relabel it provider-side.
trans_currency: input.currency,
timeout_express: this.timeoutExpress,
...(redirectUrl ? { redirect_url: redirectUrl } : {}),
},
@@ -341,9 +342,6 @@ export class DMoneyProvider implements PaymentProvider {
private get language(): string {
return this.config.get<string>("dmoney.language") ?? "en";
}
private get currency(): string {
return this.config.get<string>("dmoney.currency") ?? "FDJ";
}
private get privateKey(): string {
return this.config.get<string>("dmoney.privateKey") ?? "";
}

View File

@@ -208,8 +208,9 @@ export class WaafiProvider implements PaymentProvider {
transactionInfo: {
referenceId: input.merchantOrderId,
amount: this.toAmount(input.amountMinor),
// Waafi has no ETB; `waafi.currency` overrides the booking currency when set.
currency: this.currency || input.currency,
// Charge exactly the currency the caller already converted to (passenger/freight resolve
// the method's settlement currency). The provider never relabels the currency.
currency: input.currency,
description: `${input.orderRef}`,
},
},
@@ -298,9 +299,6 @@ export class WaafiProvider implements PaymentProvider {
private get paymentMethod(): string {
return this.config.get<string>("waafi.paymentMethod") ?? "MWALLET_ACCOUNT";
}
private get currency(): string {
return this.config.get<string>("waafi.currency") ?? "";
}
private get successUrl(): string {
return this.config.get<string>("waafi.successUrl") ?? "";
}

View File

@@ -0,0 +1,652 @@
import type { BaseEntity } from "../common";
import type { IYard } from "./index";
// ── Contract classification ─────────────────────────────────────────────────
/**
* A contract is the legal/commercial agreement (scope + unit rates, no
* quantities). ONE_TIME allows a single active shipment booking at a time;
* GENERAL allows many shipment cycles over the validity window.
*/
export enum ContractKind {
OneTime = "ONE_TIME",
General = "GENERAL",
}
export type ContractTradeDirection = "IMPORT" | "EXPORT" | "DOMESTIC";
export enum ContractFreightType {
Container = "CONTAINER",
Bulk = "BULK",
}
/** Who created a shipment booking under a contract. */
export type BookingCreatedByRole = "CUSTOMER" | "GL_ET" | "STAFF";
/** GL region that owns a clearance step / milestone. */
export type OwnerRegion = "ET" | "DJ" | "OPS" | "CUST";
// ── Contract status machine (doc Appendix A) ────────────────────────────────
export const CONTRACT_STATUSES = [
"DRAFT",
"SUBMITTED",
"PRICE_CHANGED_PENDING_CONFIRM",
"CHANGES_REQUESTED",
"PENDING_APPROVAL",
"APPROVED",
"APPROVED_PENDING_SIGNATURE",
"CONTRACT_READY",
"SIGNED_CUSTOMER",
// transport-only execution (Path A)
"FULLY_EXECUTED", // ONE_TIME
"CONTRACT_ACTIVE", // GENERAL
// customs clearance execution (Path B, pre-booking)
"AWAITING_CLEARANCE_DOCUMENTS",
"CLEARANCE_UNDER_REVIEW",
"CLEARANCE_READY_FOR_BOOKING",
"ACTIVE_SHIPMENT_IN_PROGRESS",
// terminal
"CONTRACT_CLOSED",
"EXPIRED",
"REJECTED",
"CANCELLED",
// renewal branch
"RENEWAL_DRAFT",
"RENEWAL_SUBMITTED",
"RENEWAL_PENDING_APPROVAL",
"AMENDMENTS_PROPOSED",
"ARCHIVED",
] as const;
export type ContractStatus = (typeof CONTRACT_STATUSES)[number];
/**
* Pre-booking clearance gate carried on the contract (Path B only). Separate
* from the contract status so transport-only contracts stay NOT_APPLICABLE.
*/
export const CONTRACT_CLEARANCE_STATUSES = [
"NOT_APPLICABLE",
"AWAITING_DOCUMENTS",
"DOCUMENTS_UNDER_REVIEW",
"CLEARANCE_READY_FOR_BOOKING", // Path B — GL may create the booking
"SELF_CLEARED", // Path A — Operations approved; customer may book
"ACTIVE_SHIPMENT_IN_PROGRESS",
] as const;
export type ContractClearanceStatus =
(typeof CONTRACT_CLEARANCE_STATUSES)[number];
/** Statuses where the customer may edit contract fields. */
export const CONTRACT_CUSTOMER_EDITABLE_STATUSES: ContractStatus[] = [
"DRAFT",
"CHANGES_REQUESTED",
];
// ── Pricing (unit-rate display at contract phase, doc §9.1) ─────────────────
export type ContractRateUnit =
| "per_container"
| "per_ton"
| "per_item"
| "per_km"
| "flat";
export interface ContractUnitRateLineItem {
code: string;
label: string;
unit: ContractRateUnit;
unitPrice: number;
/** "20ft" | "40ft" when the rate is container-size specific. */
containerSize?: string | null;
/** "is_hazardous" | "is_reefer" when this is a conditional surcharge. */
conditionalOn?: string | null;
cargoTypeCode?: string | null;
}
/** Contract `pricing_breakdown` shape — unit rates, no totals. */
export interface ContractPricingBreakdown {
displayMode: "UNIT_RATES";
currency: string;
lineItems: ContractUnitRateLineItem[];
generatedAt: string;
}
// ── Contract child collections ──────────────────────────────────────────────
export interface IContractRoute {
id: string;
contractId: string;
originYardId: string;
originYard?: IYard | null;
destinationYardId: string;
destinationYard?: IYard | null;
/** Road billing distance; null for rail-only. */
km?: number | null;
sortOrder: number;
}
export interface IContractCargoScope {
id: string;
contractId: string;
/** "20ft" | "40ft"; null for bulk. */
containerSize?: string | null;
cargoTypeId?: string | null;
cargoFreeText?: string | null;
/**
* GENERAL contracts: total quantity bookable across all shipments on this line
* (containers per size, or tons/items for bulk). null = uncapped / ONE_TIME.
*/
quantityCap?: number | null;
}
/** Remaining bookable quantity per cargo-scope line (GENERAL contracts). */
export interface ContractCapacityLine {
containerSize?: string | null;
cargoTypeId?: string | null;
cap: number | null;
booked: number;
remaining: number | null;
}
export interface IContractRateSnapshot {
id: string;
contractId: string;
rateId?: string | null;
rateCode: string;
description?: string | null;
unitPrice: number;
unitOfMeasure: ContractRateUnit;
currency: string;
containerSize?: string | null;
isSurcharge: boolean;
conditionalOn?: string | null;
}
export type ContractSignatureRole = "CUSTOMER" | "STAFF" | "DIRECTOR" | "CEO";
export interface IContractSignature {
id: string;
contractId: string;
role: ContractSignatureRole;
signerDisplayName: string;
signatureFileId?: string | null;
consentText?: string | null;
signedAt: string;
}
export type ContractApprovalStepStatus =
| "PENDING"
| "APPROVED"
| "REJECTED"
| "SKIPPED";
export interface IContractApprovalStep {
id: string;
contractId: string;
stepOrder: number;
requiredRole: string;
blocksRole?: string | null;
status: ContractApprovalStepStatus;
actedByStaffId?: string | null;
actedAt?: string | null;
note?: string | null;
}
// ── Pre-booking clearance (Path B, doc §5.16) ───────────────────────────────
export type ContractDocReviewStatus = "PENDING" | "APPROVED" | "QUERIED";
export interface IContractDocumentReview {
id: string;
contractId: string;
clearanceCycleId?: string | null;
settingCode: string;
fileKey: string;
fileRecordId?: string | null;
status: ContractDocReviewStatus;
note?: string | null;
uploadedByRole: "CUSTOMER" | "GL_ET" | "GL_DJ";
reviewedByStaffId?: string | null;
reviewedAt?: string | null;
}
export interface IContractClearanceCycle {
id: string;
contractId: string;
cycleNumber: number;
status: string;
bookingId?: string | null;
startedAt: string;
clearanceReadyAt?: string | null;
completedAt?: string | null;
}
/**
* The pre-booking clearance view for a contract (Path B). Mirrors
* {@link ClearanceView} for bookings but keyed on the contract.
*/
export interface ContractClearanceDocument {
fileKey: string;
label: string;
required: boolean;
uploadedBy: "customer" | "gl" | "gl_et" | "gl_dj";
phase: ContractDocPhase;
settingCode: string;
file: { id: string; name: string; url: string } | null;
reviewStatus: ContractDocReviewStatus | null;
note: string | null;
/** When the review decision (approve/query) was recorded. */
reviewedAt?: string | null;
/** Staff id that recorded the decision (no user directory to resolve names). */
reviewedByStaffId?: string | null;
}
export interface ContractClearanceView {
contractId: string;
/** Overall contract status (e.g. CLEARANCE_UNDER_REVIEW). */
status?: string;
clearanceStatus: ContractClearanceStatus;
cycleNumber: number;
/**
* True when the contract bundles EDR customs clearance (Path B, GL-reviewed).
* False for Path A self-clearance, reviewed by the Operations team.
*/
includesCustoms?: boolean;
/** Resolved customer-input / GL-output clearance setting codes. */
inputCode?: string | null;
outputCode?: string | null;
documents: ContractClearanceDocument[];
/** True once every required customer document is APPROVED. */
allApproved: boolean;
}
/** Phased clearance document upload slots (doc §5.13). */
export enum ContractDocPhase {
CustomerIntake = "CUSTOMER_INTAKE",
GlEtReview = "GL_ET_REVIEW",
GlDjCollection = "GL_DJ_COLLECTION",
GlEtOutput = "GL_ET_OUTPUT",
CustomerDuty = "CUSTOMER_DUTY",
GlEtPostClearance = "GL_ET_POST_CLEARANCE",
GlDjLoading = "GL_DJ_LOADING",
PostTransit = "POST_TRANSIT",
}
// ── Clearance milestones (doc §5.12, Appendix B/C) ──────────────────────────
export type MilestoneStatus = "PENDING" | "COMPLETED" | "SKIPPED";
export const CUSTOMS_RISK_LEVELS = ["GREEN", "YELLOW", "RED"] as const;
export type CustomsRiskLevel = (typeof CUSTOMS_RISK_LEVELS)[number];
/** Structured payload carried by RISK_ASSIGNED / DUTY_TAXES_ADVISED milestones. */
export interface MilestoneMetadata {
riskLevel?: CustomsRiskLevel;
dutyAmount?: number;
dutyCurrency?: string;
declarationSerial?: string;
}
export interface IClearanceMilestone {
id: string;
bookingId?: string | null;
contractId?: string | null;
clearanceCycleId?: string | null;
milestoneCode: string;
milestoneLabel: string;
status: MilestoneStatus;
ownerRegion?: OwnerRegion | null;
triggeredByDoc: boolean;
triggeredAt?: string | null;
triggeredByUserId?: string | null;
note?: string | null;
metadata?: MilestoneMetadata | null;
sortOrder: number;
}
// ── GL cargo exception / damage reports (doc §11 GL Import US-07) ────────────
export const INCIDENT_TYPES = [
"SEAL_BROKEN",
"CONTAINER_OPENED",
"CONTAINER_DAMAGED",
"FLUID_LEAKING",
] as const;
export type IncidentType = (typeof INCIDENT_TYPES)[number];
export interface IClearanceIncident {
id: string;
bookingId: string;
incidentType: IncidentType;
description: string;
photoFileIds: string[];
reportedByUserId?: string | null;
reportedAt: string;
createdAt: string;
}
export const IMPORT_MILESTONES = [
"IMPORT_DOCS_UPLOADED",
"PENDING_DOCUMENT_REVIEW",
"DOCUMENTS_APPROVED",
"UNDER_CUSTOMS_CLEARANCE",
"DECLARED",
"DUTY_TAXES_ADVISED",
"DUTY_TAX_PAID",
"DO_COLLECTED",
"WAGON_REQUESTED",
"FREIGHT_PAYMENT_SETTLED",
"WAGON_ALLOCATED",
"GATEPASS_GRANTED",
"READY_FOR_LOADING",
"LOADED",
"DEPARTED_FROM_DJIBOUTI",
"ARRIVED_ETHIOPIA",
"OFFLOADED",
"T1_CLOSED",
"RISK_ASSIGNED",
"IMPORT_RELEASE_GRANTED",
"IMPORT_PROCESS_COMPLETED",
"STORAGE_INVOICE_RAISED",
"EXIT_NOTE_GENERATED",
] as const;
export type ImportMilestoneCode = (typeof IMPORT_MILESTONES)[number];
export const EXPORT_MILESTONES = [
"EXPORT_DOCS_UPLOADED",
"PENDING_DOCUMENT_REVIEW",
"DOCUMENTS_APPROVED",
"RELEASE_ORDER_SECURED",
"UNDER_CUSTOMS_CLEARANCE",
"DECLARED",
"EXPORT_RELEASED",
"WAGON_REQUESTED",
"FREIGHT_PAYMENT_PENDING",
"FREIGHT_PAYMENT_SETTLED",
"WAGON_ALLOCATED",
"CARGO_ARRIVED",
"READY_FOR_LOADING",
"LOADED",
"DEPARTED_TO_DJIBOUTI",
"ARRIVED_AT_DJIBOUTI",
"GATEPASS_GRANTED",
"OFFLOADED",
] as const;
export type ExportMilestoneCode = (typeof EXPORT_MILESTONES)[number];
// ── Booking container units (per-unit detail at booking, doc §5.10) ─────────
export interface IBookingContainerUnit {
id: string;
bookingContainerId: string;
containerNumber: string;
sealNumber?: string | null;
vgmTons: number;
isHazardous: boolean;
isReefer: boolean;
sortOrder: number;
}
// ── Contract aggregate ──────────────────────────────────────────────────────
export interface IContract extends BaseEntity {
reference: string;
companyId?: string | null;
companyProfileId?: string | null;
isGovernment: boolean;
governmentInstitution?: string | null;
contractKind: ContractKind;
renewalOfId?: string | null;
tradeDirection: ContractTradeDirection;
freightType: ContractFreightType;
serviceTypeId: string;
/** Loaded service-type relation (queue + detail responses include it). */
serviceType?: {
id: string;
code: string;
serviceName: string;
canBeBookedAlone: boolean;
includesCustoms: boolean;
} | null;
paymentCurrency: string;
customsClearingEnabled: boolean;
customsClearingAgent?: string | null;
equipmentReturn?: string | null;
firstMilePickupAddress?: string | null;
firstMilePickupLat?: number | null;
firstMilePickupLng?: number | null;
lastMileDeliveryAddress?: string | null;
lastMileDeliveryLat?: number | null;
lastMileDeliveryLng?: number | null;
isHazardous: boolean;
isReefer: boolean;
estimatedShipmentDate?: string | null;
contractValidityDays?: number | null;
contractValidFrom?: string | null;
contractValidUntil?: string | null;
expiresAt?: string | null;
status: ContractStatus;
clearanceStatus: ContractClearanceStatus;
clearanceCycleNumber: number;
pricingBreakdown?: ContractPricingBreakdown | null;
pricingDisplayMode?: "UNIT_RATES";
contractType?: string | null;
contractTemplateKey?: string | null;
contractGeneratedAt?: string | null;
contractSummary?: string | null;
versionNumber: number;
financialTerms?: string | null;
approvedByStaffId?: string | null;
approvedByStaffAt?: string | null;
signedByDirectorId?: string | null;
signedByDirectorAt?: string | null;
signedByCeoId?: string | null;
signedByCeoAt?: string | null;
customerSignedAt?: string | null;
fullyExecutedAt?: string | null;
lockedAt?: string | null;
routes?: IContractRoute[];
cargoScope?: IContractCargoScope[];
rateSnapshots?: IContractRateSnapshot[];
signatures?: IContractSignature[];
approvalSteps?: IContractApprovalStep[];
clearanceCycles?: IContractClearanceCycle[];
files?: Array<{
id: string;
code: string;
name: string;
url: string;
mimeType: string;
size: number;
resourceId: string;
resource: string;
signedUrl?: string | null;
}>;
}
// ── DTOs ─────────────────────────────────────────────────────────────────────
export interface CreateContractCargoScopeDto {
/** "20ft" | "40ft"; omit for bulk. */
containerSize?: string | null;
cargoTypeId?: string | null;
cargoFreeText?: string | null;
/** GENERAL only: total bookable quantity for this line. Omit for uncapped. */
quantityCap?: number | null;
}
export interface CreateContractRouteInputDto {
originYardId: string;
destinationYardId: string;
km?: number;
sortOrder?: number;
}
export interface CreateContractDto {
reference?: string;
isGovernment?: boolean;
governmentInstitution?: string;
companyId?: string;
contractKind: ContractKind;
renewalOfId?: string;
tradeDirection: ContractTradeDirection;
freightType: ContractFreightType;
serviceTypeId: string;
paymentCurrency: string;
customsClearingEnabled?: boolean;
customsClearingAgent?: string;
equipmentReturn?: string;
firstMilePickupAddress?: string;
firstMilePickupLat?: number;
firstMilePickupLng?: number;
lastMileDeliveryAddress?: string;
lastMileDeliveryLat?: number;
lastMileDeliveryLng?: number;
isHazardous?: boolean;
isReefer?: boolean;
estimatedShipmentDate?: string;
contractType?: string;
cargoScope: CreateContractCargoScopeDto[];
routes: CreateContractRouteInputDto[];
}
export type UpdateContractDto = Partial<CreateContractDto>;
// ── Booking-under-contract DTOs (doc §8) ────────────────────────────────────
export interface CreateContainerUnitDto {
containerNumber: string;
sealNumber?: string;
vgmTons: number;
isHazardous?: boolean;
isReefer?: boolean;
}
export interface CreateBookingContainerLineDto {
/** "20ft" | "40ft" — must be in the contract's cargo scope. */
containerSize: string;
quantity: number;
hazardousQuantity?: number;
reeferQuantity?: number;
units: CreateContainerUnitDto[];
}
export interface CreateBulkLineDto {
cargoTypeId?: string | null;
cargoWeightTons?: number;
itemCount?: number;
hazardousQuantity?: number;
}
/** Shipment booking created under a contract (Path A customer, Path B GL ET). */
export interface CreateBookingUnderContractDto {
/** Required for GENERAL multi-route contracts; ONE_TIME auto-selected. */
contractRouteId?: string;
/** Binding shipment day. */
scheduledDate: string;
containers?: CreateBookingContainerLineDto[];
bulkLines?: CreateBulkLineDto[];
notes?: string;
}
// ── Shipment / booking requests (GENERAL + customs, Path B) ─────────────────
// On a GENERAL customs contract the customer cannot book directly. They submit a
// shipment request (date + quantities); Global Logistics reviews it, then creates
// the booking on their behalf and per-booking clearance begins.
export const BOOKING_REQUEST_STATUSES = [
"PENDING",
"ACCEPTED",
"REJECTED",
"CANCELLED",
] as const;
export type BookingRequestStatus = (typeof BOOKING_REQUEST_STATUSES)[number];
/** Requested quantities — container lines OR a single bulk line (no per-unit data). */
export interface RequestedShipmentLines {
containers?: Array<{
containerSize: string;
quantity: number;
hazardousQuantity?: number;
reeferQuantity?: number;
}>;
bulk?: {
cargoTypeId?: string | null;
cargoWeightTons?: number;
itemCount?: number;
hazardousQuantity?: number;
};
}
export interface IBookingRequest extends BaseEntity {
reference: string;
contractId: string;
requestedByUserId?: string | null;
contractRouteId?: string | null;
/** Customer's preferred shipment day — informational; GL sets the binding date. */
scheduledDate?: string | null;
status: BookingRequestStatus;
requestedLines: RequestedShipmentLines;
notes?: string | null;
/** Set when GL accepts and creates the booking. */
createdBookingId?: string | null;
reviewedByStaffId?: string | null;
reviewedAt?: string | null;
reviewNote?: string | null;
}
export interface CreateBookingRequestDto {
contractRouteId?: string;
scheduledDate?: string;
containers?: Array<{
containerSize: string;
quantity: number;
hazardousQuantity?: number;
reeferQuantity?: number;
}>;
bulk?: {
cargoTypeId?: string | null;
cargoWeightTons?: number;
itemCount?: number;
hazardousQuantity?: number;
};
notes?: string;
}
export interface ReviewBookingRequestDto {
note?: string;
}
// ── Clearance review / finalize DTOs (Path B) ───────────────────────────────
export interface ReviewContractClearanceDocumentDto {
fileKey: string;
status: Extract<ContractDocReviewStatus, "APPROVED" | "QUERIED">;
note?: string;
}
export interface RenewContractDto {
/** Reference of the prior contract being renewed. */
previousContractReference?: string;
}

View File

@@ -4,6 +4,7 @@ export * from "./dropdown_settings";
export * from "./file_upload_settings";
export * from "./overview";
export * from "./etrade";
export * from "./contracts";
export enum TradeDirection {
IMPORT = "IMPORT",
@@ -377,6 +378,8 @@ export interface IYard extends BaseEntity {
export interface IBooking extends BaseEntity {
reference: string;
customerId: string;
/** The contract this booking was created under (Path A / Path B). */
contractId?: string | null;
trainId?: string | null;
status: BookingStatus;
/** ONE_TIME for normal bookings; GENERAL_CONTRACT for umbrella contracts. */
@@ -613,6 +616,23 @@ export interface AvailableDaysQuery {
destinationYardId?: string;
}
/**
* Cargo-aware availability query: beyond the route yards it carries the cargo
* sizing so the server only returns days where a train has remaining capacity
* AND enough matching-type wagons. Response reuses {@link AvailableDaysResponse}.
*/
export interface AvailableDaysForCargoQuery {
originYardId?: string;
destinationYardId?: string;
freightType: "CONTAINER" | "BULK";
/** Bulk cargo type code (e.g. "COFFEE"); ignored for container freight. */
cargoTypeCode?: string;
/** Total bulk weight in tons. */
totalWeightTons?: number;
/** Container lines (size + quantity) for container freight. */
containers?: { containerSize: string; quantity: number }[];
}
/**
* Day-level booking pool: the EAT calendar days that have at least one OPEN
* departure on a route. `days` are `yyyy-MM-dd` strings, e.g. `["2026-06-20"]`.

View File

@@ -32,8 +32,17 @@ export interface IOverviewStaffKpis {
activeUsers: number;
}
export interface IOverviewContractKpis {
totalActive: number;
needsAction: number;
inApproval: number;
inClearance: number;
createdToday: number;
}
export interface IOverviewKpis {
bookings: IOverviewBookingKpis;
contracts: IOverviewContractKpis;
operations: IOverviewOperationsKpis;
customers: IOverviewCustomerKpis;
billing: IOverviewBillingKpis;
@@ -72,6 +81,18 @@ export interface IOverviewRecentBooking {
createdAt: string;
}
export interface IOverviewRecentContract {
id: string;
reference: string;
customerLabel: string;
status: string;
contractKind: string;
freightType: string;
paymentCurrency: string | null;
validUntil: string | null;
createdAt: string;
}
export interface IOverviewDashboard {
kpis: IOverviewKpis;
bookingTrend: IOverviewTrendPoint[];
@@ -110,6 +131,17 @@ export interface IOverviewBookingsTab {
generatedAt: string;
}
export interface IOverviewContractsTab {
kpis: IOverviewContractKpis;
contractTrend: IOverviewTrendPoint[];
contractsByStatus: IOverviewStatusCount[];
contractsByPipeline: IOverviewPipelineCount[];
contractsByKind: IOverviewLabelCount[];
contractsByFreightType: IOverviewLabelCount[];
recentContracts: IOverviewRecentContract[];
generatedAt: string;
}
export interface IOverviewBillingTab {
kpis: IOverviewBillingKpis;
paymentTrend: IOverviewPaymentTrendPoint[];
@@ -146,6 +178,7 @@ export interface IOverviewStaffTab {
export type OverviewTabKey =
| 'bookings'
| 'contracts'
| 'billing'
| 'operations'
| 'customers'

View File

@@ -0,0 +1,307 @@
import { useMemo } from "react";
import {
Box,
Button,
Center,
Group,
Modal,
Stack,
Text,
ThemeIcon,
} from "@mantine/core";
import {
Download,
ExternalLink,
FileArchive,
FileQuestion,
} from "lucide-react";
/** The minimal file shape the viewer needs. */
export interface ViewableFile {
/** Display name (used for the title + extension fallback). */
name: string;
/** Direct URL to the file content. A signed URL is preferred when present. */
url: string;
/** MIME type when known (e.g. "application/pdf", "image/png"). */
mimeType?: string | null;
}
export interface FileViewerModalProps {
/** Whether the modal is open. */
open: boolean;
/** The file to display, or null when nothing is selected. */
file: ViewableFile | null;
/** Close handler. */
onClose: () => void;
}
type ViewerKind =
| "image"
| "video"
| "audio"
| "pdf"
| "office"
| "text"
| "unsupported";
const EXT_KIND: Record<string, ViewerKind> = {
// images
png: "image",
jpg: "image",
jpeg: "image",
gif: "image",
webp: "image",
bmp: "image",
svg: "image",
// video
mp4: "video",
webm: "video",
ogv: "video",
mov: "video",
m4v: "video",
// audio
mp3: "audio",
wav: "audio",
ogg: "audio",
m4a: "audio",
// documents
pdf: "pdf",
// office — rendered via the Microsoft Office online viewer
doc: "office",
docx: "office",
xls: "office",
xlsx: "office",
ppt: "office",
pptx: "office",
// text
txt: "text",
csv: "text",
json: "text",
log: "text",
md: "text",
};
/** Archives / binaries we deliberately do NOT try to render inline. */
const UNVIEWABLE_EXT = new Set([
"zip",
"rar",
"7z",
"tar",
"gz",
"bz2",
"exe",
"dmg",
"iso",
"bin",
]);
function extOf(name: string): string {
const dot = name.lastIndexOf(".");
return dot >= 0 ? name.slice(dot + 1).toLowerCase() : "";
}
/** Decide how to render a file from its MIME type, falling back to extension. */
export function resolveViewerKind(file: ViewableFile): ViewerKind {
const mime = (file.mimeType ?? "").toLowerCase();
const ext = extOf(file.name);
if (UNVIEWABLE_EXT.has(ext)) return "unsupported";
if (mime.startsWith("image/")) return "image";
if (mime.startsWith("video/")) return "video";
if (mime.startsWith("audio/")) return "audio";
if (mime === "application/pdf") return "pdf";
if (
mime.includes("word") ||
mime.includes("excel") ||
mime.includes("spreadsheet") ||
mime.includes("powerpoint") ||
mime.includes("presentation") ||
mime.includes("officedocument")
) {
return "office";
}
if (mime.startsWith("text/") || mime === "application/json") return "text";
// Fall back to the file extension when the MIME type is missing/generic.
return EXT_KIND[ext] ?? "unsupported";
}
/** True when a file can be previewed inline (not an archive/binary). */
export function isViewable(file: ViewableFile): boolean {
return resolveViewerKind(file) !== "unsupported";
}
/**
* A wide modal that renders the content of common document types inline —
* images, video, audio, PDFs, Office documents (via the Microsoft online
* viewer) and plain text. Archives and other binaries fall back to a download
* prompt. Use the {@link isViewable} / {@link resolveViewerKind} helpers to gate
* a "view" affordance in the caller.
*/
export function FileViewerModal({ open, file, onClose }: FileViewerModalProps) {
const kind = useMemo(
() => (file ? resolveViewerKind(file) : "unsupported"),
[file],
);
return (
<Modal
opened={open}
onClose={onClose}
title={
<Text fw={700} fz={15} truncate>
{file?.name ?? "Document"}
</Text>
}
size="90%"
radius="md"
centered
overlayProps={{ blur: 2, backgroundOpacity: 0.55 }}
styles={{
content: {
height: "90vh",
display: "flex",
flexDirection: "column",
},
body: { flex: 1, minHeight: 0, display: "flex", padding: 0 },
header: { paddingInline: 16 },
}}
>
{file && (
<Stack gap={0} style={{ flex: 1, minHeight: 0 }}>
<Group
justify="flex-end"
gap="xs"
px="md"
py={8}
style={{ borderBottom: "1px solid var(--mantine-color-gray-2)" }}
>
<Button
component="a"
href={file.url}
target="_blank"
rel="noopener noreferrer"
size="compact-sm"
variant="default"
leftSection={<ExternalLink size={14} />}
>
Open in new tab
</Button>
<Button
component="a"
href={file.url}
download={file.name}
size="compact-sm"
variant="light"
color="edr-green"
leftSection={<Download size={14} />}
>
Download
</Button>
</Group>
<Box style={{ flex: 1, minHeight: 0, overflow: "auto" }}>
<FileContent file={file} kind={kind} />
</Box>
</Stack>
)}
</Modal>
);
}
function FileContent({
file,
kind,
}: {
file: ViewableFile;
kind: ViewerKind;
}) {
switch (kind) {
case "image":
return (
<Center p="md" style={{ minHeight: "100%" }}>
<img
src={file.url}
alt={file.name}
style={{ maxWidth: "100%", maxHeight: "100%", objectFit: "contain" }}
/>
</Center>
);
case "video":
return (
<Center p="md" style={{ minHeight: "100%", background: "#000" }}>
<video
src={file.url}
controls
style={{ maxWidth: "100%", maxHeight: "100%" }}
/>
</Center>
);
case "audio":
return (
<Center p="xl" style={{ minHeight: "100%" }}>
<audio src={file.url} controls style={{ width: "100%", maxWidth: 480 }} />
</Center>
);
case "pdf":
return (
<iframe
src={file.url}
title={file.name}
style={{ width: "100%", height: "100%", border: "none" }}
/>
);
case "office":
return (
<iframe
// The Microsoft Office online viewer requires a publicly reachable URL.
src={`https://view.officeapps.live.com/op/embed.aspx?src=${encodeURIComponent(
file.url,
)}`}
title={file.name}
style={{ width: "100%", height: "100%", border: "none" }}
/>
);
case "text":
return (
<iframe
src={file.url}
title={file.name}
style={{ width: "100%", height: "100%", border: "none" }}
/>
);
default:
return <UnsupportedNotice file={file} />;
}
}
function UnsupportedNotice({ file }: { file: ViewableFile }) {
const isArchive = UNVIEWABLE_EXT.has(extOf(file.name));
return (
<Center p="xl" style={{ minHeight: "100%" }}>
<Stack align="center" gap="sm" maw={360} ta="center">
<ThemeIcon variant="light" color="gray" radius="xl" size={56}>
{isArchive ? <FileArchive size={26} /> : <FileQuestion size={26} />}
</ThemeIcon>
<Text fw={600}>This file type cant be previewed</Text>
<Text fz="sm" c="dimmed">
{isArchive
? "Archives need to be downloaded and extracted on your computer."
: "Download the file to open it in a compatible application."}
</Text>
<Button
component="a"
href={file.url}
download={file.name}
mt="xs"
color="edr-green"
leftSection={<Download size={16} />}
>
Download file
</Button>
</Stack>
</Center>
);
}
export default FileViewerModal;

View File

@@ -0,0 +1,7 @@
export {
FileViewerModal,
isViewable,
resolveViewerKind,
} from "./FileViewer";
export type { FileViewerModalProps, ViewableFile } from "./FileViewer";
export { default } from "./FileViewer";

View File

@@ -176,7 +176,7 @@ const DashboardLayout = ({
{isUserMenuOpen ? (
<div
role="menu"
className="absolute right-0 top-full z-50 mt-2 w-52 overflow-hidden rounded-xl border border-border bg-card py-1 shadow-lg"
className="absolute right-0 top-full z-50 mt-2 w-52 max-w-[calc(100vw-2rem)] overflow-hidden rounded-xl border border-border bg-card py-1 shadow-lg -translate-x-4 sm:translate-x-0"
>
<div className="border-b border-border px-4 py-3">
<p className="text-sm font-semibold text-card-foreground">

View File

@@ -0,0 +1,248 @@
import { Box, Button, Group, Text } from "@mantine/core";
import {
Calendar as CalendarIcon,
Check,
ChevronLeft,
ChevronRight,
} from "lucide-react";
import { useMemo, useState } from "react";
export interface OperationDatePickerProps {
/** Selectable days as `yyyy-MM-dd` strings. */
availableDays: string[];
/** Show the loading state instead of the grid. */
isLoading?: boolean;
/** Currently selected day as `yyyy-MM-dd`, or "" when none. */
value: string;
/** Called with the picked `yyyy-MM-dd` day. */
onChange: (date: string) => void;
}
/** `yyyy-MM-dd` for a local date. */
function fmtDay(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
const MONTH_NAMES = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
/**
* Presentational month calendar for picking a binding shipment day. Only the
* `availableDays` (passed in by the caller, which owns the query) are
* selectable; every other day is disabled. Framework-light: no data fetching,
* no date library — both the portal and backoffice feed it their own
* availability results so the picker renders identically in each app.
*/
export function OperationDatePicker({
availableDays,
isLoading = false,
value,
onChange,
}: OperationDatePickerProps) {
// First-of-month for the visible month; defaults to the current month.
const [month, setMonth] = useState(() => {
const now = new Date();
return new Date(now.getFullYear(), now.getMonth(), 1);
});
const departureDays = useMemo(
() => new Set(availableDays ?? []),
[availableDays],
);
const cells = useMemo(() => {
const first = new Date(month.getFullYear(), month.getMonth(), 1);
// Monday-first grid: JS getDay() Sun=0..Sat=6 → shift so Mon=0.
const lead = (first.getDay() + 6) % 7;
const start = new Date(first);
start.setDate(first.getDate() - lead);
const today = new Date();
const todayStr = fmtDay(today);
return Array.from({ length: 42 }, (_, i) => {
const date = new Date(start);
date.setDate(start.getDate() + i);
const dateString = fmtDay(date);
return {
dateString,
day: date.getDate(),
inMonth: date.getMonth() === month.getMonth(),
today: dateString === todayStr,
selected: value === dateString,
hasDeparture: departureDays.has(dateString),
};
});
}, [month, departureDays, value]);
const shiftMonth = (delta: number) =>
setMonth((m) => new Date(m.getFullYear(), m.getMonth() + delta, 1));
return (
<Box
style={{
border: "1px solid #E6ECF2",
borderRadius: 12,
padding: 14,
maxWidth: 340,
}}
>
<Group justify="space-between" align="center" mb="sm">
<Button
variant="default"
size="xs"
px={6}
radius="xl"
onClick={() => shiftMonth(-1)}
>
<ChevronLeft size={15} />
</Button>
<Text fz="13px" fw={700} c="#10202F">
{MONTH_NAMES[month.getMonth()]} {month.getFullYear()}
</Text>
<Button
variant="default"
size="xs"
px={6}
radius="xl"
onClick={() => shiftMonth(1)}
>
<ChevronRight size={15} />
</Button>
</Group>
{isLoading ? (
<Group justify="center" py="md" gap={8}>
<CalendarIcon size={15} color="#9AA8B5" />
<Text fz="12px" c="dimmed">
Loading available days
</Text>
</Group>
) : (
<>
<Box
style={{
display: "grid",
gridTemplateColumns: "repeat(7, 1fr)",
gap: 4,
marginBottom: 6,
}}
>
{["M", "T", "W", "T", "F", "S", "S"].map((d, i) => (
<Text key={i} ta="center" fz="10px" fw={700} c="#9AA8B5">
{d}
</Text>
))}
</Box>
<Box
style={{
display: "grid",
gridTemplateColumns: "repeat(7, 1fr)",
gap: 4,
}}
>
{cells.map((c) => {
const clickable = c.hasDeparture && c.inMonth;
return (
<button
key={c.dateString}
type="button"
disabled={!clickable}
onClick={() => clickable && onChange(c.dateString)}
style={{
position: "relative",
height: 34,
borderRadius: 8,
fontSize: 12.5,
fontWeight: c.selected ? 800 : 600,
cursor: clickable ? "pointer" : "default",
border: c.selected
? "1.5px solid #12B981"
: clickable
? "1px solid #CDEBDD"
: "1px solid transparent",
background: c.selected
? "#12B981"
: clickable
? "#F4FBF7"
: "transparent",
color: c.selected
? "#fff"
: !c.inMonth
? "#CBD5E1"
: clickable
? "#0A6F4D"
: "#C4CDD6",
transition: "all 120ms ease",
}}
>
{c.day}
{c.hasDeparture && c.inMonth && !c.selected && (
<span
style={{
position: "absolute",
bottom: 4,
left: "50%",
transform: "translateX(-50%)",
width: 4,
height: 4,
borderRadius: "50%",
background: "#12B981",
}}
/>
)}
{c.selected && (
<Check
size={11}
color="#fff"
strokeWidth={3}
style={{
position: "absolute",
bottom: 3,
left: "50%",
transform: "translateX(-50%)",
}}
/>
)}
</button>
);
})}
</Box>
{value && (
<Text fz="12px" c="#0A6F4D" fw={600} mt="sm">
Selected:{" "}
{new Date(value + "T00:00:00").toLocaleDateString(undefined, {
weekday: "short",
month: "short",
day: "numeric",
year: "numeric",
})}
</Text>
)}
{departureDays.size === 0 && (
<Text fz="12px" c="orange.7" mt="sm">
No scheduled departures found for this route yet.
</Text>
)}
</>
)}
</Box>
);
}
export default OperationDatePicker;

View File

@@ -0,0 +1,5 @@
export {
OperationDatePicker,
default,
} from "./OperationDatePicker";
export type { OperationDatePickerProps } from "./OperationDatePicker";

View File

@@ -1,8 +1,5 @@
import React, { useState, useMemo, useRef } from "react";
import {
IFileUploadSetting,
IFileUploadField,
} from "@edr/types/freight";
import { IFileUploadSetting, IFileUploadField } from "@edr/types/freight";
import {
UploadCloud,
FileText,
@@ -24,12 +21,20 @@ export interface SmartFileInputProps {
onChange?: (value: Record<string, File | File[] | null>) => void;
/** External form errors mapped by fileKey. */
errors?: Record<string, string>;
/**
* fileKeys whose document is already uploaded on the server. Such fields show
* an "Already uploaded" badge and a replace-oriented dropzone hint, even when
* no in-memory File is currently selected for them.
*/
uploadedKeys?: string[];
/** Disabled state for the entire file input group. */
disabled?: boolean;
/** Display variant style. Default is "default" (large dropzone). Minimal renders a compact upload button. */
variant?: "default" | "minimal";
/** Optional custom container CSS classes. */
className?: string;
containerClassName?: string;
}
/** Helper to format file sizes in bytes to a human-readable string. */
@@ -45,23 +50,23 @@ function formatBytes(bytes: number, decimals = 2) {
/** Render a suitable icon based on file extension. */
function FileIcon({ name, className }: { name: string; className?: string }) {
const ext = name.split(".").pop()?.toLowerCase() || "";
if (ext === "pdf") {
return <FileText className={cn("text-red-500", className)} />;
}
if (["png", "jpg", "jpeg", "webp", "svg", "gif"].includes(ext)) {
return <ImageIcon className={cn("text-blue-500", className)} />;
}
if (["csv", "xls", "xlsx"].includes(ext)) {
return <FileText className={cn("text-emerald-500", className)} />;
}
if (["zip", "rar", "tar", "gz", "7z"].includes(ext)) {
return <File className={cn("text-amber-500", className)} />;
}
return <File className={cn("text-slate-400", className)} />;
}
@@ -70,16 +75,20 @@ export function SmartFileInput({
value,
onChange,
errors,
uploadedKeys,
disabled = false,
variant = "default",
className,
containerClassName,
}: SmartFileInputProps) {
// Local state to manage files when the component is used in an uncontrolled manner
const [internalFiles, setInternalFiles] = useState<Record<string, File[]>>({});
const [internalFiles, setInternalFiles] = useState<Record<string, File[]>>(
{},
);
// Local validation errors
const [localErrors, setLocalErrors] = useState<Record<string, string>>({});
// Drag-and-drop state active per field
const [dragActive, setDragActive] = useState<Record<string, boolean>>({});
@@ -93,10 +102,13 @@ export function SmartFileInput({
// Create a map of fields for quick lookup
const fieldsMap = useMemo(() => {
return file.fields.reduce((acc, currentField) => {
acc[currentField.fileKey] = currentField;
return acc;
}, {} as Record<string, IFileUploadField>);
return file.fields.reduce(
(acc, currentField) => {
acc[currentField.fileKey] = currentField;
return acc;
},
{} as Record<string, IFileUploadField>,
);
}, [file.fields]);
// Resolve current files list for a field
@@ -109,8 +121,8 @@ export function SmartFileInput({
const handleFilesChange = (fieldKey: string, newFiles: File[]) => {
const field = fieldsMap[fieldKey];
if (!field) return;
const newValue = field.isMultiple ? newFiles : (newFiles[0] || null);
const newValue = field.isMultiple ? newFiles : newFiles[0] || null;
if (onChange) {
const updatedValues = {
@@ -129,10 +141,10 @@ export function SmartFileInput({
const processFiles = (field: IFileUploadField, incomingFiles: File[]) => {
const currentFiles = getFilesForField(field.fileKey);
const maxAllowed = field.isMultiple ? Math.max(1, field.maxFiles) : 1;
// Clean up extensions (e.g. '.pdf' or 'pdf' -> 'pdf')
const allowedExts = field.allowedExtensions.map((ext) =>
ext.toLowerCase().replace(/^\./, "")
ext.toLowerCase().replace(/^\./, ""),
);
let validIncoming: File[] = [];
@@ -140,13 +152,12 @@ export function SmartFileInput({
for (const fileObj of incomingFiles) {
const ext = fileObj.name.split(".").pop()?.toLowerCase() || "";
const isExtValid =
allowedExts.length === 0 || allowedExts.includes(ext);
const isExtValid = allowedExts.length === 0 || allowedExts.includes(ext);
const isSizeValid = fileObj.size <= field.maxSizeMb * 1024 * 1024;
if (!isExtValid) {
errorMsg = `Invalid file extension. Allowed: ${field.allowedExtensions.join(
", "
", ",
)}`;
break;
}
@@ -187,7 +198,11 @@ export function SmartFileInput({
handleFilesChange(field.fileKey, newFilesList);
};
const handleDrag = (e: React.DragEvent, fieldKey: string, active: boolean) => {
const handleDrag = (
e: React.DragEvent,
fieldKey: string,
active: boolean,
) => {
e.preventDefault();
e.stopPropagation();
if (disabled) return;
@@ -208,7 +223,7 @@ export function SmartFileInput({
const handleFileSelect = (
e: React.ChangeEvent<HTMLInputElement>,
field: IFileUploadField
field: IFileUploadField,
) => {
if (e.target.files && e.target.files.length > 0) {
const filesArray = Array.from(e.target.files);
@@ -246,181 +261,277 @@ export function SmartFileInput({
{file.description}
</div>
)}
{sortedFields.map((field) => {
const currentFiles = getFilesForField(field.fileKey);
const maxFiles = field.isMultiple ? Math.max(1, field.maxFiles) : 1;
const reachedLimit = currentFiles.length >= maxFiles;
const fieldError = errors?.[field.fileKey] || localErrors[field.fileKey];
const isDragOver = dragActive[field.fileKey];
// Format accepted files for the HTML input element
const acceptString = field.allowedExtensions
.map((ext) => (ext.startsWith(".") ? ext : `.${ext}`))
.join(",");
<div className={cn("flex flex-col gap-6", containerClassName)}>
{sortedFields.map((field) => {
const currentFiles = getFilesForField(field.fileKey);
const maxFiles = field.isMultiple ? Math.max(1, field.maxFiles) : 1;
const reachedLimit = currentFiles.length >= maxFiles;
const fieldError =
errors?.[field.fileKey] || localErrors[field.fileKey];
const isDragOver = dragActive[field.fileKey];
// Already uploaded server-side and nothing newly picked to replace it.
const isUploaded =
(uploadedKeys?.includes(field.fileKey) ?? false) &&
currentFiles.length === 0;
return (
<div key={field.id || field.fileKey} className="flex flex-col gap-2">
{/* Field Header */}
<div className="flex flex-col md:flex-row md:items-baseline justify-between gap-1">
<label className="text-sm font-semibold text-foreground flex items-center gap-1">
{field.fileLabel}
{field.isRequired && (
<span className="text-destructive font-bold" aria-hidden="true">
*
</span>
)}
</label>
<span className="text-xs text-muted-foreground">
Max size: {field.maxSizeMb}MB
{field.isMultiple && ` • Files: ${currentFiles.length}/${maxFiles}`}
</span>
</div>
// Format accepted files for the HTML input element
const acceptString = field.allowedExtensions
.map((ext) => (ext.startsWith(".") ? ext : `.${ext}`))
.join(",");
{/* Help / Description Text */}
{field.helpText && (
<p className="text-xs text-muted-foreground">{field.helpText}</p>
)}
{/* Selected Files List */}
{currentFiles.length > 0 && (
<div className="flex flex-col gap-2">
{currentFiles.map((fileObj, idx) => (
<div
key={`${fileObj.name}-${idx}`}
className={cn(
"flex items-center justify-between p-3 rounded-lg border bg-card transition shadow-2xs hover:shadow-xs",
fieldError ? "border-destructive/30" : "border-border"
return (
<div
key={field.id || field.fileKey}
className="flex flex-col gap-2"
>
{/* Field Header */}
<div className="flex flex-col md:flex-row md:items-baseline justify-between gap-1">
<label className="text-sm font-semibold text-foreground flex items-center gap-1.5">
<span className="flex items-center gap-1">
{field.fileLabel}
{field.isRequired && (
<span
className="text-destructive font-bold"
aria-hidden="true"
>
*
</span>
)}
>
<div className="flex items-center gap-3 min-w-0">
<div className="p-2 bg-muted rounded-md flex items-center justify-center">
<FileIcon name={fileObj.name} className="h-5 w-5" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-foreground truncate max-w-[200px] md:max-w-md" title={fileObj.name}>
{fileObj.name}
</p>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-xs text-muted-foreground">
{formatBytes(fileObj.size)}
</span>
<span className="flex items-center gap-0.5 text-xs text-primary font-medium">
<CheckCircle2 className="h-3 w-3" /> Ready
</span>
</span>
{isUploaded && (
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-50 px-2 py-0.5 text-[11px] font-medium text-emerald-600 dark:bg-emerald-500/10 dark:text-emerald-400">
<CheckCircle2 className="h-3 w-3" /> Already uploaded
</span>
)}
</label>
<span className="text-xs text-muted-foreground">
Max size: {field.maxSizeMb}MB
{field.isMultiple &&
` • Files: ${currentFiles.length}/${maxFiles}`}
</span>
</div>
{/* Help / Description Text */}
{field.helpText && (
<p className="text-xs text-muted-foreground">
{field.helpText}
</p>
)}
{/* Selected Files List */}
{currentFiles.length > 0 && (
<div className="flex flex-col gap-2">
{currentFiles.map((fileObj, idx) => (
<div
key={`${fileObj.name}-${idx}`}
className={cn(
"flex items-center justify-between p-3 rounded-lg border bg-card transition shadow-2xs hover:shadow-xs",
fieldError ? "border-destructive/30" : "border-border",
)}
>
<div className="flex items-center gap-3 min-w-0">
<div className="p-2 bg-muted rounded-md flex items-center justify-center">
<FileIcon name={fileObj.name} className="h-5 w-5" />
</div>
<div className="min-w-0">
<p
className="text-sm font-medium text-foreground truncate max-w-[200px] md:max-w-md"
title={fileObj.name}
>
{fileObj.name}
</p>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-xs text-muted-foreground">
{formatBytes(fileObj.size)}
</span>
<span className="flex items-center gap-0.5 text-xs text-primary font-medium">
<CheckCircle2 className="h-3 w-3" /> Ready
</span>
</div>
</div>
</div>
<button
type="button"
disabled={disabled}
onClick={() => removeFile(field.fileKey, idx)}
className={cn(
"p-1.5 rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors",
disabled && "opacity-50 pointer-events-none",
)}
aria-label={`Remove file ${fileObj.name}`}
>
<Trash2 className="h-4 w-4" />
</button>
{/* Hidden inputs to represent file details in traditional form submissions */}
<input
type="hidden"
name={
field.isMultiple
? `${field.fileKey}[]`
: field.fileKey
}
value={fileObj.name}
/>
</div>
))}
</div>
)}
{/* Dropzone area */}
{!reachedLimit &&
(variant === "minimal" ? (
<div className="flex flex-wrap items-center gap-3">
<Button
type="button"
variant="outline"
size="sm"
disabled={disabled}
onClick={() =>
fileInputRefs.current[field.fileKey]?.click()
}
className="gap-1.5 cursor-pointer"
>
<UploadCloud className="h-4 w-4 text-muted-foreground" />
<span>{isUploaded ? "Replace File" : "Upload File"}</span>
</Button>
<input
type="file"
ref={(el) => {
if (fileInputRefs.current) {
fileInputRefs.current[field.fileKey] = el;
}
}}
multiple={field.isMultiple}
accept={acceptString}
disabled={disabled}
onChange={(e) => handleFileSelect(e, field)}
className="hidden"
/>
<span className="text-xs text-muted-foreground">
Accepts:{" "}
{field.allowedExtensions.join(", ").toUpperCase() ||
"All"}
</span>
</div>
) : isUploaded ? (
// Uploaded state: a solid success panel that still doubles as a
// replace target (click anywhere or drag a new file onto it).
<div
onDragOver={(e) => handleDrag(e, field.fileKey, true)}
onDragLeave={(e) => handleDrag(e, field.fileKey, false)}
onDrop={(e) => handleDrop(e, field)}
className={cn(
"group relative flex items-center gap-4 rounded-lg border p-4 transition-all",
isDragOver
? "border-2 border-dashed border-primary bg-primary/5 dark:bg-primary/10"
: "border-emerald-300/70 bg-emerald-50/60 dark:border-emerald-500/30 dark:bg-emerald-500/10",
disabled &&
"opacity-50 pointer-events-none cursor-not-allowed",
)}
>
<input
type="file"
multiple={field.isMultiple}
accept={acceptString}
disabled={disabled}
onChange={(e) => handleFileSelect(e, field)}
id={`file-input-${field.fileKey}`}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer disabled:cursor-not-allowed"
aria-label={`Replace ${field.fileLabel}`}
/>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-emerald-100 text-emerald-600 dark:bg-emerald-500/20 dark:text-emerald-400">
{isDragOver ? (
<UploadCloud className="h-5 w-5 animate-bounce" />
) : (
<CheckCircle2 className="h-5 w-5" />
)}
</div>
<button
type="button"
disabled={disabled}
onClick={() => removeFile(field.fileKey, idx)}
className={cn(
"p-1.5 rounded-md text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors",
disabled && "opacity-50 pointer-events-none"
)}
aria-label={`Remove file ${fileObj.name}`}
>
<Trash2 className="h-4 w-4" />
</button>
{/* Hidden inputs to represent file details in traditional form submissions */}
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-foreground">
{isDragOver ? "Drop to replace" : "Document uploaded"}
</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{isDragOver
? "Release to replace the document on file."
: "Saved to your application. Drag a new file here or click to replace it."}
</p>
</div>
<span className="hidden shrink-0 items-center gap-1.5 rounded-md border border-border bg-card px-3 py-1.5 text-xs font-medium text-foreground shadow-2xs transition group-hover:border-primary/50 group-hover:text-primary sm:inline-flex">
<UploadCloud className="h-3.5 w-3.5" />
Replace
</span>
</div>
) : (
<div
onDragOver={(e) => handleDrag(e, field.fileKey, true)}
onDragLeave={(e) => handleDrag(e, field.fileKey, false)}
onDrop={(e) => handleDrop(e, field)}
className={cn(
"relative border-2 border-dashed rounded-lg p-6 flex flex-col items-center justify-center text-center transition-all bg-card/50",
isDragOver
? "border-primary bg-primary/5 dark:bg-primary/10"
: "border-border hover:border-primary/50 hover:bg-muted/10",
fieldError &&
"border-destructive hover:border-destructive/80",
disabled &&
"opacity-50 pointer-events-none cursor-not-allowed",
)}
>
<input
type="hidden"
name={field.isMultiple ? `${field.fileKey}[]` : field.fileKey}
value={fileObj.name}
type="file"
multiple={field.isMultiple}
accept={acceptString}
disabled={disabled}
onChange={(e) => handleFileSelect(e, field)}
id={`file-input-${field.fileKey}`}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer disabled:cursor-not-allowed"
/>
<div className="p-3 bg-muted rounded-full mb-3 text-muted-foreground transition group-hover:scale-110">
<UploadCloud
className={cn(
"h-6 w-6 text-muted-foreground",
isDragOver && "text-primary animate-bounce",
)}
/>
</div>
<p className="text-sm font-semibold text-foreground">
Drag & drop your file here, or{" "}
<span className="text-primary font-bold hover:underline">
browse
</span>
</p>
<p className="text-xs text-muted-foreground mt-1">
Supported formats:{" "}
{field.allowedExtensions.join(", ").toUpperCase() ||
"All"}
</p>
</div>
))}
</div>
)}
{/* Dropzone area */}
{!reachedLimit && (
variant === "minimal" ? (
<div className="flex flex-wrap items-center gap-3">
<Button
type="button"
variant="outline"
size="sm"
disabled={disabled}
onClick={() => fileInputRefs.current[field.fileKey]?.click()}
className="gap-1.5 cursor-pointer"
>
<UploadCloud className="h-4 w-4 text-muted-foreground" />
<span>Upload File</span>
</Button>
<input
type="file"
ref={(el) => {
if (fileInputRefs.current) {
fileInputRefs.current[field.fileKey] = el;
}
}}
multiple={field.isMultiple}
accept={acceptString}
disabled={disabled}
onChange={(e) => handleFileSelect(e, field)}
className="hidden"
/>
<span className="text-xs text-muted-foreground">
Accepts: {field.allowedExtensions.join(", ").toUpperCase() || "All"}
</span>
{/* Validation Error Message */}
{fieldError && (
<div className="flex items-center gap-1.5 mt-1 text-xs text-destructive animate-in fade-in slide-in-from-top-1 duration-200">
<AlertCircle className="h-3.5 w-3.5" />
<span>{fieldError}</span>
</div>
) : (
<div
onDragOver={(e) => handleDrag(e, field.fileKey, true)}
onDragLeave={(e) => handleDrag(e, field.fileKey, false)}
onDrop={(e) => handleDrop(e, field)}
className={cn(
"relative border-2 border-dashed rounded-lg p-6 flex flex-col items-center justify-center text-center transition-all bg-card/50",
isDragOver
? "border-primary bg-primary/5 dark:bg-primary/10"
: "border-border hover:border-primary/50 hover:bg-muted/10",
fieldError && "border-destructive hover:border-destructive/80",
disabled && "opacity-50 pointer-events-none cursor-not-allowed"
)}
>
<input
type="file"
multiple={field.isMultiple}
accept={acceptString}
disabled={disabled}
onChange={(e) => handleFileSelect(e, field)}
id={`file-input-${field.fileKey}`}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer disabled:cursor-not-allowed"
/>
<div className="p-3 bg-muted rounded-full mb-3 text-muted-foreground transition group-hover:scale-110">
<UploadCloud className={cn("h-6 w-6 text-muted-foreground", isDragOver && "text-primary animate-bounce")} />
</div>
<p className="text-sm font-semibold text-foreground">
Drag & drop your file here, or <span className="text-primary font-bold hover:underline">browse</span>
</p>
<p className="text-xs text-muted-foreground mt-1">
Supported formats: {field.allowedExtensions.join(", ").toUpperCase() || "All"}
</p>
</div>
)
)}
{/* Validation Error Message */}
{fieldError && (
<div className="flex items-center gap-1.5 mt-1 text-xs text-destructive animate-in fade-in slide-in-from-top-1 duration-200">
<AlertCircle className="h-3.5 w-3.5" />
<span>{fieldError}</span>
</div>
)}
</div>
);
})}
)}
</div>
);
})}
</div>
</div>
);
}
export default SmartFileInput;
export default SmartFileInput;

View File

@@ -10,6 +10,19 @@ export type { SmartFileInputProps } from "./components/SmartFileInput";
export { default as Modal } from "./components/Modal";
export type { ModalProps } from "./components/Modal";
export {
FileViewerModal,
isViewable,
resolveViewerKind,
} from "./components/FileViewer";
export type {
FileViewerModalProps,
ViewableFile,
} from "./components/FileViewer";
export { OperationDatePicker } from "./components/OperationDatePicker";
export type { OperationDatePickerProps } from "./components/OperationDatePicker";
export { Badge } from "./components/badge";
// export type { BadgeProps } from "./components/badge";