feat(birikim): dönem kapanışındaki birikimi varlıklara ekleme

- Kapatma sonucunda onayla "Birikime ekle" (TL alımı, dönemin son günü)
- Birikimler'de "Bekleyen birikimler" listesi
- Yeniden kapatmada bağlı kayıt yeni tutara güncellenir (0 → silinir), TL çıkışları aşılırsa reddedilir
- Panel "Toplam birikim" artık portföyün güncel değeri

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-13 01:27:07 +03:00
parent 6b7b69c987
commit 48554e0ff6
20 changed files with 634 additions and 9 deletions
+4 -3
View File
@@ -11,7 +11,8 @@ import { dailyAllowance } from '@/lib/dashboard'
import { formatDayMonth, formatMoney, toIsoDate } from '@/lib/format'
import { weekIndexFor } from '@/lib/ledger'
import { periodPath } from '@/lib/period-select'
import { useBudget, useStats, useSummary } from '@/lib/periods'
import { usePortfolio } from '@/lib/assets'
import { useBudget, useSummary } from '@/lib/periods'
import { t } from '@/lib/strings'
import { cn } from '@/lib/utils'
import type { GroupBudget } from '@/types/GroupBudget'
@@ -27,7 +28,7 @@ const DashboardCharts = lazy(() => import('./charts'))
export function Overview({ period, writable }: { period: Period; writable: boolean }) {
const summary = useSummary(period.id)
const budget = useBudget(period.id)
const stats = useStats()
const portfolio = usePortfolio()
const [today] = useState(() => toIsoDate(new Date()))
const failed = [summary, budget].find((q) => q.isError)
@@ -52,7 +53,7 @@ export function Overview({ period, writable }: { period: Period; writable: boole
<Tile
emoji="🐷"
label={s.saved}
amount={stats.data?.total_saved}
amount={portfolio.data?.value_total}
hint={s.savedHint}
tone="text-income"
/>
@@ -13,6 +13,7 @@ import { SelectedPeriod } from '@/features/periods/selected-period'
import { StepActions, StepIntro, Stepper } from '@/features/period-wizard/wizard-ui'
import { PeriodHeading } from '@/features/planning/plan-read'
import { formatDate, formatMoney, formatNumber, parseMoney, toIsoDate } from '@/lib/format'
import { useAddPeriodSavings } from '@/lib/assets'
import { periodPath } from '@/lib/period-select'
import { useClosePeriod, useReview } from '@/lib/periods'
import { settlePreview } from '@/lib/review'
@@ -308,6 +309,12 @@ function Result({ result: r }: { result: PeriodCloseResult }) {
<p className="text-muted-foreground">
{r.carryover ? (attached ? s.resultAttached : s.resultHint) : s.noCarry}
</p>
{r.to_savings > 0 &&
(r.savings_linked ? (
<p className="text-sm text-muted-foreground">{s.savingsUpdated}</p>
) : (
<AddToSavings periodId={r.period.id} amount={r.to_savings} />
))}
<div className="flex flex-col gap-2 sm:flex-row">
<Button asChild size="lg">
<Link to={attached ? paths.planning : paths.periodWizard}>
@@ -322,6 +329,39 @@ function Result({ result: r }: { result: PeriodCloseResult }) {
)
}
/** Kapanıştaki birikimi TL varlığına ekleme (onay = düğme) */
function AddToSavings({ periodId, amount }: { periodId: string; amount: number }) {
const add = useAddPeriodSavings()
const [error, setError] = useState<string>()
if (add.isSuccess) {
return (
<div className="flex flex-wrap items-center gap-3 rounded-xl bg-income/10 p-3 text-sm">
<CircleCheck className="size-5 text-income" aria-hidden />
<span className="font-medium">{s.addedToSavings}</span>
<Link to={paths.savings} className="underline">
{s.toSavingsPage}
</Link>
</div>
)
}
return (
<div className="space-y-2 rounded-xl border p-3">
<p className="text-sm text-muted-foreground">{s.addToSavingsHint}</p>
<Button
variant="outline"
disabled={add.isPending}
onClick={() => {
setError(undefined)
add.mutate(periodId, { onError: (e) => setError(e.message) })
}}
>
{s.addToSavings(formatMoney(amount))}
</Button>
<FormError message={error} />
</div>
)
}
// ---------- ortak ----------
/** Gerçekleşen birikim → birikime aktarılan + devreden (önizleme ve sonuçta aynı görünüm) */
+2
View File
@@ -12,6 +12,7 @@ import { cn } from '@/lib/utils'
import type { Portfolio } from '@/types/Portfolio'
import type { PortfolioItem } from '@/types/PortfolioItem'
import { AddCurrency } from './add-currency'
import { PendingSavings } from './pending'
import { Notice, Pl, RateLine } from './parts'
const s = t.assets
@@ -46,6 +47,7 @@ function SavingsView({ p }: { p: Portfolio }) {
return (
<div className="space-y-6">
<PendingSavings />
<Summary p={p} />
{p.last_fetch === null ? (
<Notice tone="warning">{s.noFetch}</Notice>
+51
View File
@@ -0,0 +1,51 @@
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { useAddPeriodSavings, usePendingSavings } from '@/lib/assets'
import { formatDate, formatMoney } from '@/lib/format'
import { t } from '@/lib/strings'
const s = t.assets
/** Kapanan dönemlerin henüz eklenmemiş birikimleri: tek dokunuşla TL alımı olarak ekle */
export function PendingSavings() {
const pending = usePendingSavings()
const add = useAddPeriodSavings()
if (!pending.data?.length) return null
return (
<section
aria-labelledby="pending-title"
className="rounded-2xl border-2 border-income/40 bg-income/5 p-4 sm:p-5"
>
<h2 id="pending-title" className="font-semibold">
<span aria-hidden>🐷</span> {s.pendingTitle}
</h2>
<p className="mb-2 text-sm text-muted-foreground">{s.pendingHint}</p>
<ul className="divide-y">
{pending.data.map((p) => (
<li key={p.period_id} className="flex items-center gap-3 py-2.5">
<div className="min-w-0 flex-1">
<p className="font-medium">
{p.name} · <span className="money">{formatMoney(p.to_savings)}</span>
</p>
<p className="text-xs text-muted-foreground">
{s.pendingLine(formatDate(p.last_date))}
</p>
</div>
<Button
disabled={add.isPending}
onClick={() =>
add.mutate(p.period_id, {
onSuccess: () => toast.success(s.added(p.name)),
onError: (e) => toast.error(e.message),
})
}
>
{s.add}
</Button>
</li>
))}
</ul>
</section>
)
}
+15
View File
@@ -9,6 +9,7 @@ import type { AssetTransaction } from '@/types/AssetTransaction'
import type { AssetTxCreateRequest } from '@/types/AssetTxCreateRequest'
import type { AssetTxUpdateRequest } from '@/types/AssetTxUpdateRequest'
import type { AssetUpdateRequest } from '@/types/AssetUpdateRequest'
import type { PendingSavings } from '@/types/PendingSavings'
import type { Portfolio } from '@/types/Portfolio'
import type { Rate } from '@/types/Rate'
import type { RateCreateRequest } from '@/types/RateCreateRequest'
@@ -63,3 +64,17 @@ export const useSetRate = () =>
useAssetMutation((body: RateCreateRequest) => api.post<Rate>('/rates', body))
export const useDeleteRate = () => useAssetMutation((id: string) => api.delete(`/rates/${id}`))
/** Kapanıp birikime henüz eklenmemiş dönemler */
export function usePendingSavings() {
return useQuery({
queryKey: queryKeys.pendingSavings,
queryFn: () => api.get<PendingSavings[]>('/savings/pending'),
})
}
/** Dönemin kapanıştaki birikimini TL varlığına ekle (dönem başına bir kez) */
export const useAddPeriodSavings = () =>
useAssetMutation((periodId: string) =>
api.post<AssetTransaction>(`/periods/${periodId}/to-savings`),
)
+2
View File
@@ -84,10 +84,12 @@ export function useClosePeriod() {
return useMutation({
mutationFn: ({ id, ...body }: PeriodCloseRequest & { id: string }) =>
api.post<PeriodCloseResult>(`/periods/${id}/close`, body),
// Yeniden kapatma birikimdeki bağlı kaydı da değiştirebilir → portföy de yenilenir
onSettled: () =>
Promise.all([
qc.invalidateQueries({ queryKey: queryKeys.periods }),
qc.invalidateQueries({ queryKey: ['period'] }),
qc.invalidateQueries({ queryKey: queryKeys.portfolio }),
]),
})
}
+1
View File
@@ -21,6 +21,7 @@ export const queryKeys = {
/** Birikimler: portföy ve varlık işlemleri bu önekle başlar (birlikte yenilenir) */
portfolio: ['portfolio'] as const,
assetTxs: (assetId: string) => ['portfolio', 'txs', assetId] as const,
pendingSavings: ['portfolio', 'pending'] as const,
period: (id: string) => ['period', id] as const,
planItems: (id: string) => ['period', id, 'plan-items'] as const,
summary: (id: string) => ['period', id, 'summary'] as const,
+12 -1
View File
@@ -356,7 +356,7 @@ export const t = {
income: 'Toplam gelir',
fixed: 'Sabit giderler',
saved: 'Toplam birikim',
savedHint: 'Kapanan dönemlerde birikime ayrılanlar',
savedHint: 'Birikimlerin güncel değeri',
debt: 'Devreden borç',
debtHint: 'Önceki dönemden; harcanabilirden düşüldü',
groupsTitle: 'Planlanan / gerçekleşen',
@@ -487,6 +487,11 @@ export const t = {
activate: 'Etkinleştir',
statusHint: 'Pasif varlık listede kalır ama yeni işlem alınmaz.',
notFound: 'Varlık bulunamadı.',
pendingTitle: 'Bekleyen birikimler',
pendingHint: 'Kapanan dönemlerde birikime ayırdığın ama buraya henüz eklenmemiş tutarlar.',
pendingLine: (date: string) => `${date} tarihli TL alımı olarak eklenir`,
add: 'Ekle',
added: (name: string) => `${name} birikimi eklendi`,
},
periods: {
@@ -535,6 +540,12 @@ export const t = {
toDashboard: 'Panele dön',
noOpen: 'Kapatılacak açık dönem yok.',
alreadyClosed: 'Bu dönem zaten kapalı.',
addToSavings: (x: string) => `🐷 Birikime ekle (${x})`,
addToSavingsHint:
'Onaylarsan bu tutar Birikimler’de, dönemin son günü tarihli Türk Lirası alımı olarak görünür.',
addedToSavings: 'Birikime eklendi',
toSavingsPage: 'Birikimlere git',
savingsUpdated: 'Birikimlerdeki kayıt yeni tutarla güncellendi.',
},
common: {
+11
View File
@@ -0,0 +1,11 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type PendingSavings = { period_id: string, name: string,
/**
* Dönemin son günü (eklenecek alımın tarihi)
*/
last_date: string,
/**
* Kuruş
*/
to_savings: number, };
+6 -1
View File
@@ -10,4 +10,9 @@ realized_savings: number, to_savings: number,
/**
* Sonraki döneme devreden; yoksa `null`
*/
carryover: Carryover | null, };
carryover: Carryover | null,
/**
* Birikimlerde bu döneme bağlı kayıt var (yeniden kapatmada yeni tutara güncellendi).
* `false` ve `to_savings > 0` → arayüz "Birikime ekle" önerir.
*/
savings_linked: boolean, };