chore: perauset customer project
This commit is contained in:
899
apps/app/src/pages/_authenticated.finance.tsx
Normal file
899
apps/app/src/pages/_authenticated.finance.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user