Files
germantax-e2e-mrfz66zi/apps/app/src/pages/companies/$companyId/resolutions/$resolutionId.tsx
2026-07-11 08:20:01 +02:00

339 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>,
})