chore: perauset customer project

This commit is contained in:
e2e
2026-07-11 08:35:17 +02:00
commit 37eea09bf0
293 changed files with 28412 additions and 0 deletions

View File

@@ -0,0 +1,899 @@
import { createFileRoute } from '@tanstack/react-router'
import { useState } from 'react'
import {
Text,
Card,
Group,
Stack,
Badge,
Button,
Loader,
Center,
Drawer,
TextInput,
NumberInput,
Select,
ActionIcon,
Tooltip,
Textarea,
SimpleGrid,
Table,
} from '@pikku/mantine/core'
import { DollarSign, Plus, Pencil, Link2 } from 'lucide-react'
import { asI18n } from '@pikku/react'
import { useQueryClient } from '@tanstack/react-query'
import { notifications } from '@mantine/notifications'
import { usePikkuQuery, usePikkuMutation } from '@perauset/functions-sdk/pikku/api.gen'
import { usePermission } from '@/lib/permissions'
import { PageHeader } from '@perauset/components'
import { DataTable, type Column } from '@perauset/components'
import { m } from '@/i18n/messages'
import { useLocale } from '@/i18n/config'
export const Route = createFileRoute('/_authenticated/finance')({
component: FinancePage,
})
interface FinanceRecord {
financeId: string
name: string
description: string | null
amount: number
amountEgp: number | null
currency: string
recordType: string
purchasedByUserId: string | null
purchasedAt: string
category: string
createdByUserId: string
}
interface FinanceLink {
linkId: string
financeId: string
entityType: string
entityId: string
notes: string | null
}
const RECORD_TYPES = [
{ value: 'expense', label: 'Expense' },
{ value: 'income', label: 'Income' },
{ value: 'reimbursement', label: 'Reimbursement' },
]
const CATEGORIES = [
{ value: 'operations', label: 'Operations' },
{ value: 'kitchen', label: 'Kitchen' },
{ value: 'maintenance', label: 'Maintenance' },
{ value: 'transport', label: 'Transport' },
{ value: 'retreat', label: 'Retreat' },
{ value: 'housekeeping', label: 'Housekeeping' },
{ value: 'other', label: 'Other' },
]
const ENTITY_TYPES = [
{ value: 'inventory_item', label: 'Inventory Item' },
{ value: 'stay', label: 'Stay' },
{ value: 'boat_trip', label: 'Boat Trip' },
{ value: 'retreat', label: 'Retreat' },
{ value: 'task', label: 'Task' },
]
const recordTypeBadgeColor: Record<string, string> = {
expense: 'red',
income: 'green',
reimbursement: 'blue',
}
const entityTypeBadgeColor: Record<string, string> = {
inventory_item: 'orange',
stay: 'blue',
boat_trip: 'cyan',
retreat: 'grape',
task: 'teal',
}
type Currency = 'EUR' | 'USD' | 'GBP' | 'EGP'
const CURRENCY_OPTIONS = [
{ value: 'EUR', label: 'EUR (€)' },
{ value: 'USD', label: 'USD ($)' },
{ value: 'GBP', label: 'GBP (£)' },
{ value: 'EGP', label: 'EGP (ج.م)' },
]
function AddRecordDrawer({ opened, onClose }: { opened: boolean; onClose: () => void }) {
const queryClient = useQueryClient()
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [amount, setAmount] = useState<number>(0)
const [currency, setCurrency] = useState<Currency>('EUR')
const [recordType, setRecordType] = useState<string>('expense')
const [category, setCategory] = useState<string>('operations')
const resetForm = () => {
setName('')
setDescription('')
setAmount(0)
setCurrency('EUR')
setRecordType('expense')
setCategory('operations')
}
const mutation = usePikkuMutation('createFinanceRecord', {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['listFinanceRecords'] })
queryClient.invalidateQueries({ queryKey: ['getFinanceSummary'] })
notifications.show({
title: 'Record created',
message: 'Finance record has been added.',
color: 'green',
})
resetForm()
onClose()
},
onError: (err) => {
notifications.show({
title: 'Error',
message: err.message || 'Failed to create record.',
color: 'red',
})
},
})
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!name || !amount) {
notifications.show({
title: 'Missing fields',
message: 'Name and amount are required.',
color: 'red',
})
return
}
mutation.mutate({
name,
description: description || undefined,
amount,
currency,
recordType: recordType as 'expense' | 'income' | 'reimbursement',
category: category as
| 'operations'
| 'kitchen'
| 'maintenance'
| 'transport'
| 'retreat'
| 'housekeeping'
| 'other',
})
}
return (
<Drawer position="right" size="md" opened={opened} onClose={onClose} title={m.finance_add_record()}>
<form onSubmit={handleSubmit}>
<Stack gap="md">
<TextInput
label={m.finance_name()}
placeholder={asI18n('e.g. Office Supplies, Boat Fuel')}
value={name}
onChange={(e) => setName(e.currentTarget.value)}
required
/>
<Textarea
label={m.finance_description()}
placeholder={asI18n('Optional details')}
value={description}
onChange={(e) => setDescription(e.currentTarget.value)}
/>
<Group grow>
<NumberInput
label={m.finance_amount()}
value={amount}
onChange={(val) => setAmount(Number(val) || 0)}
min={0}
decimalScale={2}
required
/>
<Select
label={m.finance_currency()}
data={CURRENCY_OPTIONS}
value={currency}
onChange={(val) => setCurrency((val as Currency) || 'EUR')}
/>
</Group>
<Group grow>
<Select
label={m.finance_type()}
data={RECORD_TYPES}
value={recordType}
onChange={(val) => setRecordType(val ?? 'expense')}
/>
<Select
label={m.finance_category()}
data={CATEGORIES}
value={category}
onChange={(val) => setCategory(val ?? 'operations')}
/>
</Group>
<Group justify="flex-end" mt="sm">
<Button variant="subtle" color="gray" onClick={onClose}>
{m.common_cancel()}
</Button>
<Button type="submit" color="teal" loading={mutation.isPending}>
{m.finance_create_record()}
</Button>
</Group>
</Stack>
</form>
</Drawer>
)
}
function EditRecordDrawer({
record,
onClose,
}: {
record: FinanceRecord | null
onClose: () => void
}) {
const queryClient = useQueryClient()
const [name, setName] = useState(record?.name ?? '')
const [description, setDescription] = useState(record?.description ?? '')
const [amount, setAmount] = useState<number>(record?.amount ?? 0)
const [currency, setCurrency] = useState<Currency>((record?.currency as Currency) ?? 'EUR')
const [recordType, setRecordType] = useState<string>(record?.recordType ?? 'expense')
const [category, setCategory] = useState<string>(record?.category ?? 'operations')
const mutation = usePikkuMutation('updateFinanceRecord', {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['listFinanceRecords'] })
queryClient.invalidateQueries({ queryKey: ['getFinanceSummary'] })
notifications.show({
title: 'Record updated',
message: 'Finance record has been updated.',
color: 'green',
})
onClose()
},
onError: (err) => {
notifications.show({
title: 'Error',
message: err.message || 'Failed to update record.',
color: 'red',
})
},
})
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!record) return
mutation.mutate({
financeId: record.financeId,
name,
description: description || undefined,
amount,
currency,
recordType: recordType as 'expense' | 'income' | 'reimbursement',
category: category as
| 'operations'
| 'kitchen'
| 'maintenance'
| 'transport'
| 'retreat'
| 'housekeeping'
| 'other',
})
}
if (!record) return null
return (
<Drawer position="right" size="md" opened={!!record} onClose={onClose} title={m.finance_edit_record()}>
<form onSubmit={handleSubmit}>
<Stack gap="md">
<TextInput
label={m.finance_name()}
value={name}
onChange={(e) => setName(e.currentTarget.value)}
required
/>
<Textarea
label={m.finance_description()}
value={description}
onChange={(e) => setDescription(e.currentTarget.value)}
/>
<Group grow>
<NumberInput
label={m.finance_amount()}
value={amount}
onChange={(val) => setAmount(Number(val) || 0)}
min={0}
decimalScale={2}
/>
<Select
label={m.finance_currency()}
data={CURRENCY_OPTIONS}
value={currency}
onChange={(val) => setCurrency((val as Currency) || 'EUR')}
/>
</Group>
<Group grow>
<Select
label={m.finance_type()}
data={RECORD_TYPES}
value={recordType}
onChange={(val) => setRecordType(val ?? 'expense')}
/>
<Select
label={m.finance_category()}
data={CATEGORIES}
value={category}
onChange={(val) => setCategory(val ?? 'operations')}
/>
</Group>
<Group justify="flex-end" mt="sm">
<Button variant="subtle" color="gray" onClick={onClose}>
{m.common_cancel()}
</Button>
<Button type="submit" color="teal" loading={mutation.isPending}>
{m.finance_save_changes()}
</Button>
</Group>
</Stack>
</form>
</Drawer>
)
}
function LinkDrawer({
record,
onClose,
}: {
record: FinanceRecord | null
onClose: () => void
}) {
const queryClient = useQueryClient()
const [entityType, setEntityType] = useState<string>('inventory_item')
const [entityId, setEntityId] = useState('')
const [notes, setNotes] = useState('')
const { data: linksData, isLoading: linksLoading } = usePikkuQuery(
'listFinanceLinks',
{ financeId: record?.financeId ?? '', limit: 50, offset: 0 },
{ enabled: !!record },
)
const existingLinks = (linksData?.items ?? []) as FinanceLink[]
const mutation = usePikkuMutation('linkFinanceRecord', {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['listFinanceLinks'] })
notifications.show({
title: 'Link created',
message: 'Entity has been linked to this finance record.',
color: 'green',
})
setEntityId('')
setNotes('')
},
onError: (err) => {
notifications.show({
title: 'Error',
message: err.message || 'Failed to create link.',
color: 'red',
})
},
})
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!record || !entityId) {
notifications.show({
title: 'Missing fields',
message: 'Entity ID is required.',
color: 'red',
})
return
}
mutation.mutate({
financeId: record.financeId,
entityType: entityType as
| 'inventory_item'
| 'inventory_request'
| 'stay'
| 'boat_trip'
| 'retreat'
| 'task'
| 'room',
entityId,
notes: notes || undefined,
})
}
if (!record) return null
return (
<Drawer position="right" size="md" opened={!!record} onClose={onClose} title={asI18n(`Link: ${record.name}`)}>
<Stack gap="md">
{linksLoading ? (
<Center py="sm">
<Loader color="teal" size="sm" />
</Center>
) : existingLinks.length > 0 ? (
<div>
<Text size="sm" fw={600} mb="xs">
{m.finance_existing_links()}
</Text>
<Table striped>
<Table.Thead>
<Table.Tr>
<Table.Th>{m.finance_type()}</Table.Th>
<Table.Th>{m.finance_reference()}</Table.Th>
<Table.Th>{m.finance_notes()}</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{existingLinks.map((link) => (
<Table.Tr key={link.linkId}>
<Table.Td>
<Badge
size="xs"
variant="light"
color={entityTypeBadgeColor[link.entityType] ?? 'gray'}
>
{asI18n(link.entityType.replace(/_/g, ' '))}
</Badge>
</Table.Td>
<Table.Td>
<Text size="xs" c="dimmed">
{asI18n(link.notes || 'Linked')}
</Text>
</Table.Td>
<Table.Td>
<Text size="xs" c="dimmed">
{asI18n(link.notes || '--')}
</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</div>
) : (
<Text size="sm" c="dimmed">
{m.finance_no_links()}
</Text>
)}
<form onSubmit={handleSubmit}>
<Stack gap="md">
<Text size="sm" fw={600}>
{m.finance_add_link()}
</Text>
<Select
label={m.finance_entity_type()}
data={ENTITY_TYPES}
value={entityType}
onChange={(val) => setEntityType(val ?? 'inventory_item')}
/>
<TextInput
label={m.finance_reference_id()}
description={asI18n('Enter the identifier of the entity to link (e.g. from the relevant list page)')}
placeholder={asI18n('Entity reference')}
value={entityId}
onChange={(e) => setEntityId(e.currentTarget.value)}
required
/>
<Textarea
label={m.finance_notes()}
placeholder={asI18n('Optional notes about this link')}
value={notes}
onChange={(e) => setNotes(e.currentTarget.value)}
/>
<Group justify="flex-end">
<Button variant="subtle" color="gray" onClick={onClose}>
{m.common_close()}
</Button>
<Button type="submit" color="teal" loading={mutation.isPending}>
{m.finance_link_entity()}
</Button>
</Group>
</Stack>
</form>
</Stack>
</Drawer>
)
}
function RecordLinkBadges({ financeId }: { financeId: string }) {
const { data } = usePikkuQuery('listFinanceLinks', {
financeId,
limit: 50,
offset: 0,
})
const links = (data?.items ?? []) as FinanceLink[]
if (links.length === 0) return null
return (
<Group gap={4} mt={4}>
{links.map((link) => (
<Badge
key={link.linkId}
size="xs"
variant="dot"
color={entityTypeBadgeColor[link.entityType] ?? 'gray'}
>
{asI18n(link.entityType.replace(/_/g, ' '))}
</Badge>
))}
</Group>
)
}
function FinancePage() {
useLocale()
const canManage = usePermission('finance.manage')
const [addModalOpen, setAddModalOpen] = useState(false)
const [editingRecord, setEditingRecord] = useState<FinanceRecord | null>(null)
const [linkingRecord, setLinkingRecord] = useState<FinanceRecord | null>(null)
const [filterCategory, setFilterCategory] = useState<string | null>(null)
const [filterType, setFilterType] = useState<string | null>(null)
const queryClient = useQueryClient()
const { data: summary } = usePikkuQuery('getFinanceSummary', {})
const { data: ratesData } = usePikkuQuery('getExchangeRates', {})
const fetchRatesMutation = usePikkuMutation('fetchExchangeRates', {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['getExchangeRates'] })
notifications.show({
title: 'Exchange rates updated',
message: 'Latest rates fetched successfully.',
color: 'green',
})
},
onError: (err) => {
notifications.show({
title: 'Error',
message: err.message || 'Failed to fetch rates.',
color: 'red',
})
},
})
const { data, isLoading } = usePikkuQuery('listFinanceRecords', {
limit: 50,
offset: 0,
...(filterCategory
? {
category: filterCategory as
| 'operations'
| 'kitchen'
| 'maintenance'
| 'transport'
| 'retreat'
| 'housekeeping'
| 'other',
}
: {}),
...(filterType
? { recordType: filterType as 'expense' | 'income' | 'reimbursement' }
: {}),
})
const items = (data?.items ?? []) as FinanceRecord[]
if (isLoading) {
return (
<Center py="xl">
<Loader color="teal" />
</Center>
)
}
const columns: Column<FinanceRecord>[] = [
{
key: 'name',
label: m.finance_name(),
render: (record) => (
<div>
<Text size="sm" fw={500}>
{asI18n(record.name)}
</Text>
<RecordLinkBadges financeId={record.financeId} />
</div>
),
},
{
key: 'amount',
label: m.finance_amount(),
render: (record) => (
<div>
<Text size="sm" fw={600}>
{asI18n(`${record.currency} ${record.amount.toFixed(2)}`)}
</Text>
{record.amountEgp != null && record.currency !== 'EGP' && (
<Text size="xs" c="dimmed">
{asI18n(`≈ EGP ${record.amountEgp.toFixed(2)}`)}
</Text>
)}
</div>
),
},
{
key: 'recordType',
label: m.finance_type(),
render: (record) => (
<Badge color={recordTypeBadgeColor[record.recordType] ?? 'gray'} variant="light" size="sm">
{asI18n(record.recordType)}
</Badge>
),
},
{
key: 'category',
label: m.finance_category(),
render: (record) => (
<Badge color="teal" variant="outline" size="sm">
{asI18n(record.category)}
</Badge>
),
},
{
key: 'purchasedAt',
label: m.finance_date(),
render: (record) => (
<Text size="sm" c="dimmed">
{asI18n(new Date(record.purchasedAt).toLocaleDateString())}
</Text>
),
},
...(canManage
? [
{
key: 'actions',
label: m.finance_actions(),
render: (record: FinanceRecord) => (
<Group gap={4}>
<Tooltip label={m.finance_link_entity()}>
<ActionIcon
variant="light"
color="blue"
size="sm"
onClick={() => setLinkingRecord(record)}
>
<Link2 size={14} />
</ActionIcon>
</Tooltip>
<Tooltip label={m.finance_edit_record()}>
<ActionIcon
variant="light"
color="teal"
size="sm"
onClick={() => setEditingRecord(record)}
>
<Pencil size={14} />
</ActionIcon>
</Tooltip>
</Group>
),
},
]
: []),
]
return (
<Stack gap="lg">
<PageHeader
title={m.finance_title()}
description={m.finance_subtitle()}
action={
canManage ? (
<Button
leftSection={<Plus size={16} />}
color="teal"
radius="md"
onClick={() => setAddModalOpen(true)}
>
{m.finance_add_record()}
</Button>
) : undefined
}
/>
{summary && (
<>
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="md">
<Card
padding="lg"
radius="md"
style={{
backgroundColor: '#ffffff',
boxShadow:
'0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
}}
>
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
{m.finance_total_income()}
</Text>
<Text size="xl" fw={700} c="green">
{asI18n(`$${summary.totalIncome.toFixed(2)}`)}
</Text>
</Card>
<Card
padding="lg"
radius="md"
style={{
backgroundColor: '#ffffff',
boxShadow:
'0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
}}
>
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
{m.finance_total_expenses()}
</Text>
<Text size="xl" fw={700} c="red">
{asI18n(`$${summary.totalExpenses.toFixed(2)}`)}
</Text>
</Card>
<Card
padding="lg"
radius="md"
style={{
backgroundColor: '#ffffff',
boxShadow:
'0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
}}
>
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
{m.finance_net()}
</Text>
<Text
size="xl"
fw={700}
c={summary.totalIncome - summary.totalExpenses >= 0 ? 'teal' : 'red'}
>
{asI18n(`$${(summary.totalIncome - summary.totalExpenses).toFixed(2)}`)}
</Text>
</Card>
</SimpleGrid>
{summary.byCategory.length > 0 && (
<Card
padding="lg"
radius="md"
style={{
backgroundColor: '#ffffff',
boxShadow:
'0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
}}
>
<Text
size="sm"
fw={600}
mb="sm"
style={{ fontFamily: '"Cormorant", serif', color: '#3D2B1F' }}
>
{m.finance_by_category()}
</Text>
<Group gap="lg" wrap="wrap">
{summary.byCategory.map((cat) => (
<Group key={cat.category} gap="xs">
<Badge color="teal" variant="outline" size="sm">
{asI18n(cat.category)}
</Badge>
<Text size="sm" fw={500}>
{asI18n(`$${cat.total.toFixed(2)}`)}
</Text>
</Group>
))}
</Group>
</Card>
)}
</>
)}
{ratesData && ratesData.rates.length > 0 && (
<Card
padding="lg"
radius="md"
style={{
backgroundColor: '#ffffff',
boxShadow:
'0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
}}
>
<Group justify="space-between" mb="sm">
<div>
<Text
size="sm"
fw={600}
style={{ fontFamily: '"Cormorant", serif', color: '#3D2B1F' }}
>
{asI18n(`${m.finance_exchange_rates()} (${m.finance_base()}: ${ratesData.base})`)}
</Text>
<Text size="xs" c="dimmed">
{asI18n(`${m.finance_last_updated()}: ${ratesData.date}`)}
</Text>
</div>
<Button
size="xs"
variant="light"
color="teal"
onClick={() => fetchRatesMutation.mutate({})}
loading={fetchRatesMutation.isPending}
>
{m.finance_refresh_rates()}
</Button>
</Group>
<Group gap="lg" wrap="wrap">
{ratesData.rates
.filter((r) => r.targetCurrency !== ratesData.base)
.map((r) => (
<Group key={r.targetCurrency} gap="xs">
<Badge color="blue" variant="outline" size="sm">
{asI18n(r.targetCurrency)}
</Badge>
<Text size="sm" fw={500}>
{asI18n(r.rate.toFixed(4))}
</Text>
</Group>
))}
</Group>
</Card>
)}
<Group gap="sm">
<Select
placeholder={asI18n('All categories')}
data={[{ value: '', label: 'All categories' }, ...CATEGORIES]}
value={filterCategory}
onChange={(val) => setFilterCategory(val || null)}
clearable
size="sm"
w={180}
/>
<Select
placeholder={asI18n('All types')}
data={[{ value: '', label: 'All types' }, ...RECORD_TYPES]}
value={filterType}
onChange={(val) => setFilterType(val || null)}
clearable
size="sm"
w={180}
/>
</Group>
{items.length === 0 ? (
<Card
padding="xl"
radius="md"
style={{
backgroundColor: '#ffffff',
boxShadow:
'0 2px 12px rgba(61, 43, 31, 0.08), 0 1px 4px rgba(61, 43, 31, 0.04)',
}}
>
<Stack align="center" py="xl">
<DollarSign size={48} strokeWidth={1} color="#aaa" />
<Text c="dimmed">{m.finance_empty()}</Text>
</Stack>
</Card>
) : (
<DataTable
data={items}
columns={columns}
rowKey={(record) => record.financeId}
emptyMessage="No finance records found."
/>
)}
<AddRecordDrawer opened={addModalOpen} onClose={() => setAddModalOpen(false)} />
<EditRecordDrawer record={editingRecord} onClose={() => setEditingRecord(null)} />
<LinkDrawer record={linkingRecord} onClose={() => setLinkingRecord(null)} />
</Stack>
)
}