chore: germantax customer project
This commit is contained in:
@@ -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>,
|
||||
})
|
||||
141
apps/app/src/pages/companies/$companyId/resolutions/new.tsx
Normal file
141
apps/app/src/pages/companies/$companyId/resolutions/new.tsx
Normal 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>,
|
||||
})
|
||||
Reference in New Issue
Block a user