chore: seminarhof customer project
This commit is contained in:
311
apps/app/src/pages/admin.index.tsx
Normal file
311
apps/app/src/pages/admin.index.tsx
Normal file
@@ -0,0 +1,311 @@
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { m, mKey } 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 tk = (k: string, opts?: Record<string, unknown>) =>
|
||||
mKey(`common.pages.admin.dashboard.${k}` as any, opts ?? {})
|
||||
const more = (bucket: { count: number; items: unknown[] }) =>
|
||||
bucket.count > bucket.items.length ? (
|
||||
<Text size="xs" c="dimmed" px="xs" pt={4}>
|
||||
{tk('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={tk('cards.pendingEnquiries')}
|
||||
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">
|
||||
{tk('waitlisted')}
|
||||
</Badge>
|
||||
) : null
|
||||
}
|
||||
onClick={goEnquiries}
|
||||
/>
|
||||
))}
|
||||
{more(data.pendingEnquiries)}
|
||||
</ActionCard>
|
||||
),
|
||||
bookingCard('send', FileText, tk('cards.contractsToSend'), data.contractsToSend),
|
||||
bookingCard(
|
||||
'await',
|
||||
FileClock,
|
||||
tk('cards.contractsAwaiting'),
|
||||
data.contractsAwaitingResponse,
|
||||
),
|
||||
bookingCard('dep', Wallet, tk('cards.depositsOutstanding'), 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}>{tk('needsAction')}</Title>
|
||||
{noAction ? (
|
||||
<Paper withBorder radius="md" p="lg">
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{tk('allClear')}
|
||||
</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}>{tk('upcoming')}</Title>
|
||||
<Text size="xs" c="dimmed">
|
||||
{tk('upcomingWindow')}
|
||||
</Text>
|
||||
</Group>
|
||||
<Paper withBorder radius="md" p={data.upcomingEvents.count ? 'xs' : 'lg'}>
|
||||
{data.upcomingEvents.count === 0 ? (
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{tk('noUpcoming')}
|
||||
</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 ? ` · ${tk('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>
|
||||
),
|
||||
})
|
||||
Reference in New Issue
Block a user