Files
seminarhof-e2e-mrffg074/apps/app/src/pages/admin.index.tsx
2026-07-10 23:07:47 +02:00

310 lines
9.0 KiB
TypeScript

import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { m } from '@/i18n/messages'
import { useLocale, getLocale } from '@/i18n/config'
import { asI18n } from '@pikku/react'
import {
Badge,
Box,
Container,
Grid,
Group,
Paper,
SimpleGrid,
Stack,
Text,
ThemeIcon,
Title,
} from '@pikku/mantine/core'
import {
CalendarDays,
FileClock,
FileText,
Inbox,
Wallet,
type LucideIcon,
} from 'lucide-react'
import { usePikkuQuery } from '@project/functions-sdk/pikku/api.gen'
import { RequireAuth } from '../auth'
import { usePageTitle } from '../components/AppLayout'
import { fmtDateShort } from '../lib/dates'
// One row inside an action card: clickable, navigates to the relevant workspace.
function Row({
primary,
secondary,
date,
trailing,
onClick,
}: {
primary: string
secondary?: string | null
date: string
trailing?: React.ReactNode
onClick: () => void
}) {
return (
<Group
justify="space-between"
wrap="nowrap"
gap="sm"
px="xs"
py={6}
onClick={onClick}
style={{ cursor: 'pointer', borderRadius: 8 }}
className="dash-row"
>
<Box style={{ minWidth: 0 }}>
<Text size="sm" fw={500} truncate>
{asI18n(`${primary}`)}
</Text>
{secondary && (
<Text size="xs" c="dimmed" truncate>
{asI18n(`${secondary}`)}
</Text>
)}
</Box>
<Group gap={8} wrap="nowrap" style={{ flex: '0 0 auto' }}>
{trailing}
<Text size="xs" c="dimmed">
{asI18n(`${date}`)}
</Text>
</Group>
</Group>
)
}
function ActionCard({
icon: Icon,
label,
count,
children,
}: {
icon: LucideIcon
label: string
count: number
children: React.ReactNode
}) {
const empty = count === 0
return (
<Paper withBorder radius="md" p="md">
<Group justify="space-between" wrap="nowrap" mb={children ? 'xs' : 0}>
<Group gap="xs" wrap="nowrap">
<ThemeIcon
variant="light"
color={empty ? 'gray' : 'plum'}
size="md"
radius="md"
>
<Icon size={16} />
</ThemeIcon>
<Text size="sm" fw={600}>
{asI18n(`${label}`)}
</Text>
</Group>
<Badge
color={empty ? 'gray' : 'plum'}
variant={empty ? 'light' : 'filled'}
radius="sm"
>
{count}
</Badge>
</Group>
{!empty && <Stack gap={2}>{children}</Stack>}
</Paper>
)
}
function DashboardInner() {
useLocale()
const navigate = useNavigate()
usePageTitle(m.common__pages__admin__dashboard__title())
const { data, isLoading, error } = usePikkuQuery('getAdminDashboard', {
venueSlug: 'drawehn',
})
if (isLoading && !data) {
return (
<Container py="xl">
<Text>{m.common__states__loading()}</Text>
</Container>
)
}
if (error && !data) {
return (
<Container py="xl">
<Text c="red">{m.common__states__error()}</Text>
</Container>
)
}
if (!data) return null
const goEnquiries = () => navigate({ to: '/admin/enquiries' })
const goBooking = (bookingId: string) =>
navigate({ to: '/admin/bookings/$bookingId', params: { bookingId } })
const more = (bucket: { count: number; items: unknown[] }) =>
bucket.count > bucket.items.length ? (
<Text size="xs" c="dimmed" px="xs" pt={4}>
{m.common__pages__admin__dashboard__more({ count: bucket.count - bucket.items.length })}
</Text>
) : null
const noAction =
data.pendingEnquiries.count === 0 &&
data.contractsToSend.count === 0 &&
data.contractsAwaitingResponse.count === 0 &&
data.depositsOutstanding.count === 0
// A "needs action" hub should only surface what needs action — zero-count
// cards are noise, so we drop them and let `noAction` cover the empty state.
const bookingCard = (
key: string,
icon: LucideIcon,
label: string,
bucket: typeof data.contractsToSend,
) =>
bucket.count === 0 ? null : (
<ActionCard key={key} icon={icon} label={label} count={bucket.count}>
{bucket.items.map((b) => (
<Row
key={b.bookingId}
primary={b.eventName}
secondary={b.clientName}
date={fmtDateShort(b.startDate, getLocale())}
onClick={() => goBooking(b.bookingId)}
/>
))}
{more(bucket)}
</ActionCard>
)
const actionCards = [
data.pendingEnquiries.count === 0 ? null : (
<ActionCard
key="enq"
icon={Inbox}
label={m.common__pages__admin__dashboard__cards__pending_enquiries()}
count={data.pendingEnquiries.count}
>
{data.pendingEnquiries.items.map((e) => (
<Row
key={e.enquiryId}
primary={e.eventName}
secondary={e.contactName}
date={fmtDateShort(e.startDate, getLocale())}
trailing={
e.waitlisted ? (
<Badge size="xs" color="orange" variant="light">
{m.common__pages__admin__dashboard__waitlisted()}
</Badge>
) : null
}
onClick={goEnquiries}
/>
))}
{more(data.pendingEnquiries)}
</ActionCard>
),
bookingCard('send', FileText, m.common__pages__admin__dashboard__cards__contracts_to_send(), data.contractsToSend),
bookingCard(
'await',
FileClock,
m.common__pages__admin__dashboard__cards__contracts_awaiting(),
data.contractsAwaitingResponse,
),
bookingCard('dep', Wallet, m.common__pages__admin__dashboard__cards__deposits_outstanding(), data.depositsOutstanding),
].filter(Boolean)
return (
<Container size="xl" py="md">
<Grid gap="lg">
{/* Needs action */}
<Grid.Col span={{ base: 12, md: 7 }}>
<Stack gap="sm">
<Title order={4}>{m.common__pages__admin__dashboard__needs_action()}</Title>
{noAction ? (
<Paper withBorder radius="md" p="lg">
<Text size="sm" c="dimmed" ta="center">
{m.common__pages__admin__dashboard__all_clear()}
</Text>
</Paper>
) : (
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
{actionCards}
</SimpleGrid>
)}
</Stack>
</Grid.Col>
{/* Upcoming events */}
<Grid.Col span={{ base: 12, md: 5 }}>
<Stack gap="sm">
<Group gap="xs" align="baseline">
<Title order={4}>{m.common__pages__admin__dashboard__upcoming()}</Title>
<Text size="xs" c="dimmed">
{m.common__pages__admin__dashboard__upcoming_window()}
</Text>
</Group>
<Paper withBorder radius="md" p={data.upcomingEvents.count ? 'xs' : 'lg'}>
{data.upcomingEvents.count === 0 ? (
<Text size="sm" c="dimmed" ta="center">
{m.common__pages__admin__dashboard__no_upcoming()}
</Text>
) : (
<Stack gap={2}>
{data.upcomingEvents.items.map((b) => (
<Group
key={b.bookingId}
justify="space-between"
wrap="nowrap"
gap="sm"
px="xs"
py={8}
onClick={() => goBooking(b.bookingId)}
style={{ cursor: 'pointer', borderRadius: 8 }}
className="dash-row"
>
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon
variant="light"
color="plum"
size="lg"
radius="md"
>
<CalendarDays size={18} />
</ThemeIcon>
<Box style={{ minWidth: 0 }}>
<Text size="sm" fw={500} truncate>
{asI18n(`${b.eventName}`)}
</Text>
<Text size="xs" c="dimmed" truncate>
{asI18n(
`${b.clientName}${b.expectedPersons != null ? ` · ${m.common__pages__admin__dashboard__persons({ count: b.expectedPersons })}` : ''}`,
)}
</Text>
</Box>
</Group>
<Text size="xs" c="dimmed" style={{ flex: '0 0 auto' }}>
{asI18n(`${fmtDateShort(b.startDate, getLocale())}`)}
</Text>
</Group>
))}
</Stack>
)}
</Paper>
</Stack>
</Grid.Col>
</Grid>
</Container>
)
}
export const Route = createFileRoute('/admin/')({
component: () => (
<RequireAuth roles={['admin', 'owner']}>
<DashboardInner />
</RequireAuth>
),
})