feat(borç): borç takibi ekranları (Panel kartı, /borc, /taksitler)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-14 04:40:51 +03:00
parent 11402ba660
commit c063186e80
18 changed files with 1490 additions and 6 deletions
+171
View File
@@ -0,0 +1,171 @@
/**
* 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<InstallmentPlan, 'monthly_amount' | 'total_count' | 'first_month'>
/** `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<DebtSnapshot, 'date' | 'total'>[],
targets: readonly DebtTarget[],
): DebtPoint[] {
const map = new Map<string, DebtPoint>()
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<DebtSummary>('/debt') })
}
export function usePeriodDebt(periodId: string) {
return useQuery({
queryKey: queryKeys.periodDebt(periodId),
queryFn: () => api.get<PeriodDebt>(`/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<DebtSnapshot>(`/debt/snapshots/${id}`, body)
: api.post<DebtSnapshot>('/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<InstallmentPlan>(`/debt/installments/${id}`, body)
: api.post<InstallmentPlan>('/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<void>('/debt/targets', { targets }),
onSuccess: invalidate,
})
}
export function useDebtImport() {
const invalidate = useInvalidateDebt()
return useMutation({
mutationFn: ({ json, commit }: { json: unknown; commit: boolean }) =>
api.post<DebtImportReport>(`/debt/import?commit=${commit}`, json),
onSuccess: (_, v) => {
if (v.commit) void invalidate()
},
})
}