feat(borç): borç takibi ekranları (Panel kartı, /borc, /taksitler)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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()
|
||||
},
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user