310 lines
9.0 KiB
TypeScript
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>
|
|
),
|
|
})
|