life-manager/codebase/features/projects/frontend/ProjectIncomePage.tsx
2026-03-17 17:51:06 -07:00

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>
);
}