/** * Borç takibi (docs/BORC_TAKIBI.md): sorgu kancaları ve saf hesaplar. * Hesaplar backend `services/debt.rs` ile aynı tutulmalı (form önizlemesi, grafik). */ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import type { DebtImportReport } from '@/types/DebtImportReport' import type { DebtSnapshot } from '@/types/DebtSnapshot' import type { DebtSnapshotRequest } from '@/types/DebtSnapshotRequest' import type { DebtSummary } from '@/types/DebtSummary' import type { DebtTarget } from '@/types/DebtTarget' import type { InstallmentPlan } from '@/types/InstallmentPlan' import type { InstallmentRequest } from '@/types/InstallmentRequest' import type { PeriodDebt } from '@/types/PeriodDebt' import { api } from './api' import { queryKeys } from './query' // ---------- Saf hesaplar ---------- /** KMH + kart + kalan taksit − artı bakiye */ export function debtTotal(x: { kmh: number card_balance: number remaining_installments: number positive_balance: number }): number { return x.kmh + x.card_balance + x.remaining_installments - x.positive_balance } /** `"2026-11"` → ay sırası; geçersizse `null` */ export function monthIndex(m: string): number | null { const r = /^(\d{4})-(\d{2})$/.exec(m) if (!r) return null const mo = Number(r[2]) return mo >= 1 && mo <= 12 ? Number(r[1]) * 12 + mo - 1 : null } export function monthName(i: number): string { return `${Math.floor(i / 12)}-${String((i % 12) + 1).padStart(2, '0')}` } type PlanShape = Pick /** `month`'tan sonra ekstreye gelecek taksitler (ölçümdeki "kalan taksit" önerisi) */ export function remainingAfter(plans: readonly PlanShape[], month: number): number { return plans.reduce((sum, p) => { const first = monthIndex(p.first_month) if (first === null) return sum const paid = Math.min(Math.max(month - first + 1, 0), p.total_count) return sum + (p.total_count - paid) * p.monthly_amount }, 0) } /** Ayın taksit yükü */ export function installmentLoad(plans: readonly PlanShape[], month: number): number { return plans.reduce((sum, p) => { const first = monthIndex(p.first_month) if (first === null) return sum return month >= first && month <= first + p.total_count - 1 ? sum + p.monthly_amount : sum }, 0) } /** Ayın son günü `"YYYY-MM-DD"` (hedef noktaları grafikte ay sonuna konur) */ export function monthEnd(ym: string): string { const i = monthIndex(ym) if (i === null) return ym const d = new Date(Date.UTC(Math.floor(i / 12), (i % 12) + 1, 0)) return d.toISOString().slice(0, 10) } export type DebtPoint = { t: number; date: string; actual?: number; target?: number } /** * Grafik: ölçümler (gerçek) + yol haritası (hedef, ay sonları). Hedef çizgisi ilk ölçümden * başlar ki başlangıçla bağlansın. Zaman ekseni gün (ms). */ export function debtSeries( snapshots: readonly Pick[], targets: readonly DebtTarget[], ): DebtPoint[] { const map = new Map() const at = (date: string) => { let p = map.get(date) if (!p) map.set(date, (p = { t: Date.parse(`${date}T00:00:00Z`), date })) return p } const sorted = [...snapshots].sort((a, b) => a.date.localeCompare(b.date)) for (const s of sorted) at(s.date).actual = s.total if (sorted.length > 0 && targets.length > 0) at(sorted[0].date).target = sorted[0].total for (const tg of targets) at(monthEnd(tg.month)).target = tg.closing_target return [...map.values()].sort((a, b) => a.t - b.t) } // ---------- Sorgular ---------- export function useDebt() { return useQuery({ queryKey: queryKeys.debt, queryFn: () => api.get('/debt') }) } export function usePeriodDebt(periodId: string) { return useQuery({ queryKey: queryKeys.periodDebt(periodId), queryFn: () => api.get(`/periods/${periodId}/debt`), }) } /** Borç verisi değişince özet ve dönem borçları yenilenir */ function useInvalidateDebt() { const qc = useQueryClient() return () => Promise.all([ qc.invalidateQueries({ queryKey: queryKeys.debt }), qc.invalidateQueries({ queryKey: ['period'] }), ]) } export function useSaveSnapshot() { const invalidate = useInvalidateDebt() return useMutation({ mutationFn: ({ id, ...body }: DebtSnapshotRequest & { id?: string }) => id ? api.patch(`/debt/snapshots/${id}`, body) : api.post('/debt/snapshots', body), onSuccess: invalidate, }) } export function useDeleteSnapshot() { const invalidate = useInvalidateDebt() return useMutation({ mutationFn: (id: string) => api.delete(`/debt/snapshots/${id}`), onSettled: invalidate, }) } export function useSaveInstallment() { const invalidate = useInvalidateDebt() return useMutation({ mutationFn: ({ id, ...body }: InstallmentRequest & { id?: string }) => id ? api.patch(`/debt/installments/${id}`, body) : api.post('/debt/installments', body), onSuccess: invalidate, }) } export function useDeleteInstallment() { const invalidate = useInvalidateDebt() return useMutation({ mutationFn: (id: string) => api.delete(`/debt/installments/${id}`), onSettled: invalidate, }) } export function useSetTargets() { const invalidate = useInvalidateDebt() return useMutation({ mutationFn: (targets: DebtTarget[]) => api.put('/debt/targets', { targets }), onSuccess: invalidate, }) } export function useSetOpeningDebt(periodId: string) { const invalidate = useInvalidateDebt() return useMutation({ mutationFn: (amount: number | null) => api.put(`/periods/${periodId}/opening-debt`, { amount }), onSuccess: invalidate, }) } /** Plan kalemlerinde "Taksit(ler)" adlı sabit gider (büyük/küçük harf ve Türkçe harf duyarsız) */ export function findInstallmentItem( items: readonly T[], ): T | undefined { return items.find( (i) => i.kind === 'expense' && /^taks[iı]t(ler|lar)?$/.test(i.name.trim().toLocaleLowerCase('tr')), ) } export function useDebtImport() { const invalidate = useInvalidateDebt() return useMutation({ mutationFn: ({ json, commit }: { json: unknown; commit: boolean }) => api.post(`/debt/import?commit=${commit}`, json), onSuccess: (_, v) => { if (v.commit) void invalidate() }, }) }