339 lines
11 KiB
TypeScript
339 lines
11 KiB
TypeScript
import { createFileRoute, Link } from '@tanstack/react-router'
|
||
import { RequireAuth } from '../../../../auth'
|
||
import {
|
||
ActionIcon,
|
||
Alert,
|
||
Anchor,
|
||
Badge,
|
||
Box,
|
||
Breadcrumbs,
|
||
Button,
|
||
Card,
|
||
Container,
|
||
Divider,
|
||
Group,
|
||
Loader,
|
||
Paper,
|
||
SimpleGrid,
|
||
Stack,
|
||
Text,
|
||
Title,
|
||
Tooltip,
|
||
} from '@pikku/mantine/core'
|
||
import { m, mKey } from '@/i18n/messages'
|
||
import { useLocale, getLocale } from '@/i18n/config'
|
||
import { asI18n } from '@pikku/react'
|
||
import { useMemo, useState } from 'react'
|
||
import { usePikkuQuery, usePikkuMutation } from '@germantax/functions-sdk/pikku/api.gen'
|
||
|
||
type FieldType = 'text' | 'textarea' | 'number' | 'date' | 'currency'
|
||
|
||
const formatValue = (
|
||
value: unknown,
|
||
type: FieldType,
|
||
locale: string,
|
||
): string => {
|
||
if (value === undefined || value === null || value === '') return '—'
|
||
if (type === 'currency') {
|
||
const n = typeof value === 'number' ? value : Number(value)
|
||
if (Number.isFinite(n)) {
|
||
return new Intl.NumberFormat(locale, {
|
||
style: 'currency',
|
||
currency: 'EUR',
|
||
maximumFractionDigits: 2,
|
||
}).format(n)
|
||
}
|
||
}
|
||
if (type === 'date') {
|
||
const d = new Date(String(value))
|
||
if (!Number.isNaN(d.getTime())) {
|
||
return new Intl.DateTimeFormat(locale, { dateStyle: 'medium' }).format(d)
|
||
}
|
||
}
|
||
if (type === 'number') {
|
||
const n = typeof value === 'number' ? value : Number(value)
|
||
if (Number.isFinite(n)) return new Intl.NumberFormat(locale).format(n)
|
||
}
|
||
return String(value)
|
||
}
|
||
|
||
const formatTimestamp = (iso: string | null | undefined, locale: string) => {
|
||
if (!iso) return null
|
||
const d = new Date(iso)
|
||
if (Number.isNaN(d.getTime())) return null
|
||
return new Intl.DateTimeFormat(locale, {
|
||
dateStyle: 'medium',
|
||
timeStyle: 'short',
|
||
}).format(d)
|
||
}
|
||
|
||
const statusColor = (status: string) =>
|
||
status === 'published' ? 'green' : status === 'archived' ? 'gray' : 'blue'
|
||
|
||
function ResolutionPage() {
|
||
const { resolutionId, companyId } = Route.useParams()
|
||
useLocale()
|
||
const locale = getLocale() || 'en'
|
||
|
||
const r = usePikkuQuery('getResolution', { resolutionId })
|
||
const company = usePikkuQuery('getCompany', { companyId })
|
||
const templates = usePikkuQuery(
|
||
'listResolutionTemplates',
|
||
undefined as never,
|
||
)
|
||
const pdfUrl = usePikkuMutation('getResolutionPdfUrl')
|
||
const publish = usePikkuMutation('publishResolution', {
|
||
onSuccess: () => r.refetch(),
|
||
})
|
||
const archive = usePikkuMutation('archiveResolution', {
|
||
onSuccess: () => r.refetch(),
|
||
})
|
||
|
||
const [embeddedPdf, setEmbeddedPdf] = useState<string | null>(null)
|
||
|
||
const isMd = company.data?.myRole === 'managing_director'
|
||
|
||
const template = useMemo(
|
||
() => templates.data?.find((tpl) => tpl.key === r.data?.templateKey),
|
||
[templates.data, r.data?.templateKey],
|
||
)
|
||
|
||
const labelLang = locale.startsWith('de') ? 'labelDe' : 'labelEn'
|
||
const titleLang = locale.startsWith('de') ? 'titleDe' : 'titleEn'
|
||
|
||
const fieldRows = useMemo(() => {
|
||
if (!r.data) return []
|
||
const values = r.data.fieldValues as Record<string, unknown>
|
||
if (template?.fields?.length) {
|
||
return template.fields.map((f) => ({
|
||
key: f.key,
|
||
label: (f as any)[labelLang] ?? f.key,
|
||
value: formatValue(values?.[f.key], f.type as FieldType, locale),
|
||
}))
|
||
}
|
||
return Object.entries(values ?? {}).map(([k, v]) => ({
|
||
key: k,
|
||
label: k,
|
||
value: formatValue(v, 'text', locale),
|
||
}))
|
||
}, [r.data, template, labelLang, locale])
|
||
|
||
const openPdf = async (embed: boolean) => {
|
||
const res = await pdfUrl.mutateAsync({ resolutionId })
|
||
if (embed) {
|
||
setEmbeddedPdf(res.url)
|
||
} else {
|
||
window.open(res.url, '_blank', 'noopener,noreferrer')
|
||
}
|
||
}
|
||
|
||
const createdAt = formatTimestamp(r.data?.createdAt, locale)
|
||
const publishedAt = formatTimestamp(r.data?.publishedAt, locale)
|
||
|
||
return (
|
||
<Container size="lg" py="xl">
|
||
<Stack gap="lg">
|
||
<Breadcrumbs>
|
||
<Anchor component={Link} to="/" size="sm">
|
||
{m.app_nav_home({ defaultValue: 'Home' })}
|
||
</Anchor>
|
||
<Anchor
|
||
component={Link}
|
||
to="/companies/$companyId"
|
||
params={{ companyId } as any}
|
||
size="sm"
|
||
>
|
||
{asI18n(company.data?.name ?? '…')}
|
||
</Anchor>
|
||
<Text size="sm" c="dimmed">
|
||
{m.app_resolutions_resolution({ defaultValue: 'Resolution' })}
|
||
</Text>
|
||
</Breadcrumbs>
|
||
|
||
{r.error && <Alert color="red">{asI18n(String(r.error.message))}</Alert>}
|
||
{!r.data && !r.error && (
|
||
<Group justify="center" py="xl">
|
||
<Loader size="sm" />
|
||
</Group>
|
||
)}
|
||
|
||
{r.data && (
|
||
<>
|
||
<Paper withBorder radius="md" p="lg">
|
||
<Stack gap="md">
|
||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||
<Stack gap={4} style={{ minWidth: 0 }}>
|
||
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
|
||
{template
|
||
? (template as any)[titleLang]
|
||
: r.data.templateKey}
|
||
</Text>
|
||
<Title order={2} style={{ wordBreak: 'break-word' }}>
|
||
{asI18n(r.data.title)}
|
||
</Title>
|
||
</Stack>
|
||
<Badge size="lg" color={statusColor(r.data.status)} variant="light">
|
||
{mKey(`app.resolutions.${r.data.status}`)}
|
||
</Badge>
|
||
</Group>
|
||
|
||
<Divider />
|
||
|
||
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="md">
|
||
<Stack gap={2}>
|
||
<Text size="xs" c="dimmed" tt="uppercase">
|
||
{m.app_resolutions_created({ defaultValue: 'Created' })}
|
||
</Text>
|
||
<Text size="sm">{asI18n(createdAt ?? '—')}</Text>
|
||
</Stack>
|
||
<Stack gap={2}>
|
||
<Text size="xs" c="dimmed" tt="uppercase">
|
||
{m.app_resolutions_published({ defaultValue: 'Published' })}
|
||
</Text>
|
||
<Text size="sm">{asI18n(publishedAt ?? '—')}</Text>
|
||
</Stack>
|
||
<Stack gap={2}>
|
||
<Text size="xs" c="dimmed" tt="uppercase">
|
||
{m.app_resolutions_id({ defaultValue: 'ID' })}
|
||
</Text>
|
||
<Tooltip label={asI18n(resolutionId)}>
|
||
<Text size="sm" ff="monospace">
|
||
{asI18n(`${resolutionId.slice(0, 8)}…`)}
|
||
</Text>
|
||
</Tooltip>
|
||
</Stack>
|
||
</SimpleGrid>
|
||
|
||
<Group gap="sm">
|
||
{isMd && r.data.status === 'draft' && (
|
||
<Button
|
||
color="green"
|
||
loading={publish.isPending}
|
||
onClick={() => publish.mutate({ resolutionId })}
|
||
>
|
||
{m.app_resolutions_publish()}
|
||
</Button>
|
||
)}
|
||
{r.data.status === 'published' && (
|
||
<>
|
||
<Button
|
||
variant="filled"
|
||
loading={pdfUrl.isPending && embeddedPdf === null}
|
||
onClick={() => openPdf(true)}
|
||
>
|
||
{m.app_resolutions_view_pdf({
|
||
defaultValue: 'View PDF',
|
||
})}
|
||
</Button>
|
||
<Button
|
||
variant="default"
|
||
loading={pdfUrl.isPending && embeddedPdf !== null}
|
||
onClick={() => openPdf(false)}
|
||
>
|
||
{m.app_resolutions_open_pdf({
|
||
defaultValue: 'Open in new tab',
|
||
})}
|
||
</Button>
|
||
</>
|
||
)}
|
||
{isMd && r.data.status !== 'archived' && (
|
||
<Button
|
||
variant="subtle"
|
||
color="gray"
|
||
onClick={() => archive.mutate({ resolutionId })}
|
||
>
|
||
{m.app_resolutions_archive()}
|
||
</Button>
|
||
)}
|
||
</Group>
|
||
</Stack>
|
||
</Paper>
|
||
|
||
<Card withBorder radius="md" p="lg">
|
||
<Stack gap="md">
|
||
<Group justify="space-between">
|
||
<Title order={4}>
|
||
{m.app_resolutions_fields({ defaultValue: 'Details' })}
|
||
</Title>
|
||
{template && (
|
||
<Text size="xs" c="dimmed">
|
||
{(template as any).descriptionDe}
|
||
</Text>
|
||
)}
|
||
</Group>
|
||
|
||
{fieldRows.length === 0 ? (
|
||
<Text size="sm" c="dimmed">
|
||
{m.app_resolutions_no_fields({
|
||
defaultValue: 'No fields recorded.',
|
||
})}
|
||
</Text>
|
||
) : (
|
||
<Stack gap={0}>
|
||
{fieldRows.map((row, i) => (
|
||
<Box key={row.key}>
|
||
{i > 0 && <Divider />}
|
||
<SimpleGrid
|
||
cols={{ base: 1, sm: 3 }}
|
||
spacing="md"
|
||
py="sm"
|
||
>
|
||
<Text size="sm" c="dimmed" fw={500}>
|
||
{row.label}
|
||
</Text>
|
||
<Box style={{ gridColumn: 'span 2' }}>
|
||
<Text size="sm" style={{ whiteSpace: 'pre-wrap' }}>
|
||
{asI18n(row.value)}
|
||
</Text>
|
||
</Box>
|
||
</SimpleGrid>
|
||
</Box>
|
||
))}
|
||
</Stack>
|
||
)}
|
||
</Stack>
|
||
</Card>
|
||
|
||
{embeddedPdf && (
|
||
<Card withBorder radius="md" p={0} style={{ overflow: 'hidden' }}>
|
||
<Group
|
||
justify="space-between"
|
||
px="md"
|
||
py="xs"
|
||
style={{ borderBottom: '1px solid var(--mantine-color-gray-3)' }}
|
||
>
|
||
<Text size="sm" fw={500}>
|
||
{m.app_resolutions_pdf_preview({
|
||
defaultValue: 'PDF preview',
|
||
})}
|
||
</Text>
|
||
<ActionIcon
|
||
variant="subtle"
|
||
onClick={() => setEmbeddedPdf(null)}
|
||
aria-label={m.app_common_close({ defaultValue: 'Close' })}
|
||
>
|
||
×
|
||
</ActionIcon>
|
||
</Group>
|
||
<Box style={{ height: '80vh' }}>
|
||
<iframe
|
||
title="Resolution PDF"
|
||
src={embeddedPdf}
|
||
style={{ width: '100%', height: '100%', border: 0 }}
|
||
/>
|
||
</Box>
|
||
</Card>
|
||
)}
|
||
</>
|
||
)}
|
||
</Stack>
|
||
</Container>
|
||
)
|
||
}
|
||
|
||
export const Route = createFileRoute(
|
||
'/companies/$companyId/resolutions/$resolutionId',
|
||
)({
|
||
component: () => <RequireAuth><ResolutionPage /></RequireAuth>,
|
||
})
|