diff --git a/src/app/(tabs)/analytics.tsx b/src/app/(tabs)/analytics.tsx index 700ba96..da72eb2 100644 --- a/src/app/(tabs)/analytics.tsx +++ b/src/app/(tabs)/analytics.tsx @@ -1,12 +1,734 @@ +/** + * Analytics tab (ADR-0012) — read-only insight surface over the SAME derived layer the + * Finance screen reads (domain/aggregates over the live transactions/lessons ledger). + * + * Three sub-tabs share one period frame: + * • Обзор — income month-bars, KPI row, subject-share donut, top directions, Δ vs prev + * • Динамика — weekly income bars + Δ vs prev + * • Задолженности — debtors list (cross-ledger, drills into the student card) + * + * Every number is DERIVED here in render via the pure aggregates (never stored). Period is + * local useState; the shared PeriodSheet switches its type/range. CSV export ships now (web); + * PDF/Excel are deferred («скоро»). Strings via i18n, colours via theme — no inline UI copy / + * hex. Web-first (the app is wrapped in a centred ~430px column). + */ +import { useRouter } from 'expo-router'; +import { useMemo, useState } from 'react'; +import { Pressable, StyleSheet, Text, View } from 'react-native'; + import { EmptyState } from '@/components/EmptyState'; +import { PeriodSheet } from '@/components/PeriodSheet'; import { Screen } from '@/components/Screen'; -import { useT } from '@/i18n'; +import { useAllLessons, useAllTransactions, useStudents, useSubjects } from '@/db/hooks'; +import { + avgCheckInPeriod, + cancellationsInPeriod, + debtors, + entriesInPeriod, + financeEntries, + incomeInPeriod, + lessonsConductedInPeriod, + metricDelta, + paidByBucket, + subjectTotals, + topDirections, +} from '@/domain/aggregates'; +import { plural, useT, type StringKey } from '@/i18n'; +import { downloadCsv, toCsv } from '@/lib/csv'; +import { formatNumberRu, formatRub } from '@/lib/format'; +import { + currentMonth, + monthOf, + monthStarts, + shiftPeriod, + weekOf, + weekStarts, + type Period, +} from '@/lib/period'; +import { nowMs } from '@/lib/time'; +import { chartColors, useTheme } from '@/theme'; +import { + Card, + CountUp, + Donut, + Icon, + KpiStat, + MultiBarChart, + SectionLabel, + Segmented, + Sheet, + type BarDatum, + type DonutSegment, +} from '@/ui'; + +/** Which sub-tab is active (we keep the enum; labels come from i18n at render). */ +type Tab = 'overview' | 'dynamics' | 'debts'; + +/** CSV sections the user can toggle on/off before exporting (Phase-2 «Разделы»). */ +interface ExportSections { + income: boolean; + lessons: boolean; + debts: boolean; +} export default function AnalyticsScreen() { const t = useT(); + const { colors, radius } = useTheme(); + const router = useRouter(); + + // ── Local view state (period is not persisted; ADR-0012) ── + const [tab, setTab] = useState('overview'); + const [period, setPeriod] = useState(() => currentMonth(nowMs())); + const [periodOpen, setPeriodOpen] = useState(false); + const [exportOpen, setExportOpen] = useState(false); + + // ── Reactive ledger + dictionaries ── + const lessons = useAllLessons(); + const txns = useAllTransactions(); + const students = useStudents(); + const subjects = useSubjects(); + + // Name lookups (id → display name) for donut / top / debtors / CSV rows. + const studentName = useMemo(() => { + const m = new Map(); + for (const s of students) m.set(s.id, s.name); + return (id: string) => m.get(id) ?? t('common.none'); + }, [students, t]); + + const subjectName = useMemo(() => { + const m = new Map(); + for (const s of subjects) m.set(s.id, s.name); + return (id: string | null) => (id != null ? (m.get(id) ?? t('common.none')) : t('common.none')); + }, [subjects, t]); + + // RU «8 июня» day-month from an instant — for week/custom period ranges (genitive months). + const dayMonth = useMemo( + () => (ms: number) => { + const d = new Date(ms); + return `${d.getDate()} ${t(`monthGen.${d.getMonth()}` as StringKey)}`; + }, + [t], + ); + + // Human label for the current period — month name+year / year / week-range / custom-range. + const periodLabel = useMemo(() => { + const start = new Date(period.start); + switch (period.type) { + case 'month': + return `${t(`month.${start.getMonth()}` as StringKey)} ${start.getFullYear()}`; + case 'year': + return String(start.getFullYear()); + case 'week': + case 'custom': + default: { + // `end` is exclusive (next-midnight) → step back one day for the inclusive last day. + const last = new Date(period.end - 1); + return `${dayMonth(period.start)} – ${dayMonth(last.getTime())} ${last.getFullYear()}`; + } + } + }, [period, t, dayMonth]); + + // Per-tab eyebrow (label before « · »). + const eyebrow = + tab === 'overview' + ? t('analytics.income') + : tab === 'dynamics' + ? t('analytics.lessons') + : t('analytics.debt'); + + // ── Big-metric inputs (all DERIVED) ── + const debtTotal = useMemo(() => debtors(txns).reduce((sum, d) => sum + d.amount, 0), [txns]); + const conductedCount = lessonsConductedInPeriod(lessons, period); + const incomeNow = incomeInPeriod(txns, period); + + // ── Coverage: empty when the period has NO paid txns AND NO lessons in it ── + const hasData = useMemo(() => { + const anyPaid = txns.some((x) => x.type === 'paid' && x.occurredAt >= period.start && x.occurredAt < period.end); + const anyLesson = lessons.some((l) => l.startsAt >= period.start && l.startsAt < period.end); + return anyPaid || anyLesson; + }, [txns, lessons, period]); + + const resetPeriod = () => setPeriod(currentMonth(nowMs())); + + // Tab labels (i18n) → used both for the Segmented control and to map a tap back to a Tab. + const overviewLabel = t('analytics.overview'); + const dynamicsLabel = t('analytics.dynamics'); + const debtsLabel = t('analytics.debts'); + const activeLabel = tab === 'overview' ? overviewLabel : tab === 'dynamics' ? dynamicsLabel : debtsLabel; + return ( - + + setTab(label === overviewLabel ? 'overview' : label === dynamicsLabel ? 'dynamics' : 'debts') + } + /> + + {/* Top row: tappable period (opens the shared PeriodSheet) + Export action. */} + + setPeriodOpen(true)} + accessibilityRole="button" + style={({ pressed }) => [styles.periodBtn, pressed && styles.pressed]}> + + {`${eyebrow} · ${periodLabel}`} + + + + setExportOpen(true)} + accessibilityRole="button" + accessibilityLabel={t('export.title')} + hitSlop={8} + style={({ pressed }) => [styles.exportBtn, { backgroundColor: colors.stoneLight }, pressed && styles.pressed]}> + + + + + {!hasData ? ( + + + [ + styles.resetBtn, + { backgroundColor: colors.primaryVlight, borderColor: colors.primaryLight, borderRadius: radius.field }, + pressed && styles.pressed, + ]}> + {t('common.reset')} + + + ) : ( + <> + {/* Big metric — animated; debt total is danger-coloured. */} + {tab === 'overview' ? ( + formatRub(v)} style={StyleSheet.flatten([styles.metric, { color: colors.heading }])} /> + ) : tab === 'dynamics' ? ( + + `${formatNumberRu(v)} ${plural(Math.round(v), { + one: t('unit.lessons.one'), + few: t('unit.lessons.few'), + many: t('unit.lessons.many'), + })}` + } + style={StyleSheet.flatten([styles.metric, { color: colors.heading }])} + /> + ) : ( + formatRub(v)} style={StyleSheet.flatten([styles.metric, { color: colors.danger }])} /> + )} + + {tab === 'overview' && ( + + )} + {tab === 'dynamics' && } + {tab === 'debts' && ( + router.push({ pathname: '/student/[id]', params: { id } })} + /> + )} + + )} + + setPeriodOpen(false)} + onApply={(p) => setPeriod(p)} + /> + + {exportOpen ? ( + setExportOpen(false)} + /> + ) : null} ); } + +// ── ОБЗОР ──────────────────────────────────────────────────────────────────── + +function OverviewBody({ + lessons, + txns, + period, + subjectName, +}: { + lessons: Parameters[0]; + txns: Parameters[1]; + period: Period; + subjectName: (id: string | null) => string; +}) { + const t = useT(); + const { colors } = useTheme(); + + // (a) Income month-bars: 6 month anchors ending at the period's month. + const monthBars = useMemo(() => { + const start = new Date(period.start); + const y = start.getFullYear(); + const m = start.getMonth(); + const from = monthOf(new Date(y, m - 5, 1).getTime()).start; // 6-month window (incl. current) + const months = monthStarts(from, period.start); + const vals = paidByBucket(txns, months, (ms) => monthOf(ms).start); + const max = Math.max(1, ...vals); // avoid /0; flat-zero bars render empty + return months.map((anchor, i) => ({ + label: t(`month.${new Date(anchor).getMonth()}` as StringKey).slice(0, 3), + v: vals[i] / max, + value: formatRub(vals[i]), + on: i === months.length - 1, + })); + }, [txns, period, t]); + + // (b) KPI row. + const conducted = lessonsConductedInPeriod(lessons, period); + const cancels = cancellationsInPeriod(lessons, period); + const avgCheck = avgCheckInPeriod(txns, period); + + // (c) Donut — income share per subject. + const totals = subjectTotals(txns, period); + const totalAmount = totals.reduce((s, x) => s + x.amount, 0); + const segments = useMemo( + () => + totals.map((x, i) => ({ + label: subjectName(x.subjectId), + pct: totalAmount > 0 ? Math.round((x.amount / totalAmount) * 100) : 0, + color: chartColors[i % 6], + })), + [totals, totalAmount, subjectName], + ); + + // (d) Top directions (ranked by income); thin bars relative to the max amount. + const directions = topDirections(lessons, txns, period); + const maxAmount = Math.max(1, ...directions.map((d) => d.amount)); + + // (e) Comparison vs the previous period of the same type. + const prev = shiftPeriod(period, -1); + const delta = metricDelta(incomeInPeriod(txns, period), incomeInPeriod(txns, prev)); + + return ( + + {/* (a) income month-bars */} + + + + + {/* (b) KPI row — KpiStat.value is typed string|number (frozen kit), so we pass the + pre-formatted RU number; avg check uses the same Hermes-safe formatter. */} + + + + + + + + + {/* (c) donut shares + legend */} + + {t('analytics.shares')} + + + {String(segments.length)} + {t('analytics.topicsShort')} + + } + /> + + {segments.map((s) => ( + + + + {s.label} + + {`${s.pct}%`} + + ))} + + + + + {/* (d) top directions */} + + {t('analytics.top')} + + {directions.map((d, i) => ( + + + + {subjectName(d.subjectId)} + + {formatRub(d.amount)} + + + + + + ))} + + + + {/* (e) comparison */} + + + ); +} + +// ── ДИНАМИКА ─────────────────────────────────────────────────────────────────── + +function DynamicsBody({ txns, period }: { txns: Parameters[0]; period: Period }) { + const t = useT(); + + // Weekly income bars across the period; keep the last ~6 weeks when there are many. + const weekBars = useMemo(() => { + let weeks = weekStarts(period.start, period.end); + if (weeks.length > 6) weeks = weeks.slice(weeks.length - 6); + const vals = paidByBucket(txns, weeks, (ms) => weekOf(ms).start); + const max = Math.max(1, ...vals); + return weeks.map((anchor, i) => ({ + label: String(new Date(anchor).getDate()), // start day-number — DATA, not UI copy + v: vals[i] / max, + value: formatRub(vals[i]), + on: i === weeks.length - 1, + })); + }, [txns, period]); + + // Comparison vs the previous period of the same type. + const prev = shiftPeriod(period, -1); + const delta = metricDelta(incomeInPeriod(txns, period), incomeInPeriod(txns, prev)); + + return ( + + + {t('analytics.byWeeks')} + + + + + + + ); +} + +// ── ЗАДОЛЖЕННОСТИ ────────────────────────────────────────────────────────────── + +function DebtsBody({ + txns, + studentName, + onOpen, +}: { + txns: Parameters[0]; + studentName: (id: string) => string; + onOpen: (studentId: string) => void; +}) { + const t = useT(); + const { colors } = useTheme(); + + const ds = debtors(txns); + const maxDebt = Math.max(1, ...ds.map((d) => d.amount)); + + if (ds.length === 0) { + return ( + + + {t('analytics.noDebts')} + {t('analytics.allPaid')} + + + ); + } + + return ( + + + {t('analytics.debtors')} + + {ds.map((d) => ( + onOpen(d.studentId)} + accessibilityRole="button" + style={({ pressed }) => [styles.barRow, pressed && styles.pressed]}> + + + {studentName(d.studentId)} + + + {formatRub(d.amount)} + + + + + + + + ))} + + + + ); +} + +// ── Comparison card (shared by Обзор + Динамика) ────────────────────────────── + +function ComparisonCard({ delta }: { delta: ReturnType }) { + const t = useT(); + const { colors } = useTheme(); + + // No baseline (previous period was 0) → nothing to compare against. + if (delta.pct === null) { + return ( + + {t('analytics.comparison')} + + + + {t('analytics.noCompare')} + + + + ); + } + + // Income up = good → accentSoft pill; down = danger pill. Sign the percentage explicitly. + const good = delta.dir === 'up' || delta.dir === 'flat'; + const sign = delta.pct > 0 ? '+' : ''; + return ( + + {t('analytics.comparison')} + + + + + {`${sign}${delta.pct}%`} + + + {t('analytics.vsPrev')} + + + + ); +} + +// ── Export sheet (CSV now; PDF/Excel deferred) ──────────────────────────────── + +function ExportSheet({ + lessons, + txns, + period, + studentName, + subjectName, + onClose, +}: { + lessons: Parameters[0]; + txns: Parameters[1]; + period: Period; + studentName: (id: string) => string; + subjectName: (id: string | null) => string; + onClose: () => void; +}) { + const t = useT(); + const { colors, radius } = useTheme(); + + // CSV is the only enabled format (ADR-0012); PDF/Excel show «скоро». + const formats: { key: 'csv' | 'pdf' | 'excel'; label: string; enabled: boolean }[] = [ + { key: 'csv', label: 'CSV', enabled: true }, + { key: 'pdf', label: 'PDF', enabled: false }, + { key: 'excel', label: 'Excel', enabled: false }, + ]; + + const [sections, setSections] = useState({ income: true, lessons: true, debts: true }); + const [done, setDone] = useState(false); + + const toggle = (k: keyof ExportSections) => setSections((s) => ({ ...s, [k]: !s[k] })); + + // Build the CSV from the in-period finance entries (paid + derived debt/expected rows). + const generate = () => { + const rows = entriesInPeriod(financeEntries(lessons, txns), period); + const header: (string | number)[] = [ + t('field.date'), + t('field.student'), + t('finance.subject'), + t('finance.amount'), + t('finance.status'), + t('finance.method'), + ]; + const body = rows.map((e) => [ + new Date(e.occurredAt).toISOString().slice(0, 10), // YYYY-MM-DD — stable, locale-free DATA + studentName(e.studentId), + subjectName(e.subjectId), + e.amount, + t(`pay.${e.kind}` as StringKey), + e.method ? t(`method.${e.method}` as StringKey) : t('common.none'), + ]); + downloadCsv('tutor-plus-report', toCsv([header, ...body])); + setDone(true); + }; + + return ( + + {/* Формат — only CSV enabled. */} + {t('export.format')} + + {formats.map((f) => ( + + + + {f.label} + + + {!f.enabled ? {t('export.soon')} : null} + + ))} + + + {/* Разделы — toggle checkboxes. */} + {t('export.sections')} + + toggle('income')} /> + + toggle('lessons')} /> + + toggle('debts')} /> + + + {done ? {t('export.done')} : null} + + [ + styles.generateBtn, + { backgroundColor: colors.primary, borderRadius: radius.field }, + pressed && styles.pressed, + ]}> + + {t('export.generate')} + + + ); +} + +/** A single «Раздел» row with a checkbox tick (export selector). */ +function SectionToggle({ label, on, onPress }: { label: string; on: boolean; onPress: () => void }) { + const { colors } = useTheme(); + return ( + [styles.sectionRow, pressed && styles.pressed]}> + {label} + + {on ? : null} + + + ); +} + +const styles = StyleSheet.create({ + // top row + topRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 2, marginTop: 2 }, + periodBtn: { flexDirection: 'row', alignItems: 'center', gap: 5, flexShrink: 1, paddingVertical: 4 }, + periodText: { fontSize: 13, fontWeight: '500' }, + exportBtn: { width: 36, height: 36, borderRadius: 12, alignItems: 'center', justifyContent: 'center' }, + pressed: { opacity: 0.7 }, + + // big metric + metric: { fontSize: 38, fontWeight: '600', letterSpacing: -0.8, marginTop: 2, fontVariant: ['tabular-nums'] }, + + // empty coverage + emptyWrap: { alignItems: 'center', gap: 4 }, + resetBtn: { paddingVertical: 11, paddingHorizontal: 20, borderWidth: StyleSheet.hairlineWidth }, + resetText: { fontSize: 15, fontWeight: '500' }, + + // body wrapper + body: { gap: 20 }, + + // cards + chartCard: { padding: 16 }, + kpiCard: { flexDirection: 'row', paddingVertical: 14, paddingHorizontal: 4 }, + kpiSep: { width: StyleSheet.hairlineWidth, marginVertical: 2 }, + + // donut + donutCard: { flexDirection: 'row', alignItems: 'center', gap: 18, padding: 16 }, + donutCenter: { alignItems: 'center' }, + donutCount: { fontSize: 19, fontWeight: '600', fontVariant: ['tabular-nums'] }, + donutUnit: { fontSize: 11 }, + legend: { flex: 1, gap: 9 }, + legendRow: { flexDirection: 'row', alignItems: 'center', gap: 8 }, + legendDot: { width: 10, height: 10, borderRadius: 3 }, + legendLabel: { flex: 1, fontSize: 13 }, + legendPct: { fontSize: 13, fontWeight: '600', fontVariant: ['tabular-nums'] }, + + // bar rows (top directions + debtors) + listCard: { paddingVertical: 4 }, + barRow: { paddingVertical: 13, paddingHorizontal: 14 }, + barRowHead: { flexDirection: 'row', alignItems: 'baseline', justifyContent: 'space-between', marginBottom: 8, gap: 10 }, + barRowName: { flex: 1, fontSize: 14.5, fontWeight: '600' }, + barRowAmount: { fontSize: 14, fontWeight: '600', fontVariant: ['tabular-nums'] }, + progressTrack: { height: 7, borderRadius: 5, overflow: 'hidden' }, + progressFill: { height: '100%', borderRadius: 5 }, + + // debtor row specifics + debtorName: { flex: 1, fontSize: 15, fontWeight: '500' }, + debtorAmountWrap: { flexDirection: 'row', alignItems: 'center', gap: 6 }, + debtorAmount: { fontSize: 15, fontWeight: '500', fontVariant: ['tabular-nums'] }, + + // empty debts + emptyDebtCard: { paddingVertical: 32, paddingHorizontal: 24, alignItems: 'center' }, + emptyDebtTitle: { fontSize: 15, fontWeight: '500' }, + emptyDebtSub: { fontSize: 14, marginTop: 6 }, + + // comparison + compareCard: { paddingVertical: 13, paddingHorizontal: 14 }, + compareResultRow: { flexDirection: 'row', alignItems: 'center', gap: 10, flexWrap: 'wrap' }, + comparePill: { paddingVertical: 4, paddingHorizontal: 11, borderRadius: 999 }, + comparePillText: { fontSize: 15, fontWeight: '600', fontVariant: ['tabular-nums'] }, + compareVs: { fontSize: 13, fontWeight: '500' }, + compareInfoRow: { flexDirection: 'row', alignItems: 'center', gap: 8 }, + compareInfoText: { fontSize: 14, fontWeight: '500' }, + + // export sheet + exportLabel: { fontSize: 13, fontWeight: '500', marginBottom: 10, marginTop: 4 }, + formatRow: { flexDirection: 'row', gap: 9, marginBottom: 18 }, + formatCol: { flex: 1, alignItems: 'center', gap: 4 }, + formatBtn: { width: '100%', height: 46, alignItems: 'center', justifyContent: 'center' }, + formatText: { fontSize: 15, fontWeight: '500' }, + formatSoon: { fontSize: 11, fontWeight: '500' }, + sectionsCard: { paddingVertical: 2, marginBottom: 18 }, + sectionsSep: { height: StyleSheet.hairlineWidth, marginLeft: 14 }, + sectionRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingVertical: 13, paddingHorizontal: 14 }, + sectionLabel: { fontSize: 15 }, + checkbox: { width: 22, height: 22, borderRadius: 6, alignItems: 'center', justifyContent: 'center' }, + exportDone: { fontSize: 13, fontWeight: '500', marginBottom: 10, textAlign: 'center' }, + generateBtn: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 8, height: 50 }, + generateText: { fontSize: 15, fontWeight: '500' }, +}); diff --git a/src/app/(tabs)/finance.tsx b/src/app/(tabs)/finance.tsx index 2d7716a..4fb8bd1 100644 --- a/src/app/(tabs)/finance.tsx +++ b/src/app/(tabs)/finance.tsx @@ -1,12 +1,377 @@ +/** + * Finance root tab (Phase 2, ADR-0011/0012). A period-scoped, searchable, grouped + * feed of money events — a VIEW over the append-only ledger + derived lesson rows. + * + * Nothing here computes money: `financeEntries` builds the row union, `entriesInPeriod` + * scopes it, and `periodSummary` rolls up received/debt — all pure aggregates. The screen + * only owns presentation state (period / active tab / search query) and routes drill-downs + * (a lesson-sourced row opens the lesson; a standalone op opens the operation detail). + */ +import { useRouter } from 'expo-router'; +import { useMemo, useState } from 'react'; +import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native'; + import { EmptyState } from '@/components/EmptyState'; +import { PeriodSheet } from '@/components/PeriodSheet'; import { Screen } from '@/components/Screen'; -import { useT } from '@/i18n'; +import { useAllLessons, useAllTransactions, useStudents, useSubjects } from '@/db/hooks'; +import type { LessonModel, StudentModel, SubjectModel } from '@/db/models'; +import { entriesInPeriod, financeEntries, periodSummary } from '@/domain/aggregates'; +import type { FinanceEntry, FinanceEntryKind } from '@/domain/types'; +import { useT, type StringKey } from '@/i18n'; +import { formatRub } from '@/lib/format'; +import { currentMonth, shiftPeriod, startOfDay, type Period } from '@/lib/period'; +import { nowMs } from '@/lib/time'; +import { useTheme } from '@/theme'; +import { Card, Fab, Icon, Segmented } from '@/ui'; + +/** Finance tabs — a stable key drives filtering; the visible label is the i18n string. */ +type FinTab = 'all' | 'paid' | 'debts' | 'expected'; + +/** A day-bucket of entries for the grouped list (key = local-midnight ms). */ +interface DayGroup { + day: number; + entries: FinanceEntry[]; +} + +/** kind → accent colour for the left strip & amount (paid→paid, debt→danger, expected→warning). */ +function useKindColor(): (kind: FinanceEntryKind) => string { + const { colors } = useTheme(); + return (kind) => (kind === 'paid' ? colors.paid : kind === 'debt' ? colors.danger : colors.warning); +} export default function FinanceScreen() { const t = useT(); + const { colors, radius } = useTheme(); + const router = useRouter(); + const kindColor = useKindColor(); + + // ── Presentation state (period / active tab / search; period defaults to this month) ── + const [period, setPeriod] = useState(() => currentMonth()); + const [tab, setTab] = useState('all'); + const [periodOpen, setPeriodOpen] = useState(false); + const [query, setQuery] = useState(''); + + // ── Reactive data (whole ledger + lessons; students/subjects for name resolution) ── + const lessons = useAllLessons(); + const txns = useAllTransactions(); + const students = useStudents(); + const subjects = useSubjects(); + + const studentsById = useMemo(() => { + const m = new Map(); + for (const s of students) m.set(s.id, s); + return m; + }, [students]); + const subjectsById = useMemo(() => { + const m = new Map(); + for (const s of subjects) m.set(s.id, s); + return m; + }, [subjects]); + // Lesson lookup — resolves a lesson-sourced row's meta line (its topic) for the subtitle. + const lessonsById = useMemo(() => { + const m = new Map(); + for (const l of lessons) m.set(l.id, l); + return m; + }, [lessons]); + + // ── View-model: full entry union, then period-scoped (both pure aggregates) ── + const allEntries = useMemo(() => financeEntries(lessons, txns), [lessons, txns]); + const inPeriod = useMemo(() => entriesInPeriod(allEntries, period), [allEntries, period]); + + // Header summary (received flow + in-period debt) over the period slice (ADR-0012). + const summary = useMemo(() => periodSummary(inPeriod), [inPeriod]); + + // ── Filter by tab (kind) then by query (case-insensitive student-name contains) ── + const filtered = useMemo(() => { + const q = query.trim().toLowerCase(); + return inPeriod.filter((e) => { + if (tab === 'paid' && e.kind !== 'paid') return false; + if (tab === 'debts' && e.kind !== 'debt') return false; + if (tab === 'expected' && e.kind !== 'expected') return false; + if (q) { + const name = studentsById.get(e.studentId)?.name ?? ''; + if (!name.toLowerCase().includes(q)) return false; + } + return true; + }); + }, [inPeriod, tab, query, studentsById]); + + // ── Group the filtered rows by local day, newest day first (already time-desc inside) ── + const groups = useMemo(() => { + const byDay = new Map(); + for (const e of filtered) { + const day = startOfDay(e.occurredAt); + const arr = byDay.get(day); + if (arr) arr.push(e); + else byDay.set(day, [e]); + } + return [...byDay.entries()] + .map(([day, entries]) => ({ day, entries })) + .sort((a, b) => b.day - a.day); + }, [filtered]); + + const todayStart = startOfDay(nowMs()); + + // ── Tab label ↔ key bridge (Segmented matches by the visible string). ── + const TAB_LABEL: Record = { + all: t('finance.tab.all'), + paid: t('finance.tab.paid'), + debts: t('finance.tab.debts'), + expected: t('finance.tab.expected'), + }; + const tabLabels = [TAB_LABEL.all, TAB_LABEL.paid, TAB_LABEL.debts, TAB_LABEL.expected]; + const onTabChange = (label: string) => { + const next = (Object.keys(TAB_LABEL) as FinTab[]).find((k) => TAB_LABEL[k] === label); + if (next) setTab(next); + }; + + // ── Period navigator label («Май 2026» / «2026» / «1–7 июня») — see helper below. ── + const dateLabel = useDateLabel(); + const periodLabel = usePeriodLabel(); + const isCustom = period.type === 'custom'; + + // Drill-down: lesson-sourced row → the lesson card; standalone op → the operation detail. + const openEntry = (e: FinanceEntry) => { + if (e.source === 'lesson' && e.lessonId) { + router.push({ pathname: '/lesson/[id]', params: { id: e.lessonId } }); + } else { + router.push({ pathname: '/finance/[id]', params: { id: e.id } }); + } + }; + + // Meta subtitle under the name: lesson topic / subject for a lesson row, else the kind word. + const metaOf = (e: FinanceEntry): string => { + if (e.source === 'lesson' && e.lessonId) { + const topic = lessonsById.get(e.lessonId)?.topic?.trim(); + if (topic) return topic; + } + if (e.subjectId) { + const name = subjectsById.get(e.subjectId)?.name; + if (name) return name; + } + return t(`pay.${e.kind}` as StringKey); + }; + return ( - - + router.push('/finance/new')} />}> + {/* 1 · Period navigator — ± stepper (disabled for custom) + tappable label opening the sheet. */} + + setPeriod(shiftPeriod(period, -1))} + hitSlop={8} + style={({ pressed }) => [styles.periodArrow, pressed && !isCustom && styles.pressed]}> + + + + setPeriodOpen(true)} + hitSlop={6} + style={({ pressed }) => [styles.periodTitle, pressed && styles.pressed]}> + {periodLabel(period)} + + + + setPeriod(shiftPeriod(period, 1))} + hitSlop={8} + style={({ pressed }) => [styles.periodArrow, pressed && !isCustom && styles.pressed]}> + + + + + {/* 2 · Summary — received (paid flow) | debt (in-period), split by a thin divider. */} + + + + {t('finance.received')} + {formatRub(summary.received)} + + + + {t('finance.debt')} + {formatRub(summary.debt)} + + + + + {/* 3 · Kind tabs. */} + + + {/* 4 · Inline search over operations (by student name). */} + + + + {query.length > 0 ? ( + setQuery('')} + hitSlop={8}> + + + ) : null} + + + {/* 5/6 · Grouped list + empty states. */} + {allEntries.length === 0 ? ( + // No data at all in the whole ledger. + + ) : groups.length === 0 ? ( + // There IS data, but the current period/tab slice is empty — say which. + + + {inPeriod.length === 0 ? t('finance.noOpsPeriod') : t('finance.noOpsTab')} + + + ) : ( + groups.map((g) => ( + + {/* Day header: «Сегодня» for today, else « ». */} + + {g.day === todayStart ? t('group.today') : dateLabel(g.day)} + + + {g.entries.map((e, i) => ( + + {i > 0 ? : null} + openEntry(e)} + style={({ pressed }) => [styles.opRow, pressed && styles.pressed]}> + {/* Left accent strip coloured by kind. */} + + + + {studentsById.get(e.studentId)?.name ?? t('common.none')} + + + {metaOf(e)} + + + + {e.kind === 'paid' ? '+' : ''} + {formatRub(e.amount)} + + + + ))} + + + )) + )} + + {/* Shared period selector (week/month/year/custom). */} + setPeriodOpen(false)} + onApply={(p) => setPeriod(p)} + /> ); } + +// ── Date / period labelling (RU, via i18n month keys — mirrors lesson/[id] useDateLabel) ── + +/** «8 июня» — genitive day-month from a local-instant ms (for day-group headers). */ +function useDateLabel(): (ms: number) => string { + const t = useT(); + return (ms: number) => { + const d = new Date(ms); + return `${d.getDate()} ${t(`monthGen.${d.getMonth()}` as StringKey)}`; + }; +} + +/** + * Period navigator title, composed from the JS Date of `period.start` (and `end − 1`): + * month → «Май 2026» (nominative month + 4-digit year) + * year → «2026» + * week → «1–7 июня» (start-day – end-day + genitive month of the start day) + * custom → same day-range shape as week. + */ +function usePeriodLabel(): (period: Period) => string { + const t = useT(); + return (period) => { + const start = new Date(period.start); + if (period.type === 'month') { + return `${t(`month.${start.getMonth()}` as StringKey)} ${start.getFullYear()}`; + } + if (period.type === 'year') { + return `${start.getFullYear()}`; + } + // week / custom — inclusive day range; `end` is exclusive, so the last day is end − 1ms. + const lastDay = new Date(period.end - 1); + const monthGen = t(`monthGen.${start.getMonth()}` as StringKey); + return `${start.getDate()}–${lastDay.getDate()} ${monthGen}`; + }; +} + +const styles = StyleSheet.create({ + // Period navigator + periodBar: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 14, + paddingTop: 2, + }, + periodArrow: { padding: 4 }, + periodTitle: { flexDirection: 'row', alignItems: 'center', gap: 6, minWidth: 130, justifyContent: 'center' }, + periodLabel: { fontSize: 16, fontWeight: '600', letterSpacing: -0.2 }, + pressed: { opacity: 0.6 }, + + // Summary card + summaryCard: { paddingVertical: 12, paddingHorizontal: 14 }, + summaryRow: { flexDirection: 'row', alignItems: 'stretch' }, + summaryCol: { flex: 1 }, + summaryCaption: { fontSize: 13, fontWeight: '500' }, + summaryReceived: { fontSize: 22, fontWeight: '600', marginTop: 3, fontVariant: ['tabular-nums'] }, + summaryDebt: { fontSize: 19, fontWeight: '600', marginTop: 3, fontVariant: ['tabular-nums'] }, + summaryDivider: { width: StyleSheet.hairlineWidth, marginHorizontal: 14, marginVertical: 2 }, + + // Search + searchRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + height: 42, + paddingHorizontal: 12, + }, + searchInput: { flex: 1, fontSize: 15, padding: 0 }, + + // Empty period/tab slice + emptySliceCard: { paddingVertical: 40, paddingHorizontal: 24, alignItems: 'center' }, + emptySliceText: { fontSize: 14.5, fontWeight: '500', textAlign: 'center', lineHeight: 20 }, + + // Grouped list + group: { gap: 8 }, + groupHeader: { + fontSize: 11, + fontWeight: '600', + letterSpacing: 0.7, + textTransform: 'uppercase', + paddingHorizontal: 2, + }, + hairline: { height: StyleSheet.hairlineWidth, marginLeft: 16 }, + opRow: { flexDirection: 'row', alignItems: 'center', paddingVertical: 13, paddingLeft: 16, paddingRight: 14 }, + opStrip: { position: 'absolute', left: 0, top: 8, bottom: 8, width: 3, borderRadius: 3 }, + opBody: { flex: 1, minWidth: 0 }, + opName: { fontSize: 15, fontWeight: '600' }, + opMeta: { fontSize: 13, marginTop: 3 }, + opAmount: { fontSize: 15, fontWeight: '600', marginLeft: 12, fontVariant: ['tabular-nums'] }, +}); diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx index a2441ce..999e2f9 100644 --- a/src/app/_layout.tsx +++ b/src/app/_layout.tsx @@ -85,6 +85,7 @@ function NavigationRoot() { + diff --git a/src/app/finance/[id].tsx b/src/app/finance/[id].tsx new file mode 100644 index 0000000..a78f036 --- /dev/null +++ b/src/app/finance/[id].tsx @@ -0,0 +1,217 @@ +import { useLocalSearchParams, useRouter } from 'expo-router'; +import { type ReactNode, useMemo } from 'react'; +import { Linking, Pressable, StyleSheet, Text, View } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; + +import { EmptyState } from '@/components/EmptyState'; +import { useStudent, useSubjects, useTransaction } from '@/db/hooks'; +import { createTransaction } from '@/db/mutations'; +import { useT } from '@/i18n'; +import { formatRub } from '@/lib/format'; +import { hhmm } from '@/lib/time'; +import { useTheme } from '@/theme'; +import { Card, Chip, Icon } from '@/ui'; + +/** + * Finance operation detail (ADR-0011) — drill-down for a TXN-sourced Finance row. + * The Finance list routes lesson-sourced rows to `/lesson/[id]` and only standalone / + * settlement transactions here, so `id` is always a transaction id. + * + * Money is APPEND-ONLY: a `debt` is settled by APPENDING a compensating `paid` txn + * (carrying the same lessonId so the lesson's derived payStatus flips) — never by editing + * the original row. Mirrors the custom Header + SafeAreaView shell from `lesson/[id].tsx`. + */ + +/** RU date «8 июня» (genitive day-month) from a UTC-instant ms (device-local), via i18n month keys. */ +function useDateLabel(): (ms: number) => string { + const t = useT(); + return (ms: number) => { + const d = new Date(ms); + const month = t(`monthGen.${d.getMonth()}` as 'monthGen.0'); + return `${d.getDate()} ${month}`; + }; +} + +export default function OperationDetailScreen() { + const { id } = useLocalSearchParams<{ id: string }>(); + const router = useRouter(); + const t = useT(); + const { colors, radius } = useTheme(); + const dateLabel = useDateLabel(); + + // `id` is a transaction id (txn-sourced Finance rows route here); the student owns the operation. + const txn = useTransaction(id); + const student = useStudent(txn?.studentId ?? ''); + + // Resolve the optional subject NAME via the live subjects table (FK → row, no ORM join, ADR-0007). + const subjects = useSubjects(); + const subjectName = useMemo(() => { + if (!txn?.subjectId) return undefined; + return subjects.find((s) => s.id === txn.subjectId)?.name; + }, [subjects, txn?.subjectId]); + + // `expected` is never a stored row (ADR-0008/0011) → a real txn is paid | debt. Treat paid specially, + // everything else (debt) takes the danger styling + the settle action. + const isPaid = txn?.type === 'paid'; + const amountColor = isPaid ? colors.paid : colors.danger; + + /** Settle a debt: APPEND a `paid` txn carrying the debt's lesson/subject links, then pop. */ + const markPaid = async () => { + if (!txn) return; + await createTransaction({ + studentId: txn.studentId, + type: 'paid', + amount: txn.amount, + method: 'transfer', + lessonId: txn.lessonId, + subjectId: txn.subjectId, + }); + router.back(); + }; + + /** Reach out to the student/client via the device dialer (paid-operation convenience). */ + const contact = () => { + if (student?.phone) void Linking.openURL(`tel:${student.phone}`); + }; + + return ( + +
router.back()} /> + + {!txn ? ( + + ) : ( + + {/* Hero: signed amount (leading «+» for income) + the type chip. */} + + + {isPaid ? '+' : ''} + {formatRub(txn.amount)} + + + {t(`pay.${txn.type}` as 'pay.paid')} + + + + {/* Info card — Field/Hairline rows (mirrors lesson/[id].tsx). */} + + + + + + + {subjectName ? ( + <> + + + + ) : null} + + + {t(`pay.${txn.type}` as 'pay.paid')} + + + + {/* Action — settle a debt (primary), or contact for a recorded payment. */} + {txn.type === 'debt' ? ( + [ + styles.action, + { backgroundColor: colors.primary, borderRadius: radius.field }, + pressed && styles.pressed, + ]}> + + {t('finance.markPaidCta')} + + ) : student?.phone ? ( + [ + styles.action, + { backgroundColor: colors.stoneLight, borderRadius: radius.field }, + pressed && styles.pressed, + ]}> + + {t('common.contact')} + + ) : null} + + )} + + ); +} + +/** Compact stack header (this stack has headerShown:false) — mirrors Screen.tsx shell. */ +function Header({ title, onBack }: { title: string; onBack: () => void }) { + const { colors } = useTheme(); + return ( + + [styles.backBtn, { backgroundColor: colors.stoneLight }, pressed && styles.pressed]} + accessibilityRole="button" + accessibilityLabel={title}> + + + {title} + + ); +} + +function Field({ label, value, children }: { label: string; value?: string; children?: ReactNode }) { + const { colors } = useTheme(); + return ( + + {label} + {children ?? {value}} + + ); +} + +function Hairline() { + const { colors } = useTheme(); + return ; +} + +const styles = StyleSheet.create({ + fill: { flex: 1 }, + header: { + flexDirection: 'row', + alignItems: 'center', + gap: 12, + paddingHorizontal: 16, + paddingTop: 6, + paddingBottom: 10, + }, + backBtn: { width: 36, height: 36, borderRadius: 18, alignItems: 'center', justifyContent: 'center' }, + headerTitle: { fontSize: 20, fontWeight: '700', letterSpacing: -0.4 }, + content: { paddingHorizontal: 16, paddingTop: 4, gap: 14 }, + hero: { alignItems: 'center', paddingTop: 10, paddingBottom: 6, gap: 12 }, + amount: { fontSize: 38, fontWeight: '700', letterSpacing: -0.6, fontVariant: ['tabular-nums'] }, + chipRow: { flexDirection: 'row', justifyContent: 'center' }, + card: { paddingHorizontal: 16, paddingVertical: 4 }, + field: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + gap: 16, + paddingVertical: 13, + }, + label: { fontSize: 14, fontWeight: '500' }, + value: { fontSize: 15, fontWeight: '600', flexShrink: 1, textAlign: 'right' }, + hairline: { height: StyleSheet.hairlineWidth }, + action: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 8, + paddingVertical: 14, + }, + actionLabel: { fontSize: 15, fontWeight: '600' }, + pressed: { opacity: 0.85 }, +}); diff --git a/src/app/finance/_layout.tsx b/src/app/finance/_layout.tsx new file mode 100644 index 0000000..9c7ce05 --- /dev/null +++ b/src/app/finance/_layout.tsx @@ -0,0 +1,6 @@ +import { Stack } from 'expo-router'; + +/** Finance sub-routes (operation detail + new operation) — push navigation, each sets its title. */ +export default function FinanceLayout() { + return ; +} diff --git a/src/app/finance/new.tsx b/src/app/finance/new.tsx new file mode 100644 index 0000000..9b1f481 --- /dev/null +++ b/src/app/finance/new.tsx @@ -0,0 +1,410 @@ +/** + * «Новая операция» — the general money-write screen (ADR-0011). Appends a standalone + * transaction (Оплата → `type:'paid'` / Долг → `type:'debt'`) via `createTransaction` + * (money is APPEND-ONLY; this only ever CREATES a row). No «Ожидается» here — `expected` + * is a DERIVED state, never a stored row (ADR-0011), so the type picker offers paid/debt only. + * + * Shell mirrors lesson/[id].tsx: a custom Header (this stack has headerShown:false) inside a + * SafeAreaView; the body is a ScrollView so the lower fields/keyboard stay reachable. Pickers + * reuse the shared kit Sheet (student/subject) and DateTimePickerSheet (date) — no new sheets. + * All strings via i18n (dual-mode resolves inside t()); all colours via theme tokens. + */ +import { useRouter } from 'expo-router'; +import { useState } from 'react'; +import { Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; + +import { DateTimePickerSheet } from '@/components/DateTimePickerSheet'; +import { useStudents, useSubjects } from '@/db/hooks'; +import { createTransaction } from '@/db/mutations'; +import type { PayMethod } from '@/domain/types'; +import { useT, type StringKey } from '@/i18n'; +import { formatNumberRu } from '@/lib/format'; +import { nowMs } from '@/lib/time'; +import { useTheme } from '@/theme'; +import { Card, Chip, Icon, SectionLabel, Sheet } from '@/ui'; + +/** Stored transaction kinds for this form (`expected` is derived, never written — ADR-0011). */ +type OpType = 'paid' | 'debt'; + +/** RU date «8 июня» (genitive day-month) from a UTC-instant ms (device-local) — same pattern as lesson/[id].tsx. */ +function useDateLabel(): (ms: number) => string { + const t = useT(); + return (ms: number) => { + const d = new Date(ms); + const month = t(`monthGen.${d.getMonth()}` as 'monthGen.0'); + return `${d.getDate()} ${month}`; + }; +} + +/** Payment methods, in display order — only relevant for `paid` operations. */ +const METHODS: { key: PayMethod; label: StringKey }[] = [ + { key: 'transfer', label: 'method.transfer' }, + { key: 'cash', label: 'method.cash' }, + { key: 'card', label: 'method.card' }, +]; + +export default function NewOperationScreen() { + const router = useRouter(); + const t = useT(); + const { colors, radius } = useTheme(); + const dateLabel = useDateLabel(); + + const students = useStudents(); + const subjects = useSubjects(); + + // ── Form state ── + const [type, setType] = useState('paid'); + const [studentId, setStudentId] = useState(null); + const [amount, setAmount] = useState(0); + const [method, setMethod] = useState('transfer'); + const [occurredAt, setOccurredAt] = useState(() => nowMs()); + const [subjectId, setSubjectId] = useState(null); + const [comment, setComment] = useState(''); + + // ── Picker visibility ── + const [studentPicker, setStudentPicker] = useState(false); + const [datePicker, setDatePicker] = useState(false); + const [subjectPicker, setSubjectPicker] = useState(false); + + // Amount preview colour follows the operation type (income vs. owed). + const previewColor = type === 'paid' ? colors.paid : colors.danger; + // Chosen entities (undefined until a row is tapped → placeholder shown). + const student = students.find((s) => s.id === studentId); + const subject = subjects.find((s) => s.id === subjectId); + + // Save is gated: a counterparty must be chosen and the amount must be positive. + const canSave = studentId != null && amount > 0; + + const save = async () => { + if (!canSave || studentId == null) return; + await createTransaction({ + studentId, + type, + amount, + // Method only makes sense for an actual payment; a debt has none. + method: type === 'paid' ? method : null, + occurredAt, + subjectId: subjectId ?? null, + comment: comment || null, + }); + router.back(); + }; + + return ( + +
router.back()} /> + + + {/* ── Amount preview (centred, large) ───────────────────────────────── */} + + + {`${formatNumberRu(amount)} ₽`} + + + {t(type === 'paid' ? 'op.paid' : 'op.debt')} + + + + {/* ── Тип операции (paid / debt) ────────────────────────────────────── */} + {t('finance.opType')} + + {(['paid', 'debt'] as const).map((k) => { + const on = k === type; + return ( + setType(k)} + style={({ pressed }) => [ + styles.choiceBtn, + { + backgroundColor: on ? colors.primaryVlight : colors.surface, + borderColor: on ? colors.primary : colors.hairline, + borderRadius: radius.field, + }, + pressed && styles.pressed, + ]}> + + {t(k === 'paid' ? 'op.paid' : 'op.debt')} + + + ); + })} + + + {/* ── Main fields: student · date · amount ──────────────────────────── */} + + setStudentPicker(true)} + style={({ pressed }) => [styles.pickRow, pressed && styles.pressed]}> + {t('field.student')} + + {student?.name ?? t('finance.chooseStudent')} + + + + + + + setDatePicker(true)} + style={({ pressed }) => [styles.pickRow, pressed && styles.pressed]}> + {t('field.date')} + {dateLabel(occurredAt)} + + + + + + {/* Сумма — inline numeric input (digits only). */} + + {t('finance.amount')} + 0 ? String(amount) : ''} + onChangeText={(s) => setAmount(Number(s.replace(/\D/g, '')) || 0)} + keyboardType="number-pad" + placeholder="0" + placeholderTextColor={colors.stoneInactive} + style={[styles.amountInput, { color: colors.heading }]} + /> + + + + {/* ── Способ оплаты (only for an actual payment) ────────────────────── */} + {type === 'paid' ? ( + <> + {t('finance.method')} + + {METHODS.map(({ key, label }) => { + const on = key === method; + return ( + setMethod(key)} + style={({ pressed }) => [ + styles.choiceBtn, + { + backgroundColor: on ? colors.primaryVlight : colors.surface, + borderColor: on ? colors.primary : colors.hairline, + borderRadius: radius.field, + }, + pressed && styles.pressed, + ]}> + {t(label)} + + ); + })} + + + ) : null} + + {/* ── Optional: subject (picker) + free-text comment ────────────────── */} + + setSubjectPicker(true)} + style={({ pressed }) => [styles.pickRow, pressed && styles.pressed]}> + {t('finance.subject')} + + {subject?.name ?? t('finance.optional')} + + + + + + + + {t('finance.comment')} + + + + + {/* ── Save (append the transaction) ─────────────────────────────────── */} + void save()} + disabled={!canSave} + style={({ pressed }) => [ + styles.save, + { + backgroundColor: canSave ? colors.primary : colors.stoneLight, + borderRadius: radius.field, + }, + pressed && canSave && styles.pressed, + ]}> + + {t('finance.saveOp')} + + + + + {/* ── Student picker — kit Sheet over useStudents() ─────────────────────── */} + {studentPicker ? ( + setStudentPicker(false)}> + + {students.map((s, i) => { + const on = s.id === studentId; + return ( + + {i > 0 ? : null} + { + setStudentId(s.id); + setStudentPicker(false); + }} + style={({ pressed }) => [ + styles.optionRow, + on && { backgroundColor: colors.primaryVlight }, + pressed && styles.pressed, + ]}> + {s.name} + {on ? : null} + + + ); + })} + + + ) : null} + + {/* ── Subject picker — kit Sheet over useSubjects() ────────────────────── */} + {subjectPicker ? ( + setSubjectPicker(false)}> + + {subjects.map((s, i) => { + const on = s.id === subjectId; + return ( + + {i > 0 ? : null} + { + // Tapping the chosen subject again clears it (back to «Необязательно»). + setSubjectId(on ? null : s.id); + setSubjectPicker(false); + }} + style={({ pressed }) => [ + styles.optionRow, + on && { backgroundColor: colors.primaryVlight }, + pressed && styles.pressed, + ]}> + {s.name} + {on ? : null} + + + ); + })} + + + ) : null} + + {/* ── Date picker — shared kit sheet, date only (UTC-ms instant) ────────── */} + setDatePicker(false)} + onPick={(ms) => setOccurredAt(ms)} + /> + + ); +} + +/** Compact stack header (this stack has headerShown:false) — mirrors lesson/[id].tsx. */ +function Header({ title, onBack }: { title: string; onBack: () => void }) { + const { colors } = useTheme(); + return ( + + [styles.backBtn, { backgroundColor: colors.stoneLight }, pressed && styles.pressed]} + accessibilityRole="button" + accessibilityLabel={title}> + + + {title} + + ); +} + +/** Hairline divider — same token + thickness as lesson/[id].tsx. */ +function Hairline() { + const { colors } = useTheme(); + return ; +} + +const styles = StyleSheet.create({ + fill: { flex: 1 }, + header: { + flexDirection: 'row', + alignItems: 'center', + gap: 12, + paddingHorizontal: 16, + paddingTop: 6, + paddingBottom: 10, + }, + backBtn: { width: 36, height: 36, borderRadius: 18, alignItems: 'center', justifyContent: 'center' }, + headerTitle: { fontSize: 20, fontWeight: '700', letterSpacing: -0.4 }, + content: { paddingHorizontal: 16, paddingTop: 4, paddingBottom: 40, gap: 14 }, + + // amount preview + preview: { alignItems: 'center', paddingTop: 4, paddingBottom: 6, gap: 10 }, + previewAmount: { fontSize: 36, fontWeight: '700', letterSpacing: -0.6, fontVariant: ['tabular-nums'] }, + previewChip: { flexDirection: 'row', justifyContent: 'center' }, + + // inline choice button rows (type / method) + btnRow: { flexDirection: 'row', gap: 9 }, + choiceBtn: { + flex: 1, + height: 46, + alignItems: 'center', + justifyContent: 'center', + borderWidth: StyleSheet.hairlineWidth, + }, + choiceLabel: { fontSize: 14.5, fontWeight: '600' }, + + // field cards + card: { paddingHorizontal: 14 }, + pickRow: { flexDirection: 'row', alignItems: 'center', gap: 12, minHeight: 52 }, + rowLabel: { fontSize: 14, fontWeight: '500', flexShrink: 0 }, + rowValue: { flex: 1, textAlign: 'right', fontSize: 15, fontWeight: '600' }, + amountInput: { + flex: 1, + textAlign: 'right', + fontSize: 17, + fontWeight: '600', + fontVariant: ['tabular-nums'], + paddingVertical: 0, + }, + commentInput: { flex: 1, textAlign: 'right', fontSize: 15, fontWeight: '600', paddingVertical: 0 }, + hairline: { height: StyleSheet.hairlineWidth }, + + // picker sheet rows + optionRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + gap: 12, + paddingVertical: 14, + paddingHorizontal: 12, + }, + optionLabel: { fontSize: 15, fontWeight: '500' }, + + // save + save: { height: 50, alignItems: 'center', justifyContent: 'center', marginTop: 4 }, + saveLabel: { fontSize: 15.5, fontWeight: '700' }, + + pressed: { opacity: 0.85 }, +}); diff --git a/src/components/PeriodSheet.tsx b/src/components/PeriodSheet.tsx new file mode 100644 index 0000000..be51348 --- /dev/null +++ b/src/components/PeriodSheet.tsx @@ -0,0 +1,179 @@ +/** + * Shared period selector (ADR-0012) — a kit Sheet to pick Неделя/Месяц/Год/Произвольный, + * used by BOTH Finance and Analytics. Returns a `Period` (`lib/period`). The screen's ± + * stepper navigates WITHIN a type; this sheet switches type and picks month/year/range. + * Web-safe; strings via i18n, colours via theme. Custom range = two sequential date picks. + */ +import { useState } from 'react'; +import { Pressable, StyleSheet, Text, View } from 'react-native'; + +import { useT, type StringKey } from '@/i18n'; +import { customRange, monthOf, weekOf, yearOf, type Period, type PeriodType } from '@/lib/period'; +import { useTheme } from '@/theme'; +import { Icon, Sheet } from '@/ui'; + +import { DateTimePickerSheet } from './DateTimePickerSheet'; + +export interface PeriodSheetProps { + visible: boolean; + /** The currently-applied period (anchors which month/year a type-switch lands on). */ + period: Period; + onClose: () => void; + onApply: (p: Period) => void; +} + +const TYPES: { key: PeriodType; label: StringKey }[] = [ + { key: 'week', label: 'period.week' }, + { key: 'month', label: 'period.month' }, + { key: 'year', label: 'period.year' }, + { key: 'custom', label: 'period.custom' }, +]; + +export function PeriodSheet({ visible, period, onClose, onApply }: PeriodSheetProps) { + const t = useT(); + const { colors, radius } = useTheme(); + + const [type, setType] = useState(period.type); + const [year, setYear] = useState(() => new Date(period.start).getFullYear()); + const anchorMonth = new Date(period.start).getMonth(); + const anchorYear = new Date(period.start).getFullYear(); + + // Custom range: two sequential date pickers (start → end). + const [rangeStart, setRangeStart] = useState(null); + const [pickStart, setPickStart] = useState(false); + const [pickEnd, setPickEnd] = useState(false); + + const commit = (p: Period) => { + onApply(p); + onClose(); + }; + + const onPickType = (next: PeriodType) => { + setType(next); + if (next === 'week') commit(weekOf(period.start)); // current week of the shown period; stepper navigates + }; + + return ( + <> + + + {TYPES.map(({ key, label }) => { + const on = key === type; + return ( + onPickType(key)} + style={[ + styles.chip, + { + backgroundColor: on ? colors.primary : colors.surface, + borderColor: colors.hairline, + borderWidth: on ? 0 : StyleSheet.hairlineWidth, + borderRadius: radius.pill, + }, + ]}> + {t(label)} + + ); + })} + + + {(type === 'month' || type === 'year') && ( + + + setYear((y) => y - 1)} hitSlop={8} accessibilityRole="button" accessibilityLabel={t('a11y.prevMonth')} style={[styles.yArrow, { backgroundColor: colors.stoneLight }]}> + + + {year} + setYear((y) => y + 1)} hitSlop={8} accessibilityRole="button" accessibilityLabel={t('a11y.nextMonth')} style={[styles.yArrow, { backgroundColor: colors.stoneLight }]}> + + + + + {type === 'month' ? ( + + {Array.from({ length: 12 }).map((_, m) => { + const on = m === anchorMonth && year === anchorYear; + return ( + commit(monthOf(new Date(year, m, 1).getTime()))} + style={[styles.month, { backgroundColor: on ? colors.primary : colors.stoneLight, borderRadius: radius.field }]}> + + {t(`month.${m}` as StringKey).slice(0, 3)} + + + ); + })} + + ) : ( + commit(yearOf(new Date(year, 0, 1).getTime()))} + style={[styles.cta, { backgroundColor: colors.primary, borderRadius: radius.field }]}> + {`${t('period.selectYear')} ${year}`} + + )} + + )} + + {type === 'custom' && ( + + { + setRangeStart(null); + setPickStart(true); + }} + style={[styles.rangeBtn, { borderColor: colors.hairline, borderRadius: radius.field }]}> + + {t('period.pickDates')} + + + {t('period.pickHint')} + + )} + + + {/* Custom range — start then end (separate sheets so each pick advances cleanly). */} + setPickStart(false)} + onPick={(ms) => { + setRangeStart(ms); + setPickEnd(true); // advance to the end picker + }} + /> + setPickEnd(false)} + onPick={(ms) => { + if (rangeStart != null) commit(customRange(rangeStart, ms)); + }} + /> + + ); +} + +const styles = StyleSheet.create({ + chips: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, marginBottom: 16 }, + chip: { paddingHorizontal: 14, paddingVertical: 8 }, + chipText: { fontSize: 14, fontWeight: '600' }, + yearBar: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 24, marginBottom: 14 }, + yArrow: { width: 34, height: 34, borderRadius: 11, alignItems: 'center', justifyContent: 'center' }, + yearLabel: { fontSize: 17, fontWeight: '600', minWidth: 60, textAlign: 'center', fontVariant: ['tabular-nums'] }, + grid: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 }, + month: { width: '22%', flexGrow: 1, paddingVertical: 12, alignItems: 'center' }, + monthText: { fontSize: 13, fontWeight: '600' }, + cta: { paddingVertical: 14, alignItems: 'center', marginTop: 4 }, + ctaText: { fontSize: 15, fontWeight: '700' }, + rangeBtn: { flexDirection: 'row', alignItems: 'center', gap: 12, paddingVertical: 14, paddingHorizontal: 14, borderWidth: StyleSheet.hairlineWidth }, + rangeText: { flex: 1, fontSize: 15, fontWeight: '600' }, + hint: { fontSize: 13, marginTop: 8, paddingHorizontal: 2, lineHeight: 18 }, +}); + +export default PeriodSheet; diff --git a/src/db/hooks.ts b/src/db/hooks.ts index f62fb06..2ff24ff 100644 --- a/src/db/hooks.ts +++ b/src/db/hooks.ts @@ -74,15 +74,34 @@ export function useLessonsInRange(start: number, end: number): LessonModel[] { ); } -/** Whole ledger (reactive on the columns aggregates read) — for cross-student debt. */ +/** Whole ledger (reactive) — cross-student debt + Finance/Analytics aggregates. Append-only, + * so inserts (new payments/debts) re-emit; observed columns cover the netting/entry fields. */ export function useAllTransactions(): TransactionModel[] { return useObservable( - () => txnsC().query().observeWithColumns(['type', 'amount', 'student_id', 'lesson_id']), + () => + txnsC() + .query(Q.sortBy('occurred_at', Q.desc)) + .observeWithColumns(['type', 'amount', 'student_id', 'lesson_id', 'subject_id', 'occurred_at', 'method']), [], [], ); } +/** All lessons (reactive), newest first — Finance entries (derived debt/expected rows) + + * Analytics buckets across periods. Single-practitioner scope → whole-table is cheap. */ +export function useAllLessons(): LessonModel[] { + return useObservable( + () => lessonsC().query(Q.sortBy('starts_at', Q.desc)).observeWithColumns(LESSON_COLS), + [], + [], + ); +} + +/** A single transaction (reactive); undefined until loaded — for the Finance operation detail. */ +export function useTransaction(id: string): TransactionModel | undefined { + return useObservable(() => txnsC().findAndObserve(id), [id], undefined); +} + /** One student's transactions (reactive). */ export function useStudentTransactions(studentId: string): TransactionModel[] { return useObservable( diff --git a/src/db/mutations.ts b/src/db/mutations.ts index 002aa57..216db10 100644 --- a/src/db/mutations.ts +++ b/src/db/mutations.ts @@ -145,6 +145,41 @@ export async function recordLessonPayment( }); } +export interface TransactionInput { + studentId: string; + /** Only `paid`/`debt` are ever stored — `expected` is derived, never a row (ADR-0011). */ + type: Exclude; + amount: number; + method?: PayMethod | null; + /** UTC-instant ms; defaults to now. */ + occurredAt?: number; + subjectId?: string | null; + comment?: string | null; + /** Lesson-anchored (settlement / per-lesson) when set; standalone (prepayment/general) when null. */ + lessonId?: string | null; +} + +/** + * Append a transaction — the general money-write for Phase-2 Finance (ADR-0011). Covers + * the «Новая операция» FAB (standalone Оплата/Долг) AND settling a debt FinanceEntry + * (pass `type:'paid'` + the debt row's `lessonId` → its lesson's payStatus flips to paid, + * debt drops). Append-only: this only ever CREATES rows; corrections are new rows. + */ +export async function createTransaction(input: TransactionInput): Promise { + return database.write(async () => + database.get('transactions').create((t) => { + t.studentId = input.studentId; + t.lessonId = input.lessonId ?? null; + t.amount = input.amount; + t.type = input.type; + t.method = input.method ?? null; + t.subjectId = input.subjectId ?? null; + t.occurredAt = input.occurredAt ?? Date.now(); + t.comment = input.comment ?? null; + }), + ); +} + export async function cancelLesson(lesson: LessonModel, reason?: string, comment?: string): Promise { await database.write(async () => { await lesson.update((l) => { diff --git a/src/db/seed.ts b/src/db/seed.ts index 8085160..37ee9d4 100644 --- a/src/db/seed.ts +++ b/src/db/seed.ts @@ -65,6 +65,33 @@ const LESSONS: LessonSpec[] = [ { student: 'Дмитрий Орлов', subject: 'Английский', topic: 'Past Simple', day: -3, hour: 19, dur: 60, fmt: 'online', price: 1600, life: 'done', pay: 'paid' }, ]; +/** + * Historical conducted+paid lessons across the past ~5 months (Phase 2, ADR-0012): gives + * Analytics realistic monthly/weekly bars, an income growth trend (more recent months + * busier), subject spread (donut / top directions) and a few extra debts (debtors list). + * Deterministic (no RNG) so the seed stays reproducible across launches. + */ +function buildHistory(): LessonSpec[] { + const active = STUDENTS.filter((s) => s.status !== 'archived'); + const perMonth = [9, 8, 7, 6, 5]; // monthsBack 1..5 — recent months busier → upward bars + const out: LessonSpec[] = []; + let n = 0; + for (let mb = 1; mb <= perMonth.length; mb += 1) { + for (let i = 0; i < perMonth[mb - 1]; i += 1) { + const stu = active[n % active.length]; + const subject = stu.subjects[i % stu.subjects.length]; + const day = -(mb * 30) + ((i * 5) % 25) - 12; // cluster in the mb-th month back, ±~12d + const hour = 10 + (i % 9); + const pay: TxnType = mb <= 2 && i % 6 === 2 ? 'debt' : 'paid'; // a few recent debts + out.push({ student: stu.name, subject, topic: subject, day, hour, dur: 60, fmt: stu.format, price: stu.rate, life: 'done', pay }); + n += 1; + } + } + return out; +} + +const HISTORY: LessonSpec[] = buildHistory(); + export async function seedIfEmpty(): Promise { const count = await database.get('students').query().fetchCount(); if (count > 0) return; @@ -111,7 +138,7 @@ export async function seedIfEmpty(): Promise { } } - for (const spec of LESSONS) { + for (const spec of [...LESSONS, ...HISTORY]) { const sid = studentId.get(spec.student); if (!sid) continue; const subjId = subjectId.get(spec.subject) ?? null; diff --git a/src/domain/aggregates.ts b/src/domain/aggregates.ts index b39b273..8a2bafd 100644 --- a/src/domain/aggregates.ts +++ b/src/domain/aggregates.ts @@ -1,60 +1,386 @@ /** - * Pure derived-value layer (ADR-0008). The SINGLE source of truth for money is the - * append-only `transactions` ledger; `Student.debt` and `Lesson.payStatus` are never - * stored — they are COMPUTED here, client-side, offline-first. + * Pure derived-value layer (ADR-0008/0011). The SINGLE source of truth for money is the + * append-only `transactions` ledger; `Student.debt`, `Lesson.payStatus`, every Finance row + * and every Analytics number are never stored — they are COMPUTED here, client-side, + * offline-first. * - * Functions are typed over minimal structural slices so both plain DTOs (`./types`) - * and WatermelonDB model instances satisfy them — keeping this module free of any - * persistence/React dependency, hence trivially testable and portable (heavy/period - * aggregates may move server-side in Phase 4, same contract). + * Functions are typed over minimal structural slices so both plain DTOs (`./types`) and + * WatermelonDB model instances satisfy them — keeping this module free of any persistence/ + * React dependency, hence trivially testable and portable (heavy/period aggregates may move + * server-side in Phase 4, same contract). * - * ⚠ Phase 1 scope: there is NO debt-settlement flow (Finance UI is Phase 2), so every - * transaction is a one-time, lesson-linked paid/debt at mark-done and debt only grows. - * The naive `Σ(type='debt')` rule is therefore correct here. Netting (`Σdebt − Σpaid`), - * prepayment/credit, packages and FIFO allocation land in Phase 2 — see TODOs. + * Phase 2 (ADR-0011): debt now SETTLES (append a `paid` txn linked to the same lesson → + * payStatusOf flips to `paid`), so `debtOf` nets settled lessons out. Intentional Phase-2 + * limits: full-payment only (no partial), no FIFO/credit/packages — see TODO(Phase 3). */ -import type { LifecycleStatus, PayStatus, TxnType } from './types'; +import { periodContains, type Period } from '@/lib/period'; -type TxnSlice = { type: TxnType; amount: number; lessonId: string | null }; -type LessonSlice = { lifecycleStatus: LifecycleStatus }; +import type { + FinanceEntry, + LifecycleStatus, + PayMethod, + PayStatus, + TxnType, +} from './types'; -/** - * Outstanding debt of a student = Σ amount of `debt`-type transactions. - * TODO(Phase 2): net against payments — `max(0, Σdebt − Σpaid)` — once settlement exists. - */ -export function debtOf(transactions: readonly Pick[]): number { - let total = 0; - for (const t of transactions) if (t.type === 'debt') total += t.amount; - return total; -} +// ── Structural slices (model instances & DTOs both satisfy these) ──────────── +type TxnSlice = { + id: string; + studentId: string; + type: TxnType; + amount: number; + lessonId: string | null; + subjectId: string | null; + occurredAt: number; + method: PayMethod | null; +}; +type LessonSlice = { + id: string; + studentId: string; + subjectId: string | null; + price: number; + startsAt: number; + lifecycleStatus: LifecycleStatus; +}; + +// ── Phase-1 derivations (unchanged contract) ───────────────────────────────── /** * Payment status of a lesson, derived from its DIRECTLY-linked transactions: * any linked `paid` → 'paid'; else any linked `debt` → 'debt'; else 'expected' - * (future or not-yet-recorded). - * TODO(Phase 2): allocate unlinked prepayment/packages to lessons. + * (future or not-yet-recorded). Settlement (ADR-0011) appends a linked `paid`, + * so a settled debt-lesson reads `paid` here. */ export function payStatusOf( lessonId: string, transactions: readonly Pick[], ): PayStatus { - let hasDebt = false; + let hasDebtLink = false; for (const t of transactions) { if (t.lessonId !== lessonId) continue; if (t.type === 'paid') return 'paid'; - if (t.type === 'debt') hasDebt = true; + if (t.type === 'debt') hasDebtLink = true; } - return hasDebt ? 'debt' : 'expected'; + return hasDebtLink ? 'debt' : 'expected'; } /** «проведено N из M» — lifecycle aggregate (NOT payment) over a set of lessons. */ -export function doneOfTotal(lessons: readonly LessonSlice[]): { done: number; total: number } { +export function doneOfTotal( + lessons: readonly Pick[], +): { done: number; total: number } { let done = 0; for (const l of lessons) if (l.lifecycleStatus === 'done') done += 1; return { done, total: lessons.length }; } +// ── Debt (Phase-2 netting, ADR-0011) ───────────────────────────────────────── + +type DebtTxnSlice = Pick; + +/** + * Outstanding debt of a student, computed from their transactions alone (ADR-0011): + * Σ debt-amount over lessons with a `debt`-txn and NO `paid`-txn (lesson-anchored) + * + max(0, Σ standaloneDebt − Σ standalonePaid) (operations w/o lesson) + * + * Settled lessons net out because their linked `paid` clears the lesson term; standalone + * payments only offset standalone debts (no FIFO into lesson debts yet). Non-breaking vs + * Phase 1: callers still pass one student's txns (slices carry `lessonId`). + * TODO(Phase 3): partial payments, FIFO allocation, prepayment/credit, packages. + */ +export function debtOf(transactions: readonly DebtTxnSlice[]): number { + const byLesson = new Map(); + let standaloneDebt = 0; + let standalonePaid = 0; + + for (const t of transactions) { + if (t.lessonId == null) { + if (t.type === 'debt') standaloneDebt += t.amount; + else if (t.type === 'paid') standalonePaid += t.amount; + continue; + } + const e = byLesson.get(t.lessonId) ?? { debt: 0, paid: false }; + if (t.type === 'debt') e.debt += t.amount; + else if (t.type === 'paid') e.paid = true; + byLesson.set(t.lessonId, e); + } + + let total = 0; + for (const e of byLesson.values()) if (!e.paid) total += e.debt; + return total + Math.max(0, standaloneDebt - standalonePaid); +} + /** Whether a student currently owes money (drives the «Есть долг» filter & card badge). */ -export function hasDebt(transactions: readonly Pick[]): boolean { +export function hasDebt(transactions: readonly DebtTxnSlice[]): boolean { return debtOf(transactions) > 0; } + +/** Outstanding debt per student over the WHOLE ledger — for the Analytics debtors list. */ +export function debtors( + transactions: readonly (DebtTxnSlice & Pick)[], +): { studentId: string; amount: number }[] { + const byStudent = new Map(); + for (const t of transactions) { + const arr = byStudent.get(t.studentId); + if (arr) arr.push(t); + else byStudent.set(t.studentId, [t]); + } + const out: { studentId: string; amount: number }[] = []; + for (const [studentId, list] of byStudent) { + const amount = debtOf(list); + if (amount > 0) out.push({ studentId, amount }); + } + out.sort((a, b) => b.amount - a.amount); + return out; +} + +// ── Finance entries (view-model union, ADR-0011) ───────────────────────────── + +/** Per-lesson linked-txn presence — internal cache so financeEntries is O(L+T), not O(L·T). */ +function linkedStatusMap( + transactions: readonly Pick[], +): Map { + const flags = new Map(); + for (const t of transactions) { + if (t.lessonId == null) continue; + const e = flags.get(t.lessonId) ?? { paid: false, debt: false }; + if (t.type === 'paid') e.paid = true; + else if (t.type === 'debt') e.debt = true; + flags.set(t.lessonId, e); + } + const status = new Map(); + for (const [id, e] of flags) status.set(id, e.paid ? 'paid' : e.debt ? 'debt' : 'expected'); + return status; +} + +/** + * The Finance list as a union of view rows (ADR-0011), newest first: + * 1. every `paid` txn → a paid row (lesson settlements + standalone income) + * 2. each non-cancelled lesson with derived `debt`/`expected` status → a derived row + * (paid lessons are represented by their paid txn in #1) + * 3. every standalone `debt` txn → a debt row + * `expected` rows are never stored — always derived from a lesson with no linked txn. + */ +export function financeEntries( + lessons: readonly LessonSlice[], + transactions: readonly TxnSlice[], +): FinanceEntry[] { + const entries: FinanceEntry[] = []; + const linked = linkedStatusMap(transactions); + + for (const t of transactions) { + if (t.type === 'paid') { + entries.push({ + id: t.id, + kind: 'paid', + studentId: t.studentId, + lessonId: t.lessonId, + subjectId: t.subjectId, + amount: t.amount, + occurredAt: t.occurredAt, + method: t.method, + source: 'txn', + }); + } + } + + for (const l of lessons) { + if (l.lifecycleStatus === 'cancelled') continue; // no money event from a cancelled lesson + const status = linked.get(l.id) ?? 'expected'; + if (status === 'paid') continue; // represented by its paid txn above + entries.push({ + id: `lesson:${l.id}`, + kind: status, + studentId: l.studentId, + lessonId: l.id, + subjectId: l.subjectId, + amount: l.price, + occurredAt: l.startsAt, + method: null, + source: 'lesson', + }); + } + + for (const t of transactions) { + if (t.lessonId == null && t.type === 'debt') { + entries.push({ + id: t.id, + kind: 'debt', + studentId: t.studentId, + lessonId: null, + subjectId: t.subjectId, + amount: t.amount, + occurredAt: t.occurredAt, + method: t.method, + source: 'txn', + }); + } + } + + entries.sort((a, b) => b.occurredAt - a.occurredAt); + return entries; +} + +/** Keep only entries whose instant falls in `period` (txn occurredAt / lesson startsAt). */ +export function entriesInPeriod(entries: readonly FinanceEntry[], period: Period): FinanceEntry[] { + return entries.filter((e) => periodContains(period, e.occurredAt)); +} + +/** Period summary for the Finance header — received (flow) + debt (in-period), ADR-0012. */ +export function periodSummary(entries: readonly FinanceEntry[]): { received: number; debt: number } { + let received = 0; + let debt = 0; + for (const e of entries) { + if (e.kind === 'paid') received += e.amount; + else if (e.kind === 'debt') debt += e.amount; + } + return { received, debt }; +} + +// ── Analytics aggregates (ADR-0012) ────────────────────────────────────────── + +type PaidTxnSlice = Pick; + +/** Σ `paid` amount whose `occurredAt` ∈ period — the income flow. */ +export function incomeInPeriod(transactions: readonly PaidTxnSlice[], period: Period): number { + let total = 0; + for (const t of transactions) { + if (t.type === 'paid' && periodContains(period, t.occurredAt)) total += t.amount; + } + return total; +} + +/** + * Σ `paid` amount per bucket — bars for Overview (months) and Dynamics (weeks). `bucketOf` + * maps an instant to its bucket-anchor (e.g. `(ms) => monthOf(ms).start`); `buckets` are the + * anchors to report, in order. Returns a parallel array of sums. + */ +export function paidByBucket( + transactions: readonly PaidTxnSlice[], + buckets: readonly number[], + bucketOf: (ms: number) => number, +): number[] { + const acc = new Map(); + for (const b of buckets) acc.set(b, 0); + for (const t of transactions) { + if (t.type !== 'paid') continue; + const b = bucketOf(t.occurredAt); + const cur = acc.get(b); + if (cur !== undefined) acc.set(b, cur + t.amount); + } + return buckets.map((b) => acc.get(b) ?? 0); +} + +/** Count of conducted (done) lessons whose `startsAt` ∈ period. */ +export function lessonsConductedInPeriod(lessons: readonly LessonSlice[], period: Period): number { + let n = 0; + for (const l of lessons) { + if (l.lifecycleStatus === 'done' && periodContains(period, l.startsAt)) n += 1; + } + return n; +} + +/** Count of cancelled lessons whose `startsAt` ∈ period. */ +export function cancellationsInPeriod(lessons: readonly LessonSlice[], period: Period): number { + let n = 0; + for (const l of lessons) { + if (l.lifecycleStatus === 'cancelled' && periodContains(period, l.startsAt)) n += 1; + } + return n; +} + +/** Average payment in period = received / count(paid) (rounded; 0 if none). */ +export function avgCheckInPeriod(transactions: readonly PaidTxnSlice[], period: Period): number { + let sum = 0; + let n = 0; + for (const t of transactions) { + if (t.type === 'paid' && periodContains(period, t.occurredAt)) { + sum += t.amount; + n += 1; + } + } + return n === 0 ? 0 : Math.round(sum / n); +} + +/** Income share per subject (from `paid` in period), sorted desc — for the Overview donut. */ +export function subjectTotals( + transactions: readonly PaidTxnSlice[], + period: Period, +): { subjectId: string | null; amount: number }[] { + const acc = new Map(); + for (const t of transactions) { + if (t.type !== 'paid' || !periodContains(period, t.occurredAt)) continue; + acc.set(t.subjectId, (acc.get(t.subjectId) ?? 0) + t.amount); + } + const out = [...acc].map(([subjectId, amount]) => ({ subjectId, amount })); + out.sort((a, b) => b.amount - a.amount); + return out; +} + +export interface DirectionStat { + subjectId: string | null; + /** Σ paid in period. */ + amount: number; + /** Conducted lessons in period. */ + lessons: number; + /** Distinct students active in this direction in period. */ + students: number; + /** Outstanding debt attributed to this direction (in period, by lesson startsAt). */ + debt: number; +} + +/** Per-direction breakdown for the «Топ направлений» list (ranked by income). */ +export function topDirections( + lessons: readonly LessonSlice[], + transactions: readonly TxnSlice[], + period: Period, +): DirectionStat[] { + const stat = new Map; debt: number }>(); + const get = (id: string | null) => { + let s = stat.get(id); + if (!s) { + s = { amount: 0, lessons: 0, students: new Set(), debt: 0 }; + stat.set(id, s); + } + return s; + }; + + for (const t of transactions) { + if (t.type === 'paid' && periodContains(period, t.occurredAt)) { + const s = get(t.subjectId); + s.amount += t.amount; + s.students.add(t.studentId); + } + } + + const linked = linkedStatusMap(transactions); + for (const l of lessons) { + if (l.lifecycleStatus === 'cancelled' || !periodContains(period, l.startsAt)) continue; + const s = get(l.subjectId); + if (l.lifecycleStatus === 'done') { + s.lessons += 1; + s.students.add(l.studentId); + } + if ((linked.get(l.id) ?? 'expected') === 'debt') s.debt += l.price; + } + + const out: DirectionStat[] = [...stat].map(([subjectId, s]) => ({ + subjectId, + amount: s.amount, + lessons: s.lessons, + students: s.students.size, + debt: s.debt, + })); + out.sort((a, b) => b.amount - a.amount); + return out; +} + +/** Delta of a metric between the current and a comparison period. `dir` is sign-only — the + * screen decides whether up/down is good (income up = good; debt down = good). */ +export function metricDelta( + current: number, + previous: number, +): { abs: number; pct: number | null; dir: 'up' | 'down' | 'flat' } { + const abs = current - previous; + const pct = previous === 0 ? null : Math.round((abs / previous) * 100); + return { abs, pct, dir: abs > 0 ? 'up' : abs < 0 ? 'down' : 'flat' }; +} diff --git a/src/domain/types.ts b/src/domain/types.ts index c92ab6c..6918979 100644 --- a/src/domain/types.ts +++ b/src/domain/types.ts @@ -84,3 +84,27 @@ export interface Transaction { comment: string | null; createdAt: number; } + +/** + * A money-relevant row in the Finance list (ADR-0011) — a VIEW-MODEL, not a stored + * entity. The list is a union of real `paid` transactions, derived `debt`/`expected` + * lessons (a lesson's payStatus, not a stored row), and standalone `debt` transactions. + * `expected` is never stored — it is always a derived lesson row (ADR-0008/0011). + */ +export type FinanceEntryKind = PayStatus; // 'paid' | 'debt' | 'expected' + +export interface FinanceEntry { + /** Stable row id: the txn id, or `lesson:` for a derived lesson row. */ + id: string; + kind: FinanceEntryKind; + studentId: string; + /** Present for lesson-anchored rows; null for standalone operations. */ + lessonId: string | null; + subjectId: string | null; + amount: number; + /** Bucket/sort instant: txn.occurredAt, or lesson.startsAt for a derived row. */ + occurredAt: number; + method: PayMethod | null; + /** Origin of the row — drives drill-down (open the txn vs open the lesson). */ + source: 'txn' | 'lesson'; +} diff --git a/src/i18n/strings.ts b/src/i18n/strings.ts index cddd585..a64e2bf 100644 --- a/src/i18n/strings.ts +++ b/src/i18n/strings.ts @@ -297,6 +297,121 @@ export const messages = { 'notFound.title': 'Экран не найден', 'notFound.message': 'Такой страницы нет', 'notFound.action': 'На главную', + + // ══ Phase 2 · Финансы + Аналитика ════════════════════════════════════════ + + // ── Период (Финансы + Аналитика) ───────────────────────────────────────── + 'period.title': 'Период', + 'period.week': 'Неделя', + 'period.month': 'Месяц', + 'period.year': 'Год', + 'period.custom': 'Произвольный', + 'period.customTitle': 'Произвольный период', + 'period.pickDates': 'Выбрать даты', + 'period.pickHint': 'Выберите начальную и конечную дату периода', + 'period.apply': 'Применить', + 'period.selectStart': 'Выберите начальную дату', + 'period.selectYear': 'Выбрать год', + + // ── Способ оплаты (display) ────────────────────────────────────────────── + 'method.transfer': 'Перевод', + 'method.cash': 'Наличные', + 'method.card': 'Карта', + + // ── Финансы: список + сводка ───────────────────────────────────────────── + 'finance.tab.all': 'Все', + 'finance.tab.paid': 'Оплачено', + 'finance.tab.debts': 'Долги', + 'finance.tab.expected': 'Ожидается', + 'finance.searchOps': 'Поиск по операциям', + 'finance.found': 'Найдено', + 'finance.allOps': 'Все операции', + 'finance.nothingFound': 'Ничего не найдено', + 'finance.emptyTitle': 'Операций нет', + 'finance.noOpsPeriod': 'За выбранный период операций нет', + 'finance.noOpsTab': 'По выбранной вкладке операций нет', + + // ── Финансы: деталь операции ───────────────────────────────────────────── + 'finance.opTitle': 'Операция', + 'finance.status': 'Статус', + 'finance.method': 'Способ оплаты', + 'finance.markPaidCta': 'Отметить оплаченным', + 'finance.toPay': 'к оплате', + 'finance.amount': 'Сумма', + 'finance.payDate': 'Дата оплаты', + + // ── Финансы: новая операция ────────────────────────────────────────────── + 'finance.newOp': 'Новая операция', + 'finance.opType': 'Тип операции', + 'op.paid': 'Оплата', + 'op.debt': 'Долг', + 'finance.subject': 'Предмет', + 'finance.subject_client': 'Тема встречи', + 'finance.comment': 'Комментарий', + 'finance.optional': 'Необязательно', + 'finance.saveOp': 'Сохранить операцию', + 'finance.chooseStudent': 'Выберите ученика', + 'finance.chooseStudent_client': 'Выберите клиента', + + // ── Финансы: фильтр ────────────────────────────────────────────────────── + 'finance.show': 'Показать', + 'sort.byDate': 'По дате', + 'sort.byAmount': 'По сумме', + + // ── Аналитика: показатели + KPI ────────────────────────────────────────── + 'analytics.income': 'Доход', + 'analytics.lessons': 'Уроки', + 'analytics.lessons_client': 'Встречи', + 'analytics.debt': 'Долг', + 'analytics.kpiLessons': 'Уроков', + 'analytics.kpiLessons_client': 'Встреч', + 'analytics.kpiCancels': 'Отмены', + 'analytics.kpiAvgCheck': 'Средний чек', + 'analytics.shares': 'Доли направлений', + 'analytics.topicsShort': 'тем', + 'analytics.top': 'Топ направлений', + 'analytics.byWeeks': 'Уроки по неделям', + 'analytics.byWeeks_client': 'Встречи по неделям', + 'analytics.compareBtn': 'Сравнить', + 'analytics.currentPeriod': 'текущий период', + 'analytics.pastPeriod': 'прошлый период', + + // ── Аналитика: сравнение ───────────────────────────────────────────────── + 'analytics.comparison': 'Сравнение', + 'analytics.comparePeriod': 'Период сравнения', + 'analytics.vs': 'по сравнению с', + 'analytics.vsPrev': 'по сравнению с прошлым периодом', + 'analytics.noCompare': 'Нет данных для сравнения', + 'compare.prev': 'Предыдущий период', + 'compare.prevMonth': 'Прошлый месяц', + 'compare.prevYear': 'Прошлый год', + 'compare.custom': 'Произвольный период', + + // ── Аналитика: пусто / частично / задолженности ────────────────────────── + 'analytics.noDataHint': 'За выбранный период нет данных для аналитики. Выберите другой период.', + 'analytics.partial': 'Данные есть не за весь период', + 'analytics.debtors': 'Ученики с долгом', + 'analytics.debtors_client': 'Клиенты с долгом', + 'analytics.noDebts': 'Активных задолженностей нет', + 'analytics.allPaid': 'Все оплаты получены', + 'analytics.debtTitle': 'Задолженность', + + // ── Экспорт (ADR-0012: CSV сейчас, PDF/Excel «скоро») ──────────────────── + 'export.title': 'Экспорт отчёта', + 'export.format': 'Формат', + 'export.period': 'Период', + 'export.sections': 'Разделы', + 'export.income': 'Доходы', + 'export.lessons': 'Занятия', + 'export.lessons_client': 'Встречи', + 'export.debts': 'Задолженности', + 'export.generate': 'Сформировать отчёт', + 'export.soon': 'скоро', + 'export.done': 'Отчёт выгружен', + + // ── Финансы/Аналитика: общее ───────────────────────────────────────────── + 'common.reset': 'Сбросить', + 'group.today': 'Сегодня', } as const; export type Mode = 'tutor' | 'client'; diff --git a/src/lib/csv.ts b/src/lib/csv.ts new file mode 100644 index 0000000..770b712 --- /dev/null +++ b/src/lib/csv.ts @@ -0,0 +1,38 @@ +/** + * CSV export (ADR-0012) — Phase 2 ships CSV on web; PDF/Excel are deferred («скоро»). + * Pure serialization + a web-only saver (Blob → object URL → synthetic download). On + * native this is a no-op stub until file-system/share is wired (Phase 4/5). + */ +import { Platform } from 'react-native'; + +/** Quote a CSV cell per RFC 4180 — wrap in double quotes when it holds a delimiter/quote/newline. */ +function csvCell(value: string | number): string { + const s = String(value); + return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; +} + +/** Rows → CSV text (CRLF line breaks, Excel-friendly). First row is usually the header. */ +export function toCsv(rows: ReadonlyArray>): string { + return rows.map((r) => r.map(csvCell).join(',')).join('\r\n'); +} + +/** + * Trigger a CSV download in the browser (Blob + object URL + synthetic anchor click), with + * a UTF-8 BOM so Excel renders Cyrillic correctly. Returns `true` if a download started; + * web-only — native returns `false` (deferred, Phase 4/5). + */ +export function downloadCsv(filename: string, csv: string): boolean { + if (Platform.OS !== 'web' || typeof document === 'undefined') return false; + const name = filename.endsWith('.csv') ? filename : `${filename}.csv`; + const BOM = String.fromCharCode(0xfeff); // Excel reads UTF-8 (Cyrillic) correctly with a BOM + const blob = new Blob([BOM, csv], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = name; + document.body.appendChild(a); + a.click(); + a.remove(); + setTimeout(() => URL.revokeObjectURL(url), 0); // revoke after the click is processed + return true; +} diff --git a/src/lib/period.ts b/src/lib/period.ts new file mode 100644 index 0000000..43628e0 --- /dev/null +++ b/src/lib/period.ts @@ -0,0 +1,129 @@ +/** + * Period model (ADR-0012) — pure half-open ranges `[start, end)` in DEVICE-LOCAL time + * (profile.tz defaults to device; RU has no DST), shared by Finance & Analytics. NO + * translatable phrasing here — labels are composed in screens via i18n month/weekday + * keys; this module is numeric/structural only, like `time.ts`. + * + * Week is Monday-first (RU). Transactions bucket by `occurredAt`; derived expected/debt + * lessons bucket by `startsAt` (ADR-0012). `start` is the first instant (inclusive), + * `end` is the first instant of the NEXT period (exclusive). + */ + +export type PeriodType = 'week' | 'month' | 'year' | 'custom'; + +export interface Period { + type: PeriodType; + /** Inclusive local-instant ms of the first moment. */ + start: number; + /** Exclusive local-instant ms (start of the next period). */ + end: number; +} + +const DAY = 86_400_000; + +/** Mon-first weekday index (0=Mon … 6=Sun) for a JS getDay() (0=Sun … 6=Sat). */ +function monIndex(jsDay: number): number { + return (jsDay + 6) % 7; +} + +/** Local-midnight ms of the day containing `ms` (also the Finance day-group key). */ +export function startOfDay(ms: number): number { + const d = new Date(ms); + return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(); +} + +/** Week `[Mon 00:00, next Mon 00:00)` containing `ms`. */ +export function weekOf(ms: number): Period { + const dayStart = startOfDay(ms); + const mon = dayStart - monIndex(new Date(dayStart).getDay()) * DAY; + return { type: 'week', start: mon, end: mon + 7 * DAY }; +} + +/** Month `[1st 00:00, 1st of next month 00:00)` containing `ms`. */ +export function monthOf(ms: number): Period { + const d = new Date(ms); + return { + type: 'month', + start: new Date(d.getFullYear(), d.getMonth(), 1).getTime(), + end: new Date(d.getFullYear(), d.getMonth() + 1, 1).getTime(), + }; +} + +/** Year `[Jan 1 00:00, next Jan 1 00:00)` containing `ms`. */ +export function yearOf(ms: number): Period { + const d = new Date(ms); + return { + type: 'year', + start: new Date(d.getFullYear(), 0, 1).getTime(), + end: new Date(d.getFullYear() + 1, 0, 1).getTime(), + }; +} + +/** Custom range — from local-midnight of the earlier day to the END of the later day (inclusive days). */ +export function customRange(aMs: number, bMs: number): Period { + const lo = startOfDay(Math.min(aMs, bMs)); + const hi = startOfDay(Math.max(aMs, bMs)) + DAY; // exclusive end = next midnight + return { type: 'custom', start: lo, end: hi }; +} + +/** Period of the same TYPE as `p`, shifted by `dir` (±1). Custom is returned unchanged. */ +export function shiftPeriod(p: Period, dir: number): Period { + switch (p.type) { + case 'week': + return weekOf(p.start + dir * 7 * DAY); + case 'month': { + const d = new Date(p.start); + return monthOf(new Date(d.getFullYear(), d.getMonth() + dir, 1).getTime()); + } + case 'year': { + const d = new Date(p.start); + return yearOf(new Date(d.getFullYear() + dir, 0, 1).getTime()); + } + default: + return p; + } +} + +/** Whether `ms` falls inside the half-open `[start, end)`. */ +export function periodContains(p: Period, ms: number): boolean { + return ms >= p.start && ms < p.end; +} + +/** The default period — the current month (Finance/Analytics initial view). */ +export function currentMonth(now: number = Date.now()): Period { + return monthOf(now); +} + +/** + * Local-midnight ms of the 1st of each month spanning `[fromMs, toMs]` (inclusive of + * both endpoints' months) — bucket anchors for monthly analytics bars. + */ +export function monthStarts(fromMs: number, toMs: number): number[] { + const out: number[] = []; + const end = new Date(toMs); + let y = new Date(fromMs).getFullYear(); + let m = new Date(fromMs).getMonth(); + while (y < end.getFullYear() || (y === end.getFullYear() && m <= end.getMonth())) { + out.push(new Date(y, m, 1).getTime()); + m += 1; + if (m > 11) { + m = 0; + y += 1; + } + } + return out; +} + +/** + * Mon-first week-start ms for each ISO-ish week overlapping `[fromMs, toMs)` — bucket + * anchors for weekly analytics bars (Dynamics tab). + */ +export function weekStarts(fromMs: number, toMs: number): number[] { + const out: number[] = []; + let w = weekOf(fromMs).start; + while (w < toMs) { + out.push(w); + w += 7 * DAY; + } + return out; +}