chore: seminarhof customer project
This commit is contained in:
269
apps/app/src/components/BookingStatusPanel.tsx
Normal file
269
apps/app/src/components/BookingStatusPanel.tsx
Normal file
@@ -0,0 +1,269 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user