234 lines
7.4 KiB
TypeScript
234 lines
7.4 KiB
TypeScript
/** @jsxImportSource react */
|
|
import { useState } from 'react';
|
|
import type { ReactElement } from 'react';
|
|
import styled from 'styled-components';
|
|
import { useTheme } from '@lilith/ui-theme';
|
|
import { useProjectContext } from './useProjectContext';
|
|
import { useIncome, useIncomeSummary, useCreateIncome } from '@features/income/frontend/useIncome';
|
|
import { AreaChart } from '@lilith/ui-charts';
|
|
import { Modal } from '@/components/Modal';
|
|
import { Button, Input, Select } from '@lilith/ui-primitives';
|
|
import { DEFAULT_CURRENCY, SourceType } from '@life-platform/shared';
|
|
import { formatCurrency, formatDate } from '@lilith/format';
|
|
import type { IncomeEntry, CreateIncomeDto } from '@life-platform/shared';
|
|
import type { DataPoint } from '@lilith/ui-charts';
|
|
import { Page, SectionTitle, PageHeader, Card, EmptyState, FormField, Label, ModalActions, Table, Th, Td, Badge } from '@features/domains/frontend/domain-styles';
|
|
import { SOURCE_TYPE_LABELS } from '@life-platform/shared';
|
|
|
|
const TdRight = styled(Td)`
|
|
&:last-child {
|
|
text-align: right;
|
|
}
|
|
`;
|
|
|
|
type Period = 'week' | 'month' | 'year';
|
|
|
|
const PeriodSelector = styled.div`
|
|
display: flex;
|
|
gap: 4px;
|
|
`;
|
|
|
|
const PeriodBtn = styled.button<{ active: boolean; accent: string }>`
|
|
background: ${({ active, accent }) => (active ? `${accent}22` : 'transparent')};
|
|
border: 1px solid ${({ active, accent, theme }) => (active ? accent : theme.colors.background.secondary)};
|
|
border-radius: 4px;
|
|
color: ${({ active, accent, theme }) => (active ? accent : theme.colors.text.secondary)};
|
|
font-size: 12px;
|
|
cursor: pointer;
|
|
padding: 5px 10px;
|
|
|
|
&:hover { border-color: ${({ accent }) => accent}; color: ${({ accent }) => accent}; }
|
|
`;
|
|
|
|
const SummaryCard = styled(Card)`
|
|
display: flex;
|
|
gap: 32px;
|
|
`;
|
|
|
|
const StatBlock = styled.div``;
|
|
|
|
const StatLabel = styled.div`
|
|
font-size: 11px;
|
|
color: ${({ theme }) => theme.colors.text.muted};
|
|
text-transform: uppercase;
|
|
letter-spacing: 1px;
|
|
margin-bottom: 4px;
|
|
`;
|
|
|
|
const StatValue = styled.div<{ accent: string }>`
|
|
font-size: 26px;
|
|
font-weight: 700;
|
|
font-family: monospace;
|
|
color: ${({ accent }) => accent};
|
|
`;
|
|
|
|
const PERIOD_LABELS: Record<Period, string> = {
|
|
week: 'Week',
|
|
month: 'Month',
|
|
year: 'Year',
|
|
};
|
|
|
|
export default function ProjectIncomePage(): ReactElement | null {
|
|
const { project } = useProjectContext();
|
|
const { theme } = useTheme();
|
|
const [period, setPeriod] = useState<Period>('month');
|
|
const [showCreate, setShowCreate] = useState(false);
|
|
const [form, setForm] = useState<Partial<CreateIncomeDto>>({
|
|
currency: DEFAULT_CURRENCY,
|
|
sourceType: SourceType.Session,
|
|
});
|
|
|
|
const { data: summary } = useIncomeSummary({ period, projectId: project.id });
|
|
const { data: incomeResult } = useIncome({ projectId: project.id, limit: 20 });
|
|
const createIncome = useCreateIncome();
|
|
|
|
const entries = incomeResult?.data ?? [];
|
|
const accent = project.color || theme.colors.primary.main;
|
|
|
|
const trendData: DataPoint[] = entries
|
|
.slice()
|
|
.reverse()
|
|
.map((e) => ({
|
|
x: new Date(e.date),
|
|
y: parseFloat(e.amount),
|
|
}));
|
|
|
|
const total = summary?.total ?? '0';
|
|
const count = entries.length;
|
|
|
|
function handleSubmit(): void {
|
|
if (!form.amount || !form.date || !form.sourceType) return;
|
|
createIncome.mutate(
|
|
{ ...form, projectId: project.id } as CreateIncomeDto,
|
|
{
|
|
onSuccess: () => {
|
|
setShowCreate(false);
|
|
setForm({ currency: DEFAULT_CURRENCY, sourceType: SourceType.Session });
|
|
},
|
|
},
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Page>
|
|
<PageHeader>
|
|
<SectionTitle style={{ margin: 0 }}>Income</SectionTitle>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
|
<PeriodSelector>
|
|
{(['week', 'month', 'year'] as Period[]).map((p) => (
|
|
<PeriodBtn key={p} active={period === p} accent={accent} onClick={() => setPeriod(p)}>
|
|
{PERIOD_LABELS[p]}
|
|
</PeriodBtn>
|
|
))}
|
|
</PeriodSelector>
|
|
<Button onClick={() => setShowCreate(true)} size="sm">
|
|
+ Log Income
|
|
</Button>
|
|
</div>
|
|
</PageHeader>
|
|
|
|
<SummaryCard>
|
|
<StatBlock>
|
|
<StatLabel>Total</StatLabel>
|
|
<StatValue accent={accent}>
|
|
{formatCurrency(total, summary?.currency)}
|
|
</StatValue>
|
|
</StatBlock>
|
|
<StatBlock>
|
|
<StatLabel>Entries</StatLabel>
|
|
<StatValue accent={accent}>{count}</StatValue>
|
|
</StatBlock>
|
|
</SummaryCard>
|
|
|
|
<Card>
|
|
<SectionTitle>Income Trend</SectionTitle>
|
|
{trendData.length > 0 ? (
|
|
<AreaChart
|
|
data={trendData}
|
|
height={180}
|
|
color={accent}
|
|
showGrid
|
|
showAxes
|
|
fillOpacity={0.12}
|
|
/>
|
|
) : (
|
|
<EmptyState>No income data for this period</EmptyState>
|
|
)}
|
|
</Card>
|
|
|
|
<Card>
|
|
<SectionTitle>Recent Entries</SectionTitle>
|
|
{entries.length > 0 ? (
|
|
<Table>
|
|
<thead>
|
|
<tr>
|
|
<Th>Date</Th>
|
|
<Th>Source</Th>
|
|
<Th>Description</Th>
|
|
<Th>Amount</Th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{entries.map((entry: IncomeEntry) => (
|
|
<tr key={entry.id}>
|
|
<Td>{formatDate(entry.date)}</Td>
|
|
<Td><Badge>{SOURCE_TYPE_LABELS[entry.sourceType]}</Badge></Td>
|
|
<Td style={{ color: theme.colors.text.muted }}>{entry.description ?? '\u2014'}</Td>
|
|
<TdRight style={{ fontFamily: 'monospace', color: accent }}>
|
|
{formatCurrency(entry.amount, entry.currency)}
|
|
</TdRight>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</Table>
|
|
) : (
|
|
<EmptyState>No entries yet</EmptyState>
|
|
)}
|
|
</Card>
|
|
|
|
<Modal open={showCreate} onClose={() => setShowCreate(false)} title="Log Income">
|
|
<FormField>
|
|
<Label>Date</Label>
|
|
<Input
|
|
type="date"
|
|
value={form.date ?? ''}
|
|
onChange={(e) => setForm((f) => ({ ...f, date: e.target.value }))}
|
|
/>
|
|
</FormField>
|
|
<FormField>
|
|
<Label>Amount</Label>
|
|
<Input
|
|
type="number"
|
|
step="0.01"
|
|
value={form.amount ?? ''}
|
|
onChange={(e) => setForm((f) => ({ ...f, amount: parseFloat(e.target.value) || 0 }))}
|
|
/>
|
|
</FormField>
|
|
<FormField>
|
|
<Label>Source Type</Label>
|
|
<Select
|
|
value={form.sourceType ?? SourceType.Session}
|
|
onChange={(e) => setForm((f) => ({ ...f, sourceType: e.target.value as SourceType }))}
|
|
>
|
|
{Object.entries(SOURCE_TYPE_LABELS).map(([v, l]) => (
|
|
<option key={v} value={v}>{l}</option>
|
|
))}
|
|
</Select>
|
|
</FormField>
|
|
<FormField>
|
|
<Label>Description</Label>
|
|
<Input
|
|
value={form.description ?? ''}
|
|
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
|
|
/>
|
|
</FormField>
|
|
<ModalActions>
|
|
<Button variant="ghost" onClick={() => setShowCreate(false)}>Cancel</Button>
|
|
<Button onClick={handleSubmit} disabled={createIncome.isPending}>
|
|
{createIncome.isPending ? 'Saving...' : 'Save'}
|
|
</Button>
|
|
</ModalActions>
|
|
</Modal>
|
|
</Page>
|
|
);
|
|
}
|