270 lines
9.9 KiB
TypeScript
270 lines
9.9 KiB
TypeScript
import { m } from '@/i18n/messages'
|
|
import { flagReason as flagReasonLabels } from '@/i18n/i18n-enum.gen'
|
|
import { useLocale, getLocale } from '@/i18n/config'
|
|
import { asI18n } from '@pikku/react'
|
|
import {
|
|
Alert,
|
|
Group,
|
|
Progress,
|
|
Stack,
|
|
Stepper,
|
|
Text,
|
|
} from '@pikku/mantine/core'
|
|
import { AlertTriangle, Clock } from 'lucide-react'
|
|
import { fmtDate } from '../lib/dates'
|
|
import type { BookingStatus } from '../lib/status'
|
|
|
|
// ─── Types ────────────────────────────────────────────────────────────────
|
|
|
|
type DepositInvoice = {
|
|
invoiceId: string
|
|
kind: string
|
|
dueOn: Date | null
|
|
paidOn: Date | null
|
|
status: string
|
|
}
|
|
|
|
type BookingMilestones = {
|
|
bookingId: string
|
|
status: BookingStatus
|
|
startDate: Date | null
|
|
enquiryAt: Date
|
|
reservedAt: Date | null
|
|
confirmedAt: Date | null
|
|
endedAt: Date | null
|
|
cancelledAt: Date | null
|
|
contractSentAt: Date | null
|
|
contractResponse: string | null
|
|
contractResponseAt: Date | null
|
|
depositReminderSentAt: Date | null
|
|
flaggedAt: Date | null
|
|
flagReason: string | null
|
|
}
|
|
|
|
// ─── Helpers ──────────────────────────────────────────────────────────────
|
|
|
|
function berlinToday(): string {
|
|
return new Date().toLocaleDateString('sv-SE', { timeZone: 'Europe/Berlin' })
|
|
}
|
|
|
|
function berlinDateStr(d: Date): string {
|
|
return d.toLocaleDateString('sv-SE', { timeZone: 'Europe/Berlin' })
|
|
}
|
|
|
|
function daysBetween(from: Date, toYmd: string): number {
|
|
return Math.round((Date.parse(toYmd) - from.getTime()) / 86_400_000)
|
|
}
|
|
|
|
function addDaysToDate(d: Date, n: number): Date {
|
|
const result = new Date(d)
|
|
result.setUTCDate(result.getUTCDate() + n)
|
|
return result
|
|
}
|
|
|
|
type NextAction =
|
|
| { kind: 'flagged'; reason: string }
|
|
| { kind: 'awaiting_1yr' }
|
|
| { kind: 'awaiting_signature' }
|
|
| { kind: 'awaiting_deposit'; dueOn: Date | null }
|
|
| { kind: 'contract_declined' }
|
|
| null
|
|
|
|
function nextAction(booking: BookingMilestones, deposit: DepositInvoice | null): NextAction {
|
|
const { status, flaggedAt, flagReason, contractSentAt, contractResponse } = booking
|
|
if (status === 'cancelled' || status === 'ended' || status === 'confirmed' || status === 'enquiry') return null
|
|
if (flaggedAt && flagReason === 'contract_declined') return { kind: 'contract_declined' }
|
|
if (flaggedAt) return { kind: 'flagged', reason: flagReason ?? '' }
|
|
if (status === 'reserved') {
|
|
if (!contractSentAt) return { kind: 'awaiting_1yr' }
|
|
if (!contractResponse) return { kind: 'awaiting_signature' }
|
|
if (contractResponse === 'approved' && !deposit?.paidOn) return { kind: 'awaiting_deposit', dueOn: deposit?.dueOn ?? null }
|
|
}
|
|
return null
|
|
}
|
|
|
|
// ─── Sub-components ───────────────────────────────────────────────────────
|
|
|
|
function DepositProgress({ contractSentAt, deposit, lng }: {
|
|
contractSentAt: Date
|
|
deposit: DepositInvoice | null
|
|
lng: string
|
|
}) {
|
|
useLocale()
|
|
const today = berlinToday()
|
|
const daysSent = daysBetween(contractSentAt, today)
|
|
const pct = Math.min(100, Math.round((daysSent / 14) * 100))
|
|
const color = daysSent >= 14 ? 'red' : daysSent >= 7 ? 'yellow' : 'plum'
|
|
const dueDate = deposit?.dueOn ?? addDaysToDate(contractSentAt, 14)
|
|
const dueDateStr = berlinDateStr(dueDate)
|
|
const overdue = !deposit?.paidOn && dueDateStr < today
|
|
|
|
return (
|
|
<Stack gap={4}>
|
|
<Group justify="space-between">
|
|
<Text size="xs" fw={500}>
|
|
{deposit?.paidOn
|
|
? m.common__pages__admin_booking__status__milestones__deposit_paid()
|
|
: overdue
|
|
? m.common__pages__admin_booking__status__deposit_overdue()
|
|
: m.common__pages__admin_booking__status__deposit_window({ day: daysSent })}
|
|
</Text>
|
|
{deposit?.paidOn ? null : (
|
|
<Text size="xs" c="dimmed">
|
|
{m.common__pages__admin_booking__status__due_on({ date: fmtDate(dueDate, lng) })}
|
|
</Text>
|
|
)}
|
|
</Group>
|
|
{!deposit?.paidOn && (
|
|
<Progress value={pct} color={color} size="sm" radius="xl" />
|
|
)}
|
|
</Stack>
|
|
)
|
|
}
|
|
|
|
// ─── Main component ───────────────────────────────────────────────────────
|
|
|
|
export function BookingStatusPanel({
|
|
booking,
|
|
invoices,
|
|
}: {
|
|
booking: BookingMilestones
|
|
invoices: DepositInvoice[]
|
|
}) {
|
|
useLocale()
|
|
const lng = getLocale()
|
|
|
|
const deposit = invoices.find(inv => inv.kind === 'deposit') ?? null
|
|
|
|
const action = nextAction(booking, deposit)
|
|
|
|
// ── Stepper steps ──
|
|
const steps = [
|
|
{
|
|
key: 'enquiry',
|
|
label: m.common__pages__admin_booking__status__milestones__enquiry(),
|
|
description: fmtDate(booking.enquiryAt, lng),
|
|
done: true,
|
|
},
|
|
{
|
|
key: 'reserved',
|
|
label: m.common__pages__admin_booking__status__milestones__reserved(),
|
|
description: booking.reservedAt ? fmtDate(booking.reservedAt, lng) : null,
|
|
done: ['reserved', 'confirmed', 'ended'].includes(booking.status),
|
|
},
|
|
{
|
|
key: 'contractSent',
|
|
label: m.common__pages__admin_booking__status__milestones__contract_sent(),
|
|
description: booking.contractSentAt ? fmtDate(booking.contractSentAt, lng) : null,
|
|
done: !!booking.contractSentAt,
|
|
},
|
|
{
|
|
key: 'contractSigned',
|
|
label: m.common__pages__admin_booking__status__milestones__contract_signed(),
|
|
description: booking.contractResponseAt && booking.contractResponse === 'approved'
|
|
? fmtDate(booking.contractResponseAt, lng) : null,
|
|
done: booking.contractResponse === 'approved',
|
|
},
|
|
{
|
|
key: 'depositPaid',
|
|
label: m.common__pages__admin_booking__status__milestones__deposit_paid(),
|
|
description: deposit?.paidOn ? fmtDate(deposit.paidOn, lng) : null,
|
|
done: !!deposit?.paidOn,
|
|
},
|
|
{
|
|
key: 'confirmed',
|
|
label: m.common__pages__admin_booking__status__milestones__confirmed(),
|
|
description: booking.confirmedAt ? fmtDate(booking.confirmedAt, lng) : null,
|
|
done: ['confirmed', 'ended'].includes(booking.status),
|
|
},
|
|
{
|
|
key: 'ended',
|
|
label: m.common__pages__admin_booking__status__milestones__ended(),
|
|
description: booking.endedAt ? fmtDate(booking.endedAt, lng) : null,
|
|
done: booking.status === 'ended',
|
|
},
|
|
]
|
|
|
|
const activeStep = (() => {
|
|
switch (booking.status) {
|
|
case 'ended': return steps.length // all steps get checkmarks
|
|
case 'confirmed': return steps.length - 1 // "ended" step is current
|
|
case 'cancelled': return steps.reduce((acc, s, i) => (s.done ? i : acc), -1)
|
|
default: return steps.findIndex(s => !s.done) // enquiry/reserved: use milestone data
|
|
}
|
|
})()
|
|
|
|
return (
|
|
<Stack gap="md">
|
|
{/* Flagged alert */}
|
|
{booking.flaggedAt && booking.flagReason && (
|
|
<Alert color="red" icon={<AlertTriangle size={16} />} title={m.common__pages__admin_booking__status__flagged()}>
|
|
{booking.flagReason in flagReasonLabels
|
|
? flagReasonLabels[booking.flagReason as keyof typeof flagReasonLabels]()
|
|
: asI18n(booking.flagReason)}
|
|
</Alert>
|
|
)}
|
|
|
|
{/* ── Horizontal stepper ── */}
|
|
<Stepper
|
|
active={activeStep}
|
|
color={booking.status === 'cancelled' ? 'red' : 'plum'}
|
|
size="xs"
|
|
styles={{ stepLabel: { fontSize: 'var(--mantine-font-size-xs)' }, stepDescription: { fontSize: 'var(--mantine-font-size-xs)' } }}
|
|
>
|
|
{steps.map((step) => (
|
|
<Stepper.Step
|
|
key={step.key}
|
|
label={step.label}
|
|
description={asI18n(step.description ?? 'N/A')}
|
|
/>
|
|
))}
|
|
{booking.status === 'cancelled' && (
|
|
<Stepper.Step
|
|
key="cancelled"
|
|
color="red"
|
|
label={m.common__pages__admin_booking__status__milestones__cancelled()}
|
|
description={booking.cancelledAt ? asI18n(fmtDate(booking.cancelledAt, lng)) : undefined}
|
|
/>
|
|
)}
|
|
</Stepper>
|
|
|
|
{/* Deposit progress bar (when contract sent but deposit not yet paid) */}
|
|
{booking.contractSentAt && !deposit?.paidOn && (
|
|
<DepositProgress contractSentAt={booking.contractSentAt} deposit={deposit} lng={lng} />
|
|
)}
|
|
|
|
{/* ── Next action alert (informational; controls live in BookingActions) ── */}
|
|
{action && (
|
|
<Alert
|
|
color={
|
|
action.kind === 'flagged' || action.kind === 'contract_declined' ? 'red'
|
|
: action.kind === 'awaiting_deposit' ? 'orange'
|
|
: 'blue'
|
|
}
|
|
icon={<Clock size={16} />}
|
|
>
|
|
{action.kind === 'flagged' &&
|
|
(action.reason in flagReasonLabels
|
|
? flagReasonLabels[action.reason as keyof typeof flagReasonLabels]()
|
|
: asI18n(action.reason))}
|
|
{action.kind === 'awaiting_1yr' && m.common__pages__admin_booking__status__contract_not_sent_yet({
|
|
date: booking.startDate ? fmtDate(addDaysToDate(booking.startDate, -365), lng) : '—',
|
|
})}
|
|
{action.kind === 'awaiting_signature' && m.common__pages__admin_booking__status__awaiting_signature()}
|
|
{action.kind === 'awaiting_deposit' && (
|
|
<>
|
|
{m.common__pages__admin_booking__status__awaiting_deposit()}
|
|
{action.dueOn && (
|
|
<Text size="xs" mt={2}>
|
|
{m.common__pages__admin_booking__status__due_on({ date: fmtDate(action.dueOn, lng) })}
|
|
</Text>
|
|
)}
|
|
</>
|
|
)}
|
|
{action.kind === 'contract_declined' && flagReasonLabels.contract_declined()}
|
|
</Alert>
|
|
)}
|
|
</Stack>
|
|
)
|
|
}
|