Files
seminarhof-e2e-mrfyultj/apps/app/src/components/BookingStatusPanel.tsx
2026-07-11 08:11:01 +02:00

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>
)
}