Files
kakeibo/frontend/src/lib/debt.ts
T

190 lines
6.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 useSetOpeningDebt(periodId: string) {
const invalidate = useInvalidateDebt()
return useMutation({
mutationFn: (amount: number | null) =>
api.put<void>(`/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<T extends { kind: string; name: string }>(
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<DebtImportReport>(`/debt/import?commit=${commit}`, json),
onSuccess: (_, v) => {
if (v.commit) void invalidate()
},
})
}