dbed3da1b1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
190 lines
6.4 KiB
TypeScript
190 lines
6.4 KiB
TypeScript
/**
|
||
* 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()
|
||
},
|
||
})
|
||
}
|