fix: savings widget uses /savings/categories API directly
All checks were successful
Build & Deploy Dashboard / deploy (push) Successful in 1m2s
All checks were successful
Build & Deploy Dashboard / deploy (push) Successful in 1m2s
This commit is contained in:
@@ -19,36 +19,32 @@ async function getAccessToken(): Promise<string> {
|
|||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
const token = await getAccessToken();
|
const token = await getAccessToken();
|
||||||
|
|
||||||
const res = await fetch(`${PULSE_API}/finance/transactions?limit=200&type=expense`, {
|
const res = await fetch(`${PULSE_API}/savings/categories`, {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
signal: AbortSignal.timeout(8000),
|
signal: AbortSignal.timeout(8000),
|
||||||
});
|
});
|
||||||
const txs = await res.json();
|
const categories = await res.json();
|
||||||
|
|
||||||
// Группируем накопления по цели
|
const items = (Array.isArray(categories) ? categories : [])
|
||||||
const groups: Record<string, number> = {};
|
.filter((c: any) => !c.is_closed && c.current_amount > 0)
|
||||||
for (const t of txs) {
|
.map((c: any) => ({
|
||||||
if (t.category_emoji === "💎") {
|
id: c.id,
|
||||||
// Нормализуем название
|
name: c.name,
|
||||||
const desc = t.description as string;
|
amount: c.current_amount as number,
|
||||||
let key = "Другое";
|
isDeposit: c.is_deposit as boolean,
|
||||||
if (desc.toLowerCase().includes("квартир")) key = "🏠 Квартира";
|
isAccount: c.is_account as boolean,
|
||||||
else if (desc.toLowerCase().includes("отпуск") || desc.toLowerCase().includes("путешеств")) key = "✈️ Отпуск";
|
isMulti: c.is_multi as boolean,
|
||||||
else if (desc.toLowerCase().includes("машин") || desc.toLowerCase().includes("авто")) key = "🚗 Машина";
|
interestRate: c.interest_rate as number,
|
||||||
else key = desc;
|
depositEndDate: c.deposit_end_date as string | null,
|
||||||
groups[key] = (groups[key] || 0) + (t.amount as number);
|
targetAmount: c.final_amount > 0 ? c.final_amount : null,
|
||||||
}
|
}))
|
||||||
}
|
.sort((a: any, b: any) => b.amount - a.amount);
|
||||||
|
|
||||||
const goals = Object.entries(groups)
|
const total = items.reduce((s: number, i: any) => s + i.amount, 0);
|
||||||
.map(([name, amount]) => ({ name, amount }))
|
|
||||||
.sort((a, b) => b.amount - a.amount);
|
return NextResponse.json({ items, total });
|
||||||
|
|
||||||
const total = goals.reduce((s, g) => s + g.amount, 0);
|
|
||||||
|
|
||||||
return NextResponse.json({ goals, total });
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return NextResponse.json({ error: String(e), goals: [], total: 0 }, { status: 500 });
|
return NextResponse.json({ error: String(e), items: [], total: 0 }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,48 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { useEffect, useState, useCallback } from "react";
|
import { useEffect, useState, useCallback } from "react";
|
||||||
import { RefreshCw, PiggyBank } from "lucide-react";
|
import { RefreshCw, TrendingUp, Wallet, PiggyBank } from "lucide-react";
|
||||||
|
|
||||||
interface Goal {
|
interface SavingsItem {
|
||||||
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
amount: number;
|
amount: number;
|
||||||
|
isDeposit: boolean;
|
||||||
|
isAccount: boolean;
|
||||||
|
isMulti: boolean;
|
||||||
|
interestRate: number;
|
||||||
|
depositEndDate: string | null;
|
||||||
|
targetAmount: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SavingsData {
|
interface SavingsData {
|
||||||
goals: Goal[];
|
items: SavingsItem[];
|
||||||
total: number;
|
total: number;
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatMoney(n: number): string {
|
function formatMoney(n: number): string {
|
||||||
|
if (n >= 1_000_000) return (n / 1_000_000).toFixed(2).replace(/\.?0+$/, "") + " М ₽";
|
||||||
return n.toLocaleString("ru-RU") + " ₽";
|
return n.toLocaleString("ru-RU") + " ₽";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Целевые суммы (максимум для прогресс-бара)
|
function getIcon(item: SavingsItem) {
|
||||||
const TARGETS: Record<string, number> = {
|
if (item.isDeposit) return "🏦";
|
||||||
"🏠 Квартира": 500000,
|
if (item.isAccount) return "💳";
|
||||||
"✈️ Отпуск": 200000,
|
return "🐷";
|
||||||
};
|
}
|
||||||
|
|
||||||
|
function getColor(item: SavingsItem, index: number): string {
|
||||||
|
const colors = ["#6366f1", "#f59e0b", "#10b981", "#06b6d4", "#8b5cf6", "#f43f5e"];
|
||||||
|
if (item.isDeposit) return "#f59e0b";
|
||||||
|
if (item.isAccount) return "#06b6d4";
|
||||||
|
return colors[index % colors.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBadge(item: SavingsItem): string | null {
|
||||||
|
if (item.isDeposit && item.interestRate > 0) return `${item.interestRate}%`;
|
||||||
|
if (item.isMulti) return "общая";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export function SavingsWidget() {
|
export function SavingsWidget() {
|
||||||
const [data, setData] = useState<SavingsData | null>(null);
|
const [data, setData] = useState<SavingsData | null>(null);
|
||||||
@@ -33,7 +54,7 @@ export function SavingsWidget() {
|
|||||||
const res = await fetch("/api/savings", { cache: "no-store" });
|
const res = await fetch("/api/savings", { cache: "no-store" });
|
||||||
setData(await res.json());
|
setData(await res.json());
|
||||||
} catch {
|
} catch {
|
||||||
setData({ goals: [], total: 0, error: "Ошибка загрузки" });
|
setData({ items: [], total: 0, error: "Ошибка загрузки" });
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -43,6 +64,7 @@ export function SavingsWidget() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card p-5 space-y-4" style={{ borderTop: "2px solid #10b981" }}>
|
<div className="card p-5 space-y-4" style={{ borderTop: "2px solid #10b981" }}>
|
||||||
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-7 h-7 rounded-lg flex items-center justify-center" style={{ background: "rgba(16,185,129,0.15)" }}>
|
<div className="w-7 h-7 rounded-lg flex items-center justify-center" style={{ background: "rgba(16,185,129,0.15)" }}>
|
||||||
@@ -56,46 +78,64 @@ export function SavingsWidget() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="space-y-3 animate-pulse">
|
<div className="space-y-2 animate-pulse">
|
||||||
{[1, 2].map(i => <div key={i} className="h-12 bg-white/5 rounded-xl" />)}
|
{[1,2,3,4].map(i => <div key={i} className="h-10 bg-white/5 rounded-xl" />)}
|
||||||
</div>
|
</div>
|
||||||
) : data?.error ? (
|
) : data?.error ? (
|
||||||
<div className="text-xs text-slate-600 text-center py-4">{data.error}</div>
|
<div className="text-xs text-slate-600 text-center py-4">{data.error}</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Total */}
|
{/* Итого */}
|
||||||
<div className="flex justify-between items-center py-2 px-3 rounded-xl" style={{ background: "rgba(16,185,129,0.08)" }}>
|
<div className="flex justify-between items-center py-2 px-3 rounded-xl" style={{ background: "rgba(16,185,129,0.08)" }}>
|
||||||
<span className="text-xs text-slate-400">Итого накоплено</span>
|
<span className="text-xs text-slate-400">Всего накоплено</span>
|
||||||
<span className="text-base font-bold text-white">{formatMoney(data?.total || 0)}</span>
|
<span className="text-base font-bold text-white">{formatMoney(data?.total || 0)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Goals */}
|
{/* Категории */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-2">
|
||||||
{(data?.goals || []).map(goal => {
|
{(data?.items || []).map((item, i) => {
|
||||||
const target = TARGETS[goal.name];
|
const color = getColor(item, i);
|
||||||
const pct = target ? Math.min(100, (goal.amount / target) * 100) : null;
|
const badge = getBadge(item);
|
||||||
const color = goal.name.includes("Квартир") ? "#6366f1" :
|
const pct = item.targetAmount ? Math.min(100, (item.amount / item.targetAmount) * 100) : null;
|
||||||
goal.name.includes("Отпуск") ? "#f59e0b" : "#10b981";
|
|
||||||
return (
|
return (
|
||||||
<div key={goal.name} className="space-y-1.5">
|
<div key={item.id} className="flex items-center gap-3 px-3 py-2.5 rounded-xl transition-colors hover:bg-white/3">
|
||||||
<div className="flex justify-between text-xs">
|
{/* Icon */}
|
||||||
<span className="text-slate-300">{goal.name}</span>
|
<div className="w-8 h-8 rounded-lg flex items-center justify-center text-base flex-shrink-0"
|
||||||
<div className="flex items-center gap-1.5">
|
style={{ background: `${color}18` }}>
|
||||||
<span className="text-white font-medium">{formatMoney(goal.amount)}</span>
|
{getIcon(item)}
|
||||||
{target && <span className="text-slate-600">/ {formatMoney(target)}</span>}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{pct !== null && (
|
|
||||||
<div className="h-1.5 rounded-full" style={{ background: "rgba(255,255,255,0.06)" }}>
|
{/* Name + badge */}
|
||||||
<div
|
<div className="flex-1 min-w-0">
|
||||||
className="h-full rounded-full transition-all duration-700"
|
<div className="flex items-center gap-1.5">
|
||||||
style={{ width: `${pct}%`, background: color, boxShadow: `0 0 8px ${color}50` }}
|
<span className="text-sm text-slate-200 truncate">{item.name}</span>
|
||||||
/>
|
{badge && (
|
||||||
|
<span className="text-[10px] px-1.5 py-0.5 rounded-md flex-shrink-0"
|
||||||
|
style={{ background: `${color}20`, color }}>
|
||||||
|
{badge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
{/* Прогресс-бар если есть цель */}
|
||||||
{target && (
|
{pct !== null && (
|
||||||
<div className="text-[10px] text-slate-600 text-right">{pct?.toFixed(0)}% от цели</div>
|
<div className="mt-1 h-1 rounded-full" style={{ background: "rgba(255,255,255,0.06)" }}>
|
||||||
)}
|
<div className="h-full rounded-full transition-all duration-700"
|
||||||
|
style={{ width: `${pct}%`, background: color }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Дата окончания вклада */}
|
||||||
|
{item.depositEndDate && (
|
||||||
|
<div className="text-[10px] text-slate-600 mt-0.5">
|
||||||
|
до {new Date(item.depositEndDate).toLocaleDateString("ru-RU", { day: "numeric", month: "short", year: "numeric" })}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Amount */}
|
||||||
|
<span className="text-sm font-semibold text-white flex-shrink-0">
|
||||||
|
{formatMoney(item.amount)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
Reference in New Issue
Block a user