chore: germantax customer project

This commit is contained in:
e2e
2026-07-11 08:20:01 +02:00
commit f22ff25023
145 changed files with 9830 additions and 0 deletions

View File

@@ -0,0 +1,338 @@
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>,
})

View File

@@ -0,0 +1,141 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { RequireAuth } from '../../../../auth'
import {
Alert,
Button,
Card,
Container,
NumberInput,
Select,
Stack,
Text,
Textarea,
TextInput,
Title,
} from '@pikku/mantine/core'
import { useState, useMemo } from 'react'
import { m } from '@/i18n/messages'
import { useLocale, getLocale } from '@/i18n/config'
import { asI18n } from '@pikku/react'
import { usePikkuQuery, usePikkuMutation } from '@germantax/functions-sdk/pikku/api.gen'
function NewResolutionPage() {
const { companyId } = Route.useParams()
useLocale()
const navigate = useNavigate()
const templates = usePikkuQuery('listResolutionTemplates', undefined as never)
const create = usePikkuMutation('createResolution', {
onSuccess: ({ resolutionId }) => {
navigate({
to: '/companies/$companyId/resolutions/$resolutionId',
params: { companyId, resolutionId },
})
},
})
const [templateKey, setTemplateKey] = useState<string | null>(null)
const [title, setTitle] = useState('')
const [values, setValues] = useState<Record<string, unknown>>({})
const template = useMemo(
() => templates.data?.find((t) => t.key === templateKey),
[templates.data, templateKey],
)
const lang = getLocale()?.startsWith('de') ? 'de' : 'en'
const labelOf = (f: { labelDe: string; labelEn: string }) => (lang === 'de' ? f.labelDe : f.labelEn)
return (
<Container size="md" py="xl">
<Stack gap="xl">
<Title order={2}>{m.app_resolutions_new()}</Title>
<Card withBorder radius="md" p="md">
<Stack gap="sm">
<Select
label={m.app_resolutions_select_template()}
data={
templates.data?.map((tpl) => ({ value: tpl.key, label: tpl.titleDe })) ?? []
}
value={templateKey}
onChange={(v) => {
setTemplateKey(v)
setValues({})
const tpl = templates.data?.find((x) => x.key === v)
if (tpl) setTitle(tpl.titleDe)
}}
/>
<TextInput
label={m.app_resolutions_resolution_title()}
value={title}
onChange={(e) => setTitle(e.currentTarget.value)}
/>
{template && (
<Stack gap="xs">
<Text fw={600}>{m.app_resolutions_fields()}</Text>
{template.fields.map((f) => {
const v = values[f.key]
if (f.type === 'textarea') {
return (
<Textarea
key={f.key}
label={asI18n(labelOf(f))}
required={f.required}
value={(v as string) ?? ''}
onChange={(e) => setValues({ ...values, [f.key]: e.currentTarget.value })}
/>
)
}
if (f.type === 'number' || f.type === 'currency') {
return (
<NumberInput
key={f.key}
label={asI18n(labelOf(f))}
required={f.required}
value={typeof v === 'number' ? v : ''}
onChange={(val) => setValues({ ...values, [f.key]: typeof val === 'number' ? val : 0 })}
min={0}
decimalScale={f.type === 'currency' ? 2 : 0}
/>
)
}
return (
<TextInput
key={f.key}
type={f.type === 'date' ? 'date' : 'text'}
label={asI18n(labelOf(f))}
required={f.required}
value={(v as string) ?? ''}
onChange={(e) => setValues({ ...values, [f.key]: e.currentTarget.value })}
/>
)
})}
</Stack>
)}
{create.error && <Alert color="red">{asI18n(String(create.error.message))}</Alert>}
<Button
disabled={!templateKey || !title}
loading={create.isPending}
onClick={() =>
templateKey &&
create.mutate({
companyId,
templateKey,
title,
fieldValues: values,
})
}
>
{m.app_resolutions_create()}
</Button>
</Stack>
</Card>
</Stack>
</Container>
)
}
export const Route = createFileRoute('/companies/$companyId/resolutions/new')({
component: () => <RequireAuth><NewResolutionPage /></RequireAuth>,
})