900 lines
26 KiB
TypeScript
900 lines
26 KiB
TypeScript
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>
|
|
)
|
|
}
|