mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'dev' into freight/feat/invoice
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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") ?? "";
|
||||
}
|
||||
|
||||
@@ -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") ?? "";
|
||||
}
|
||||
|
||||
652
packages/types/src/freight/contracts.ts
Normal file
652
packages/types/src/freight/contracts.ts
Normal 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;
|
||||
}
|
||||
@@ -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"]`.
|
||||
|
||||
@@ -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'
|
||||
|
||||
307
packages/ui-common/src/components/FileViewer/FileViewer.tsx
Normal file
307
packages/ui-common/src/components/FileViewer/FileViewer.tsx
Normal 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 can’t 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;
|
||||
7
packages/ui-common/src/components/FileViewer/index.ts
Normal file
7
packages/ui-common/src/components/FileViewer/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
FileViewerModal,
|
||||
isViewable,
|
||||
resolveViewerKind,
|
||||
} from "./FileViewer";
|
||||
export type { FileViewerModalProps, ViewableFile } from "./FileViewer";
|
||||
export { default } from "./FileViewer";
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
OperationDatePicker,
|
||||
default,
|
||||
} from "./OperationDatePicker";
|
||||
export type { OperationDatePickerProps } from "./OperationDatePicker";
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user