mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Add countdown timer component and integrate phase deadlines in booking windows
This commit is contained in:
@@ -178,6 +178,8 @@ interface BookingWindowRow {
|
|||||||
window_phase: string | null;
|
window_phase: string | null;
|
||||||
window_opens_at: Date | null;
|
window_opens_at: Date | null;
|
||||||
window_closes_at: Date | null;
|
window_closes_at: Date | null;
|
||||||
|
doc_review_ends_at: Date | null;
|
||||||
|
payment_phase_ends_at: Date | null;
|
||||||
booking_window_status: string;
|
booking_window_status: string;
|
||||||
booking_cycle_no: number;
|
booking_cycle_no: number;
|
||||||
scheduled_departure_date: Date;
|
scheduled_departure_date: Date;
|
||||||
@@ -3027,6 +3029,8 @@ export class TrainSchedulingService {
|
|||||||
ts.window_phase,
|
ts.window_phase,
|
||||||
ts.window_opens_at,
|
ts.window_opens_at,
|
||||||
ts.window_closes_at,
|
ts.window_closes_at,
|
||||||
|
ts.doc_review_ends_at,
|
||||||
|
ts.payment_phase_ends_at,
|
||||||
ts.booking_window_status,
|
ts.booking_window_status,
|
||||||
ts.booking_cycle_no,
|
ts.booking_cycle_no,
|
||||||
ts.scheduled_departure_date,
|
ts.scheduled_departure_date,
|
||||||
@@ -3041,6 +3045,7 @@ export class TrainSchedulingService {
|
|||||||
ON c.id = cr.contract_id
|
ON c.id = cr.contract_id
|
||||||
AND c.company_id = $1
|
AND c.company_id = $1
|
||||||
AND c.status IN ('CONTRACT_ACTIVE', 'FULLY_EXECUTED')
|
AND c.status IN ('CONTRACT_ACTIVE', 'FULLY_EXECUTED')
|
||||||
|
AND c.contract_kind = 'GENERAL'
|
||||||
AND c.deleted_at IS NULL
|
AND c.deleted_at IS NULL
|
||||||
LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
|
LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
|
||||||
LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id
|
LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id
|
||||||
@@ -3068,6 +3073,8 @@ export class TrainSchedulingService {
|
|||||||
ts.window_phase,
|
ts.window_phase,
|
||||||
ts.window_opens_at,
|
ts.window_opens_at,
|
||||||
ts.window_closes_at,
|
ts.window_closes_at,
|
||||||
|
ts.doc_review_ends_at,
|
||||||
|
ts.payment_phase_ends_at,
|
||||||
ts.booking_window_status,
|
ts.booking_window_status,
|
||||||
ts.booking_cycle_no,
|
ts.booking_cycle_no,
|
||||||
ts.scheduled_departure_date,
|
ts.scheduled_departure_date,
|
||||||
@@ -3079,6 +3086,10 @@ export class TrainSchedulingService {
|
|||||||
AND cr.destination_yard_id = ts.destination_station_id
|
AND cr.destination_yard_id = ts.destination_station_id
|
||||||
AND cr.contract_id = $1
|
AND cr.contract_id = $1
|
||||||
AND cr.deleted_at IS NULL
|
AND cr.deleted_at IS NULL
|
||||||
|
JOIN freight.contracts c
|
||||||
|
ON c.id = cr.contract_id
|
||||||
|
AND c.contract_kind = 'GENERAL'
|
||||||
|
AND c.deleted_at IS NULL
|
||||||
LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
|
LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
|
||||||
LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id
|
LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id
|
||||||
WHERE ts.deleted_at IS NULL
|
WHERE ts.deleted_at IS NULL
|
||||||
@@ -3101,6 +3112,8 @@ export class TrainSchedulingService {
|
|||||||
isOpenNow: r.window_phase === 'OPEN' && r.booking_window_status === 'OPEN',
|
isOpenNow: r.window_phase === 'OPEN' && r.booking_window_status === 'OPEN',
|
||||||
windowOpensAt: r.window_opens_at,
|
windowOpensAt: r.window_opens_at,
|
||||||
windowClosesAt: r.window_closes_at,
|
windowClosesAt: r.window_closes_at,
|
||||||
|
docReviewEndsAt: r.doc_review_ends_at,
|
||||||
|
paymentPhaseEndsAt: r.payment_phase_ends_at,
|
||||||
bookingWindowStatus: r.booking_window_status,
|
bookingWindowStatus: r.booking_window_status,
|
||||||
bookingCycleNo: r.booking_cycle_no,
|
bookingCycleNo: r.booking_cycle_no,
|
||||||
departureDate: r.scheduled_departure_date,
|
departureDate: r.scheduled_departure_date,
|
||||||
@@ -3388,6 +3401,21 @@ export class TrainSchedulingService {
|
|||||||
freightType: this.resolveScheduleFreightType(schedule),
|
freightType: this.resolveScheduleFreightType(schedule),
|
||||||
trainNumber: schedule.trainNumber ?? null,
|
trainNumber: schedule.trainNumber ?? null,
|
||||||
direction: schedule.direction ?? null,
|
direction: schedule.direction ?? null,
|
||||||
|
// Booking-window phase + phase deadlines drive the countdown timers in the
|
||||||
|
// operations workspace (display only — the window engine enforces them).
|
||||||
|
windowPhase: schedule.windowPhase ?? null,
|
||||||
|
windowOpensAt: schedule.windowOpensAt
|
||||||
|
? schedule.windowOpensAt.toISOString()
|
||||||
|
: null,
|
||||||
|
windowClosesAt: schedule.windowClosesAt
|
||||||
|
? schedule.windowClosesAt.toISOString()
|
||||||
|
: null,
|
||||||
|
docReviewEndsAt: schedule.docReviewEndsAt
|
||||||
|
? schedule.docReviewEndsAt.toISOString()
|
||||||
|
: null,
|
||||||
|
paymentPhaseEndsAt: schedule.paymentPhaseEndsAt
|
||||||
|
? schedule.paymentPhaseEndsAt.toISOString()
|
||||||
|
: null,
|
||||||
route: schedule.route
|
route: schedule.route
|
||||||
? { id: schedule.route.id, name: formatRouteLabel(schedule.route) }
|
? { id: schedule.route.id, name: formatRouteLabel(schedule.route) }
|
||||||
: null,
|
: null,
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ import {
|
|||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import { CountdownTimer } from "@edr/ui-common";
|
||||||
|
|
||||||
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
|
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
@@ -45,6 +47,32 @@ interface ScheduleWorkspacePanelProps {
|
|||||||
|
|
||||||
const GREEN = "var(--mantine-color-edr-green-6)";
|
const GREEN = "var(--mantine-color-edr-green-6)";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deadline + label for the window phase this schedule is currently in.
|
||||||
|
* Phases run: window open (windowClosesAt) → document review (docReviewEndsAt)
|
||||||
|
* → payment (paymentPhaseEndsAt). Display only. Returns null off-phase.
|
||||||
|
*/
|
||||||
|
function phaseCountdown(
|
||||||
|
schedule: TrainScheduleDetail,
|
||||||
|
): { label: string; deadline: string } | null {
|
||||||
|
switch (schedule.windowPhase) {
|
||||||
|
case "OPEN":
|
||||||
|
return schedule.windowClosesAt
|
||||||
|
? { label: "Booking window closes in", deadline: schedule.windowClosesAt }
|
||||||
|
: null;
|
||||||
|
case "DOC_REVIEW":
|
||||||
|
return schedule.docReviewEndsAt
|
||||||
|
? { label: "Document review ends in", deadline: schedule.docReviewEndsAt }
|
||||||
|
: null;
|
||||||
|
case "PAYMENT":
|
||||||
|
return schedule.paymentPhaseEndsAt
|
||||||
|
? { label: "Payment window ends in", deadline: schedule.paymentPhaseEndsAt }
|
||||||
|
: null;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Cargo weight already allocated to this train (sum of on-train bookings). */
|
/** Cargo weight already allocated to this train (sum of on-train bookings). */
|
||||||
function usedWeight(schedule: TrainScheduleDetail): number {
|
function usedWeight(schedule: TrainScheduleDetail): number {
|
||||||
return (schedule.bookings ?? []).reduce(
|
return (schedule.bookings ?? []).reduce(
|
||||||
@@ -248,6 +276,25 @@ export function ScheduleWorkspacePanel({
|
|||||||
</Box>
|
</Box>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
|
{(() => {
|
||||||
|
const cd = phaseCountdown(schedule);
|
||||||
|
return cd ? (
|
||||||
|
<Group
|
||||||
|
gap={8}
|
||||||
|
p="xs"
|
||||||
|
wrap="nowrap"
|
||||||
|
align="center"
|
||||||
|
style={{
|
||||||
|
borderRadius: 10,
|
||||||
|
background: "var(--mantine-color-blue-0)",
|
||||||
|
border: "1px solid var(--mantine-color-blue-2)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CountdownTimer deadline={cd.deadline} label={cd.label} size="sm" />
|
||||||
|
</Group>
|
||||||
|
) : null;
|
||||||
|
})()}
|
||||||
|
|
||||||
{over ? (
|
{over ? (
|
||||||
<Group
|
<Group
|
||||||
gap={8}
|
gap={8}
|
||||||
|
|||||||
@@ -385,6 +385,11 @@ export interface TrainScheduleDetail {
|
|||||||
freightType?: FreightType | null;
|
freightType?: FreightType | null;
|
||||||
trainNumber?: string | null;
|
trainNumber?: string | null;
|
||||||
direction?: string | null;
|
direction?: string | null;
|
||||||
|
windowPhase?: BookingWindowPhase | string | null;
|
||||||
|
windowOpensAt?: string | null;
|
||||||
|
windowClosesAt?: string | null;
|
||||||
|
docReviewEndsAt?: string | null;
|
||||||
|
paymentPhaseEndsAt?: string | null;
|
||||||
route?: {
|
route?: {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Box, Button, Group, Skeleton, Stack, Text } from "@mantine/core";
|
|||||||
import { memo } from "react";
|
import { memo } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { ArrowRight, CalendarClock, PackagePlus } from "lucide-react";
|
import { ArrowRight, CalendarClock, PackagePlus } from "lucide-react";
|
||||||
|
import { CountdownTimer } from "@edr/ui-common";
|
||||||
import type { MyBookingWindow } from "@/services/bookings.service";
|
import type { MyBookingWindow } from "@/services/bookings.service";
|
||||||
import { Card } from "./Card";
|
import { Card } from "./Card";
|
||||||
|
|
||||||
@@ -43,6 +44,29 @@ function windowLabel(w: MyBookingWindow): string {
|
|||||||
return (w.windowPhase ?? w.bookingWindowStatus).replace(/_/g, " ");
|
return (w.windowPhase ?? w.bookingWindowStatus).replace(/_/g, " ");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The deadline + label for whichever phase the window is currently in. Phases
|
||||||
|
* run: window open (closes at windowClosesAt) → document review (docReviewEndsAt)
|
||||||
|
* → payment (paymentPhaseEndsAt). Returns null when no phase is timing down.
|
||||||
|
*/
|
||||||
|
function phaseCountdown(
|
||||||
|
w: MyBookingWindow,
|
||||||
|
): { label: string; deadline: string } | null {
|
||||||
|
switch (w.windowPhase) {
|
||||||
|
case "OPEN":
|
||||||
|
if (w.windowClosesAt) return { label: "Window closes in", deadline: w.windowClosesAt };
|
||||||
|
return null;
|
||||||
|
case "DOC_REVIEW":
|
||||||
|
if (w.docReviewEndsAt) return { label: "Document review ends in", deadline: w.docReviewEndsAt };
|
||||||
|
return null;
|
||||||
|
case "PAYMENT":
|
||||||
|
if (w.paymentPhaseEndsAt) return { label: "Payment due in", deadline: w.paymentPhaseEndsAt };
|
||||||
|
return null;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function Pill({
|
function Pill({
|
||||||
children,
|
children,
|
||||||
bg,
|
bg,
|
||||||
@@ -180,6 +204,18 @@ export const UpcomingWindowsSection = memo(function UpcomingWindowsSection({
|
|||||||
{windowLabel(w)} · Departs {fmtDay(w.departureDate)}
|
{windowLabel(w)} · Departs {fmtDay(w.departureDate)}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
|
{(() => {
|
||||||
|
const cd = phaseCountdown(w);
|
||||||
|
return cd ? (
|
||||||
|
<Box mt={4}>
|
||||||
|
<CountdownTimer
|
||||||
|
deadline={cd.deadline}
|
||||||
|
label={cd.label}
|
||||||
|
size="xs"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
) : null;
|
||||||
|
})()}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Group gap={8} wrap="nowrap" style={{ flexShrink: 0 }}>
|
<Group gap={8} wrap="nowrap" style={{ flexShrink: 0 }}>
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import { Box, Group, Text } from "@mantine/core";
|
import { Group } from "@mantine/core";
|
||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import { CreditCard, Download, Eye } from "lucide-react";
|
import { CreditCard } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { isViewable } from "@edr/ui-common";
|
|
||||||
|
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import { fileViewUrl } from "@/constants/apiConfig";
|
|
||||||
import { useFileViewer } from "@/hooks/useFileViewer";
|
import { useFileViewer } from "@/hooks/useFileViewer";
|
||||||
import { invoicesService } from "@/services/invoices.service";
|
import { invoicesService } from "@/services/invoices.service";
|
||||||
import { paymentsService, type PaymentMethod } from "@/services/payments.service";
|
import { paymentsService, type PaymentMethod } from "@/services/payments.service";
|
||||||
@@ -19,9 +17,8 @@ import { ClearanceCard } from "./components/ClearanceCard";
|
|||||||
import { ContainersCard } from "./components/ContainersCard";
|
import { ContainersCard } from "./components/ContainersCard";
|
||||||
import { ContractCard } from "./components/ContractCard";
|
import { ContractCard } from "./components/ContractCard";
|
||||||
import { CustomerTruckAssignmentCard } from "./components/CustomerTruckAssignmentCard";
|
import { CustomerTruckAssignmentCard } from "./components/CustomerTruckAssignmentCard";
|
||||||
import { DocRow, IconSquare } from "./components/Documents";
|
|
||||||
import { KeyFactsStrip } from "./components/KeyFactsStrip";
|
import { KeyFactsStrip } from "./components/KeyFactsStrip";
|
||||||
import { BodyGrid, CardTitle, PageShell, SectionCard } from "./components/layout";
|
import { BodyGrid, PageShell } from "./components/layout";
|
||||||
import {
|
import {
|
||||||
CancelledBanner,
|
CancelledBanner,
|
||||||
ConsolidationPairedNotice,
|
ConsolidationPairedNotice,
|
||||||
@@ -51,7 +48,7 @@ export function ReadonlyBookingView({
|
|||||||
useScrollToHash();
|
useScrollToHash();
|
||||||
const status = booking.status as string;
|
const status = booking.status as string;
|
||||||
const [payModalOpen, setPayModalOpen] = useState(false);
|
const [payModalOpen, setPayModalOpen] = useState(false);
|
||||||
const { view, viewer } = useFileViewer();
|
const { viewer } = useFileViewer();
|
||||||
|
|
||||||
// Re-book opens the New Shipment Booking form for the same contract, not the
|
// Re-book opens the New Shipment Booking form for the same contract, not the
|
||||||
// New Contract page. Fall back to /contracts/new only if the link is missing.
|
// New Contract page. Fall back to /contracts/new only if the link is missing.
|
||||||
@@ -160,7 +157,6 @@ export function ReadonlyBookingView({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
menuActions={{
|
menuActions={{
|
||||||
onViewContract: booking.signedByCeoAt ? () => {} : undefined,
|
|
||||||
onRebook,
|
onRebook,
|
||||||
onSupport: () => navigate("/support"),
|
onSupport: () => navigate("/support"),
|
||||||
}}
|
}}
|
||||||
@@ -199,7 +195,7 @@ export function ReadonlyBookingView({
|
|||||||
|
|
||||||
<KeyFactsStrip booking={booking} />
|
<KeyFactsStrip booking={booking} />
|
||||||
|
|
||||||
<ContractCard booking={booking} navigate={navigate} />
|
<ContractCard booking={booking} />
|
||||||
|
|
||||||
{isClearance && <ClearanceCard booking={booking} />}
|
{isClearance && <ClearanceCard booking={booking} />}
|
||||||
|
|
||||||
@@ -220,52 +216,6 @@ export function ReadonlyBookingView({
|
|||||||
)}
|
)}
|
||||||
<WarehousePaymentsSection bookingId={booking.id} />
|
<WarehousePaymentsSection bookingId={booking.id} />
|
||||||
|
|
||||||
{booking.files && booking.files.length > 0 && (
|
|
||||||
<SectionCard>
|
|
||||||
<Group justify="space-between" align="center" mb="md">
|
|
||||||
<CardTitle>Documents</CardTitle>
|
|
||||||
<Text fz="12.5px" fw={600} c="#9AA8B5">
|
|
||||||
{booking.files.length} files
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
<Box>
|
|
||||||
{booking.files.map((file, i) => (
|
|
||||||
<DocRow
|
|
||||||
key={file.id}
|
|
||||||
last={i === booking.files!.length - 1}
|
|
||||||
title={file.name}
|
|
||||||
meta={file.code.replace(/_/g, " ")}
|
|
||||||
status="verified"
|
|
||||||
action={
|
|
||||||
<Group gap={6} wrap="nowrap">
|
|
||||||
{isViewable({
|
|
||||||
name: file.name,
|
|
||||||
url: fileViewUrl(file.id),
|
|
||||||
mimeType: file.mimeType,
|
|
||||||
}) && (
|
|
||||||
<IconSquare
|
|
||||||
icon={<Eye size={16} />}
|
|
||||||
onClick={() =>
|
|
||||||
view({
|
|
||||||
name: file.name,
|
|
||||||
url: fileViewUrl(file.id),
|
|
||||||
mimeType: file.mimeType,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<IconSquare
|
|
||||||
href={fileViewUrl(file.id, true)}
|
|
||||||
icon={<Download size={16} />}
|
|
||||||
/>
|
|
||||||
</Group>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
</SectionCard>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ActivityCard booking={booking} />
|
<ActivityCard booking={booking} />
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import { Box, Button, Group, Paper, Text } from "@mantine/core";
|
import { Box, Group, Paper, Text } from "@mantine/core";
|
||||||
import { FileSignature } from "lucide-react";
|
|
||||||
import type { useNavigate } from "react-router-dom";
|
|
||||||
|
|
||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
@@ -37,13 +35,7 @@ const CONTRACT_CONFIG: Record<
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ContractCard({
|
export function ContractCard({ booking }: { booking: Freight.IBooking }) {
|
||||||
booking,
|
|
||||||
navigate,
|
|
||||||
}: {
|
|
||||||
booking: Freight.IBooking;
|
|
||||||
navigate: ReturnType<typeof useNavigate>;
|
|
||||||
}) {
|
|
||||||
const c = CONTRACT_CONFIG[booking.status as string];
|
const c = CONTRACT_CONFIG[booking.status as string];
|
||||||
if (!c) return null;
|
if (!c) return null;
|
||||||
|
|
||||||
@@ -76,20 +68,6 @@ export function ContractCard({
|
|||||||
{c.description}
|
{c.description}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
{c.buttonLabel && (
|
|
||||||
<Button
|
|
||||||
onClick={() => navigate(`/bookings/${booking.id}/contract`)}
|
|
||||||
radius={10}
|
|
||||||
color="edr-green"
|
|
||||||
leftSection={<FileSignature size={18} />}
|
|
||||||
styles={{
|
|
||||||
root: { height: 42, paddingInline: 18 },
|
|
||||||
label: { fontSize: 13, fontWeight: 700 },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{c.buttonLabel}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Group>
|
</Group>
|
||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ export interface MyBookingWindow {
|
|||||||
isOpenNow: boolean;
|
isOpenNow: boolean;
|
||||||
windowOpensAt: string | null;
|
windowOpensAt: string | null;
|
||||||
windowClosesAt: string | null;
|
windowClosesAt: string | null;
|
||||||
|
docReviewEndsAt: string | null;
|
||||||
|
paymentPhaseEndsAt: string | null;
|
||||||
bookingWindowStatus: string;
|
bookingWindowStatus: string;
|
||||||
bookingCycleNo: number;
|
bookingCycleNo: number;
|
||||||
departureDate: string;
|
departureDate: string;
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { Group, Text } from "@mantine/core";
|
||||||
|
import { Clock } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export interface CountdownTimerProps {
|
||||||
|
/** ISO timestamp the countdown targets. */
|
||||||
|
deadline: string | null | undefined;
|
||||||
|
/** Optional label shown before the time (e.g. "Window closes in"). */
|
||||||
|
label?: string;
|
||||||
|
/** Text shown once the deadline has passed. */
|
||||||
|
expiredText?: string;
|
||||||
|
/** Visual size of the time text. */
|
||||||
|
size?: "xs" | "sm" | "md" | "lg";
|
||||||
|
/** Colour once under this many seconds remain (urgency). Default 300 (5 min). */
|
||||||
|
urgentUnderSeconds?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pad(n: number): string {
|
||||||
|
return String(n).padStart(2, "0");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Break a remaining-milliseconds figure into a human string. */
|
||||||
|
function formatRemaining(ms: number): string {
|
||||||
|
const total = Math.floor(ms / 1000);
|
||||||
|
const days = Math.floor(total / 86400);
|
||||||
|
const hours = Math.floor((total % 86400) / 3600);
|
||||||
|
const minutes = Math.floor((total % 3600) / 60);
|
||||||
|
const seconds = total % 60;
|
||||||
|
|
||||||
|
if (days > 0) return `${days}d ${pad(hours)}h ${pad(minutes)}m`;
|
||||||
|
if (hours > 0) return `${hours}h ${pad(minutes)}m ${pad(seconds)}s`;
|
||||||
|
return `${pad(minutes)}m ${pad(seconds)}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live countdown to an ISO deadline. Ticks once a second, shows the remaining
|
||||||
|
* time (d/h/m/s), turns red when under `urgentUnderSeconds`, and shows
|
||||||
|
* `expiredText` once the deadline is in the past. Display only — enforcement
|
||||||
|
* lives server-side.
|
||||||
|
*/
|
||||||
|
export function CountdownTimer({
|
||||||
|
deadline,
|
||||||
|
label,
|
||||||
|
expiredText = "Expired",
|
||||||
|
size = "sm",
|
||||||
|
urgentUnderSeconds = 300,
|
||||||
|
}: CountdownTimerProps) {
|
||||||
|
const [remaining, setRemaining] = useState<number | null>(() =>
|
||||||
|
deadline ? new Date(deadline).getTime() - Date.now() : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!deadline) {
|
||||||
|
setRemaining(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const target = new Date(deadline).getTime();
|
||||||
|
const tick = () => setRemaining(target - Date.now());
|
||||||
|
tick();
|
||||||
|
const id = setInterval(tick, 1000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [deadline]);
|
||||||
|
|
||||||
|
if (!deadline || remaining == null || Number.isNaN(remaining)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expired = remaining <= 0;
|
||||||
|
const urgent = !expired && remaining <= urgentUnderSeconds * 1000;
|
||||||
|
const color = expired ? "red.7" : urgent ? "orange.7" : "dimmed";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Group gap={6} align="center" wrap="nowrap">
|
||||||
|
<Clock size={size === "lg" ? 18 : 14} />
|
||||||
|
{label && (
|
||||||
|
<Text size={size} c="dimmed">
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Text size={size} fw={600} c={color}>
|
||||||
|
{expired ? expiredText : formatRemaining(remaining)}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CountdownTimer;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { CountdownTimer, default } from "./CountdownTimer";
|
||||||
|
export type { CountdownTimerProps } from "./CountdownTimer";
|
||||||
@@ -25,6 +25,9 @@ export { useFileViewer } from "./hooks/useFileViewer";
|
|||||||
export { OperationDatePicker } from "./components/OperationDatePicker";
|
export { OperationDatePicker } from "./components/OperationDatePicker";
|
||||||
export type { OperationDatePickerProps } from "./components/OperationDatePicker";
|
export type { OperationDatePickerProps } from "./components/OperationDatePicker";
|
||||||
|
|
||||||
|
export { CountdownTimer } from "./components/CountdownTimer";
|
||||||
|
export type { CountdownTimerProps } from "./components/CountdownTimer";
|
||||||
|
|
||||||
export { Badge } from "./components/badge";
|
export { Badge } from "./components/badge";
|
||||||
// export type { BadgeProps } from "./components/badge";
|
// export type { BadgeProps } from "./components/badge";
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user