feat: add SavingsWidget + GitActivityWidget
All checks were successful
Build & Deploy Dashboard / deploy (push) Successful in 1m6s
All checks were successful
Build & Deploy Dashboard / deploy (push) Successful in 1m6s
This commit is contained in:
@@ -3,13 +3,23 @@ export const dynamic = "force-dynamic";
|
|||||||
import { WeatherWidget } from "@/components/widgets/WeatherWidget";
|
import { WeatherWidget } from "@/components/widgets/WeatherWidget";
|
||||||
import { CalendarWidget } from "@/components/widgets/CalendarWidget";
|
import { CalendarWidget } from "@/components/widgets/CalendarWidget";
|
||||||
import { DashboardHeader } from "@/components/widgets/DashboardHeader";
|
import { DashboardHeader } from "@/components/widgets/DashboardHeader";
|
||||||
|
import { SavingsWidget } from "@/components/widgets/SavingsWidget";
|
||||||
|
import { GitActivityWidget } from "@/components/widgets/GitActivityWidget";
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<DashboardHeader />
|
<DashboardHeader />
|
||||||
<WeatherWidget />
|
<WeatherWidget />
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-5">
|
||||||
|
<div className="lg:col-span-2">
|
||||||
<CalendarWidget />
|
<CalendarWidget />
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-5">
|
||||||
|
<SavingsWidget />
|
||||||
|
<GitActivityWidget />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
53
src/app/api/git-activity/route.ts
Normal file
53
src/app/api/git-activity/route.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
const GITEA_URL = "https://git.digital-home.site";
|
||||||
|
const GITEA_TOKEN = "f7a12c4f58e0799ac119243d0d95f4551c5be8b1";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
// Получить репозитории
|
||||||
|
const reposRes = await fetch(
|
||||||
|
`${GITEA_URL}/api/v1/repos/search?limit=20&sort=updated&token=${GITEA_TOKEN}`,
|
||||||
|
{ signal: AbortSignal.timeout(8000) }
|
||||||
|
);
|
||||||
|
const reposData = await reposRes.json();
|
||||||
|
const repos = reposData.data || [];
|
||||||
|
|
||||||
|
// Получить последние коммиты из топ-5 активных репо
|
||||||
|
const topRepos = repos
|
||||||
|
.filter((r: any) => !r.archived && !r.empty)
|
||||||
|
.slice(0, 8);
|
||||||
|
|
||||||
|
const commits = await Promise.allSettled(
|
||||||
|
topRepos.map(async (repo: any) => {
|
||||||
|
const res = await fetch(
|
||||||
|
`${GITEA_URL}/api/v1/repos/${repo.full_name}/commits?limit=3&token=${GITEA_TOKEN}`,
|
||||||
|
{ signal: AbortSignal.timeout(5000) }
|
||||||
|
);
|
||||||
|
const data = await res.json();
|
||||||
|
return {
|
||||||
|
repo: repo.name,
|
||||||
|
full_name: repo.full_name,
|
||||||
|
commits: (Array.isArray(data) ? data : []).map((c: any) => ({
|
||||||
|
sha: c.sha?.slice(0, 7),
|
||||||
|
message: c.commit?.message?.split("\n")[0]?.slice(0, 60),
|
||||||
|
author: c.commit?.author?.name,
|
||||||
|
date: c.commit?.author?.date,
|
||||||
|
})),
|
||||||
|
updated_at: repo.updated_at,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = commits
|
||||||
|
.filter(r => r.status === "fulfilled" && (r.value as any).commits.length > 0)
|
||||||
|
.map(r => (r as any).value)
|
||||||
|
.sort((a: any, b: any) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime())
|
||||||
|
.slice(0, 6);
|
||||||
|
|
||||||
|
return NextResponse.json({ repos: result });
|
||||||
|
} catch (e) {
|
||||||
|
return NextResponse.json({ error: String(e), repos: [] }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
54
src/app/api/savings/route.ts
Normal file
54
src/app/api/savings/route.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
const PULSE_REFRESH_TOKEN = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE4MDIwMjY5NDgsImlhdCI6MTc3MDQ5MDk0OCwidHlwZSI6InJlZnJlc2giLCJ1c2VyX2lkIjoxfQ.zPJJB7o9vtnfIBFl7rNygEEXd9h-5YZeAxRIvWcRlXY";
|
||||||
|
const PULSE_API = "https://api.digital-home.site";
|
||||||
|
|
||||||
|
async function getAccessToken(): Promise<string> {
|
||||||
|
const res = await fetch(`${PULSE_API}/auth/refresh`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ refresh_token: PULSE_REFRESH_TOKEN }),
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data.access_token) throw new Error("Failed to get token");
|
||||||
|
return data.access_token;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const token = await getAccessToken();
|
||||||
|
|
||||||
|
const res = await fetch(`${PULSE_API}/finance/transactions?limit=200&type=expense`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
signal: AbortSignal.timeout(8000),
|
||||||
|
});
|
||||||
|
const txs = await res.json();
|
||||||
|
|
||||||
|
// Группируем накопления по цели
|
||||||
|
const groups: Record<string, number> = {};
|
||||||
|
for (const t of txs) {
|
||||||
|
if (t.category_emoji === "💎") {
|
||||||
|
// Нормализуем название
|
||||||
|
const desc = t.description as string;
|
||||||
|
let key = "Другое";
|
||||||
|
if (desc.toLowerCase().includes("квартир")) key = "🏠 Квартира";
|
||||||
|
else if (desc.toLowerCase().includes("отпуск") || desc.toLowerCase().includes("путешеств")) key = "✈️ Отпуск";
|
||||||
|
else if (desc.toLowerCase().includes("машин") || desc.toLowerCase().includes("авто")) key = "🚗 Машина";
|
||||||
|
else key = desc;
|
||||||
|
groups[key] = (groups[key] || 0) + (t.amount as number);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const goals = Object.entries(groups)
|
||||||
|
.map(([name, amount]) => ({ name, amount }))
|
||||||
|
.sort((a, b) => b.amount - a.amount);
|
||||||
|
|
||||||
|
const total = goals.reduce((s, g) => s + g.amount, 0);
|
||||||
|
|
||||||
|
return NextResponse.json({ goals, total });
|
||||||
|
} catch (e) {
|
||||||
|
return NextResponse.json({ error: String(e), goals: [], total: 0 }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
118
src/components/widgets/GitActivityWidget.tsx
Normal file
118
src/components/widgets/GitActivityWidget.tsx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
"use client";
|
||||||
|
import { useEffect, useState, useCallback } from "react";
|
||||||
|
import { RefreshCw, GitCommit, ExternalLink } from "lucide-react";
|
||||||
|
|
||||||
|
interface Commit {
|
||||||
|
sha: string;
|
||||||
|
message: string;
|
||||||
|
author: string;
|
||||||
|
date: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RepoActivity {
|
||||||
|
repo: string;
|
||||||
|
full_name: string;
|
||||||
|
commits: Commit[];
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GitData {
|
||||||
|
repos: RepoActivity[];
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeAgo(dateStr: string): string {
|
||||||
|
const diff = Date.now() - new Date(dateStr).getTime();
|
||||||
|
const mins = Math.floor(diff / 60000);
|
||||||
|
if (mins < 60) return `${mins}м назад`;
|
||||||
|
const hours = Math.floor(mins / 60);
|
||||||
|
if (hours < 24) return `${hours}ч назад`;
|
||||||
|
return `${Math.floor(hours / 24)}д назад`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GitActivityWidget() {
|
||||||
|
const [data, setData] = useState<GitData | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [expanded, setExpanded] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/git-activity", { cache: "no-store" });
|
||||||
|
setData(await res.json());
|
||||||
|
} catch {
|
||||||
|
setData({ repos: [], error: "Ошибка загрузки" });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { fetchData(); }, [fetchData]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card p-5 space-y-4" style={{ borderTop: "2px solid #f59e0b" }}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-7 h-7 rounded-lg flex items-center justify-center" style={{ background: "rgba(245,158,11,0.15)" }}>
|
||||||
|
<GitCommit className="w-4 h-4" style={{ color: "#f59e0b" }} />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium text-slate-300">Git активность</span>
|
||||||
|
</div>
|
||||||
|
<button onClick={fetchData} className="text-slate-600 hover:text-slate-300 transition-colors">
|
||||||
|
<RefreshCw className={`w-3.5 h-3.5 ${loading ? "animate-spin" : ""}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="space-y-2 animate-pulse">
|
||||||
|
{[1,2,3,4].map(i => <div key={i} className="h-10 bg-white/5 rounded-xl" />)}
|
||||||
|
</div>
|
||||||
|
) : data?.error ? (
|
||||||
|
<div className="text-xs text-slate-600 text-center py-4">{data.error}</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{(data?.repos || []).map(repo => (
|
||||||
|
<div key={repo.repo}>
|
||||||
|
<button
|
||||||
|
onClick={() => setExpanded(expanded === repo.repo ? null : repo.repo)}
|
||||||
|
className="w-full flex items-center justify-between px-3 py-2.5 rounded-xl transition-all hover:bg-white/5 text-left"
|
||||||
|
style={{ background: expanded === repo.repo ? "rgba(245,158,11,0.08)" : undefined }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<span className="text-sm font-medium text-slate-200 truncate">{repo.repo}</span>
|
||||||
|
<span className="text-xs text-slate-600 flex-shrink-0">{repo.commits.length} коммит{repo.commits.length > 1 ? "а" : ""}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
|
<span className="text-xs text-slate-600">{timeAgo(repo.updated_at)}</span>
|
||||||
|
<a
|
||||||
|
href={`https://git.digital-home.site/${repo.full_name}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
className="text-slate-700 hover:text-amber-400 transition-colors"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-3 h-3" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{expanded === repo.repo && (
|
||||||
|
<div className="mx-2 mb-1 space-y-1">
|
||||||
|
{repo.commits.map(c => (
|
||||||
|
<div key={c.sha} className="flex items-start gap-2 px-3 py-2 rounded-lg" style={{ background: "rgba(255,255,255,0.03)" }}>
|
||||||
|
<span className="text-xs font-mono text-amber-600 flex-shrink-0 mt-0.5">{c.sha}</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-xs text-slate-300 truncate">{c.message}</div>
|
||||||
|
<div className="text-[10px] text-slate-600 mt-0.5">{c.author} · {timeAgo(c.date)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
107
src/components/widgets/SavingsWidget.tsx
Normal file
107
src/components/widgets/SavingsWidget.tsx
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
"use client";
|
||||||
|
import { useEffect, useState, useCallback } from "react";
|
||||||
|
import { RefreshCw, PiggyBank } from "lucide-react";
|
||||||
|
|
||||||
|
interface Goal {
|
||||||
|
name: string;
|
||||||
|
amount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SavingsData {
|
||||||
|
goals: Goal[];
|
||||||
|
total: number;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMoney(n: number): string {
|
||||||
|
return n.toLocaleString("ru-RU") + " ₽";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Целевые суммы (максимум для прогресс-бара)
|
||||||
|
const TARGETS: Record<string, number> = {
|
||||||
|
"🏠 Квартира": 500000,
|
||||||
|
"✈️ Отпуск": 200000,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SavingsWidget() {
|
||||||
|
const [data, setData] = useState<SavingsData | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/savings", { cache: "no-store" });
|
||||||
|
setData(await res.json());
|
||||||
|
} catch {
|
||||||
|
setData({ goals: [], total: 0, error: "Ошибка загрузки" });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { fetchData(); }, [fetchData]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card p-5 space-y-4" style={{ borderTop: "2px solid #10b981" }}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<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)" }}>
|
||||||
|
<PiggyBank className="w-4 h-4" style={{ color: "#10b981" }} />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium text-slate-300">Накопления</span>
|
||||||
|
</div>
|
||||||
|
<button onClick={fetchData} className="text-slate-600 hover:text-slate-300 transition-colors">
|
||||||
|
<RefreshCw className={`w-3.5 h-3.5 ${loading ? "animate-spin" : ""}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="space-y-3 animate-pulse">
|
||||||
|
{[1, 2].map(i => <div key={i} className="h-12 bg-white/5 rounded-xl" />)}
|
||||||
|
</div>
|
||||||
|
) : data?.error ? (
|
||||||
|
<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)" }}>
|
||||||
|
<span className="text-xs text-slate-400">Итого накоплено</span>
|
||||||
|
<span className="text-base font-bold text-white">{formatMoney(data?.total || 0)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Goals */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
{(data?.goals || []).map(goal => {
|
||||||
|
const target = TARGETS[goal.name];
|
||||||
|
const pct = target ? Math.min(100, (goal.amount / target) * 100) : null;
|
||||||
|
const color = goal.name.includes("Квартир") ? "#6366f1" :
|
||||||
|
goal.name.includes("Отпуск") ? "#f59e0b" : "#10b981";
|
||||||
|
return (
|
||||||
|
<div key={goal.name} className="space-y-1.5">
|
||||||
|
<div className="flex justify-between text-xs">
|
||||||
|
<span className="text-slate-300">{goal.name}</span>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-white font-medium">{formatMoney(goal.amount)}</span>
|
||||||
|
{target && <span className="text-slate-600">/ {formatMoney(target)}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{pct !== null && (
|
||||||
|
<div className="h-1.5 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, boxShadow: `0 0 8px ${color}50` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{target && (
|
||||||
|
<div className="text-[10px] text-slate-600 text-right">{pct?.toFixed(0)}% от цели</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user