feat(ui): add theme-aware stats and finance carousel

This commit is contained in:
poyrazavsever
2026-07-17 21:56:52 +03:00
parent cb2ed90046
commit 49c4c370c8
7 changed files with 253 additions and 161 deletions
+5 -24
View File
@@ -40,7 +40,6 @@ import {
Wallet,
Clock,
ArrowRight,
type LucideIcon,
} from "lucide-react";
import Link from "next/link";
import { useState } from "react";
@@ -48,6 +47,7 @@ import { format, isPast, isToday } from "date-fns";
import { tr } from "date-fns/locale";
import { useEffect } from "react";
import { cn } from "@/lib/utils";
import { StatCard } from "@/components/system/stat-card";
export type ClientListItem = {
id: string;
@@ -180,26 +180,26 @@ export function ClientsClient({
label="Potansiyel (Lead)"
value={clients.filter(c => c.pipeline_stage === 'lead' || c.pipeline_stage === 'contacted').length.toString()}
icon={Users}
iconClassName="bg-blue-50 text-blue-700"
tone="blue"
/>
<StatCard
label="Aktif Müşteri"
value={activeCount.toString()}
icon={UserCheck}
iconClassName="bg-emerald-50 text-emerald-700"
tone="green"
/>
<StatCard
label="Bekleyen Follow-up"
value={clients.filter(c => c.next_follow_up_date && (isPast(new Date(c.next_follow_up_date)) || isToday(new Date(c.next_follow_up_date)))).length.toString()}
icon={Clock}
iconClassName="bg-rose-50 text-rose-700"
tone="rose"
/>
<StatCard
label="Kayıtlı Gelir"
value={formatCurrency(totalRevenue)}
description="Ödenmiş gelir işlemleri"
icon={Wallet}
iconClassName="bg-primary/10 text-primary"
tone="primary"
/>
</div>
@@ -613,25 +613,6 @@ function PhoneInput({ id, name, defaultValue }: { id: string; name: string; defa
);
}
function StatCard({ label, value, description, icon: Icon, iconClassName }: { label: string; value: string; description?: string; icon: LucideIcon; iconClassName: string; }) {
return (
<Card>
<CardContent className="p-4">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm text-muted-foreground">{label}</p>
<p className="mt-1 text-2xl font-semibold text-foreground">{value}</p>
{description ? <p className="mt-1 text-xs text-muted-foreground">{description}</p> : null}
</div>
<div className={`flex h-10 w-10 items-center justify-center rounded-sm ${iconClassName}`}>
<Icon className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
);
}
function EmptyState({ hasQuery }: { hasQuery: boolean }) {
return (
<div className="flex min-h-72 flex-col items-center justify-center rounded-sm border border-dashed border-border bg-muted/20 p-8 text-center">
+1 -33
View File
@@ -2,6 +2,7 @@
import { useRouter, usePathname, useSearchParams } from "next/navigation";
import { PendingLink } from "@/components/ui/pending-link";
import { StatCard } from "@/components/system/stat-card";
import { Badge, Card, CardContent } from "poyraz-ui/atoms";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "poyraz-ui/molecules";
import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis, Line, LineChart } from "recharts";
@@ -289,36 +290,3 @@ export function DashboardClient({ data }: DashboardClientProps) {
</div>
);
}
function StatCard({
label,
value,
icon: Icon,
tone,
}: {
label: string;
value: string;
icon: typeof FolderKanban;
tone: "green" | "blue" | "amber" | "red";
}) {
const toneClass = {
green: "bg-emerald-50 text-emerald-700",
blue: "bg-blue-50 text-blue-700",
amber: "bg-amber-50 text-amber-700",
red: "bg-primary/10 text-primary",
}[tone];
return (
<Card>
<CardContent className="flex items-center justify-between gap-3 p-4">
<div>
<p className="text-sm text-muted-foreground">{label}</p>
<p className="mt-1 text-2xl font-semibold text-foreground">{value}</p>
</div>
<div className={`flex h-10 w-10 items-center justify-center rounded-sm ${toneClass}`}>
<Icon className="h-5 w-5" />
</div>
</CardContent>
</Card>
);
}
+94 -32
View File
@@ -24,6 +24,8 @@ import {
import {
ArrowDownRight,
ArrowUpRight,
ChevronLeft,
ChevronRight,
Pencil,
Plus,
Trash2,
@@ -31,7 +33,8 @@ import {
Brain,
Loader2,
} from "lucide-react";
import { useMemo, useState } from "react";
import { useMemo, useRef, useState } from "react";
import { StatCard } from "@/components/system/stat-card";
export type FinanceRelationOption = {
id: string;
@@ -82,6 +85,17 @@ const currencyOptions = [
{ value: "AUD", label: "Avustralya doları (AUD)" },
];
// Dizilim ve featured alanı, özet şeridinde hangi metriklerin önce
// gösterileceğini tek bir yerden değiştirmeyi sağlar.
const financeSummaryCardConfig = [
{ key: "afterTax", label: "Vergi Sonrası Net", tone: "green", icon: Wallet, featured: true },
{ key: "net", label: "Brüt kazanç", tone: "primary", icon: Wallet, featured: true },
{ key: "income", label: "Aylık gelir", tone: "green", icon: ArrowUpRight, featured: false },
{ key: "expense", label: "Aylık gider", tone: "rose", icon: ArrowDownRight, featured: false },
{ key: "pending", label: "Bekleyen", tone: "amber", icon: Wallet, featured: false },
{ key: "tax", label: "KDV Tahmini (%20)", tone: "amber", icon: Wallet, featured: false },
] as const;
type FinanceClientProps = {
transactions: FinanceTransactionItem[];
clients: FinanceRelationOption[];
@@ -91,6 +105,7 @@ type FinanceClientProps = {
export function FinanceClient({ transactions, clients, projects }: FinanceClientProps) {
const [query, setQuery] = useState("");
const [monthFilter, setMonthFilter] = useState(() => new Date().toISOString().slice(0, 7));
const summaryTrackRef = useRef<HTMLDivElement>(null);
const normalizedQuery = query.trim().toLowerCase();
const filteredByMonth = transactions.filter((transaction) =>
transaction.transaction_date.startsWith(monthFilter),
@@ -111,6 +126,20 @@ export function FinanceClient({ transactions, clients, projects }: FinanceClient
const summary = useMemo(() => calculateSummary(filteredByMonth), [filteredByMonth]);
const categoryBreakdown = useMemo(() => calculateExpenseCategories(filteredByMonth), [filteredByMonth]);
const summaryCards = financeSummaryCardConfig.map((card) => ({
...card,
value: formatCurrency(summary[card.key]),
}));
const scrollSummary = (direction: -1 | 1) => {
const track = summaryTrackRef.current;
if (!track) return;
track.scrollBy({
left: direction * Math.max(track.clientWidth * 0.72, 260),
behavior: "smooth",
});
};
return (
<div className="mx-auto flex max-w-7xl flex-col gap-6">
@@ -129,14 +158,70 @@ export function FinanceClient({ transactions, clients, projects }: FinanceClient
</div>
</div>
<div className="grid gap-3 md:grid-cols-3 lg:grid-cols-6">
<StatCard label="Aylık gelir" value={formatCurrency(summary.income)} tone="green" />
<StatCard label="Aylık gider" value={formatCurrency(summary.expense)} tone="rose" />
<StatCard label="Brüt kazanç" value={formatCurrency(summary.net)} tone="primary" />
<StatCard label="KDV Tahmini (%20)" value={formatCurrency(summary.tax)} tone="amber" />
<StatCard label="Vergi Sonrası Net" value={formatCurrency(summary.afterTax)} tone="green" />
<StatCard label="Bekleyen" value={formatCurrency(summary.pending)} tone="amber" />
</div>
<section aria-labelledby="finance-summary-title" className="space-y-3">
<div className="flex items-end justify-between gap-4">
<div>
<h2 id="finance-summary-title" className="text-base font-semibold text-foreground">
Finans özeti
</h2>
<p className="text-sm text-muted-foreground">
Öne çıkan metrikler önce gösterilir; diğer kartlar arasında kaydırarak ilerleyebilirsin.
</p>
</div>
<div className="flex shrink-0 gap-2">
<Button
type="button"
variant="outline"
size="icon"
className="h-9 w-9"
aria-label="Önceki finans özet kartları"
onClick={() => scrollSummary(-1)}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
type="button"
variant="outline"
size="icon"
className="h-9 w-9"
aria-label="Sonraki finans özet kartları"
onClick={() => scrollSummary(1)}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
<div
ref={summaryTrackRef}
role="region"
aria-label="Kaydırılabilir finans özeti"
tabIndex={0}
onKeyDown={(event) => {
if (event.key === "ArrowLeft") {
event.preventDefault();
scrollSummary(-1);
}
if (event.key === "ArrowRight") {
event.preventDefault();
scrollSummary(1);
}
}}
className="tiny-scrollbar flex snap-x snap-mandatory gap-3 overflow-x-auto scroll-smooth pb-3 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
{summaryCards.map((card) => (
<StatCard
key={card.key}
label={card.label}
value={card.value}
icon={card.icon}
tone={card.tone}
featured={card.featured}
className="w-[250px] shrink-0 snap-start"
/>
))}
</div>
</section>
<div className="grid gap-6 lg:grid-cols-[1fr_320px]">
<Card>
@@ -487,29 +572,6 @@ function SelectField({ name, label, defaultValue, children }: { name: string; la
);
}
function StatCard({ label, value, tone }: { label: string; value: string; tone: "green" | "rose" | "primary" | "amber" }) {
const toneClass = {
green: "bg-emerald-50 text-emerald-700",
rose: "bg-rose-50 text-rose-700",
primary: "bg-primary/10 text-primary",
amber: "bg-amber-50 text-amber-700",
}[tone];
return (
<Card>
<CardContent className="flex items-center justify-between gap-3 p-4">
<div>
<p className="text-sm text-muted-foreground">{label}</p>
<p className="mt-1 text-2xl font-semibold text-foreground">{value}</p>
</div>
<div className={`flex h-10 w-10 items-center justify-center rounded-sm ${toneClass}`}>
<Wallet className="h-5 w-5" />
</div>
</CardContent>
</Card>
);
}
function EmptyState({ hasQuery }: { hasQuery: boolean }) {
return (
<div className="flex min-h-72 flex-col items-center justify-center rounded-sm border border-dashed border-border bg-muted/20 p-8 text-center">
+5 -38
View File
@@ -35,8 +35,8 @@ import {
XAxis,
YAxis,
} from "recharts";
import type { ReactNode } from "react";
import { useMemo, useState } from "react";
import { StatCard } from "@/components/system/stat-card";
export type DailyLogItem = {
id: string;
@@ -95,25 +95,25 @@ export function JournalClient({ logs }: JournalClientProps) {
<StatCard
label="Ortalama mood"
value={summary.moodAverage ? summary.moodAverage.toFixed(1) : "-"}
icon={<Smile className="h-5 w-5" />}
icon={Smile}
tone="primary"
/>
<StatCard
label="Ortalama enerji"
value={summary.energyAverage ? summary.energyAverage.toFixed(1) : "-"}
icon={<Battery className="h-5 w-5" />}
icon={Battery}
tone="green"
/>
<StatCard
label="Memnuniyet"
value={summary.satisfactionAverage ? summary.satisfactionAverage.toFixed(1) : "-"}
icon={<LineChartIcon className="h-5 w-5" />}
icon={LineChartIcon}
tone="blue"
/>
<StatCard
label="Kayıtlı gün"
value={String(logs.length)}
icon={<CalendarDays className="h-5 w-5" />}
icon={CalendarDays}
tone="amber"
/>
</div>
@@ -399,39 +399,6 @@ function ScoreBadge({ score, tone }: { score: number; tone: "primary" | "green"
return <Badge className={className}>{score}/5 · {scoreLabels[score]}</Badge>;
}
function StatCard({
label,
value,
icon,
tone,
}: {
label: string;
value: string;
icon: ReactNode;
tone: "primary" | "green" | "blue" | "amber";
}) {
const toneClass = {
primary: "bg-primary/10 text-primary",
green: "bg-emerald-50 text-emerald-700",
blue: "bg-blue-50 text-blue-700",
amber: "bg-amber-50 text-amber-700",
}[tone];
return (
<Card>
<CardContent className="flex items-center justify-between gap-3 p-4">
<div>
<p className="text-sm text-muted-foreground">{label}</p>
<p className="mt-1 text-2xl font-semibold text-foreground">{value}</p>
</div>
<div className={`flex h-10 w-10 items-center justify-center rounded-sm ${toneClass}`}>
{icon}
</div>
</CardContent>
</Card>
);
}
function EmptyState() {
return (
<div className="flex min-h-72 flex-col items-center justify-center rounded-sm border border-dashed border-border bg-muted/20 p-8 text-center">
+1 -33
View File
@@ -40,6 +40,7 @@ import {
} from "lucide-react";
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useState, type ChangeEvent } from "react";
import { StatCard } from "@/components/system/stat-card";
export type ProjectClientOption = {
id: string;
@@ -722,39 +723,6 @@ function ProgressBar({ progress, compact = false }: { progress: number; compact?
);
}
function StatCard({
label,
value,
icon: Icon,
tone,
}: {
label: string;
value: string;
icon: typeof FolderKanban;
tone: "green" | "blue" | "amber" | "red";
}) {
const toneClass = {
green: "bg-emerald-50 text-emerald-700",
blue: "bg-blue-50 text-blue-700",
amber: "bg-amber-50 text-amber-700",
red: "bg-primary/10 text-primary",
}[tone];
return (
<Card>
<CardContent className="flex items-center justify-between gap-3 p-4">
<div>
<p className="text-sm text-muted-foreground">{label}</p>
<p className="mt-1 text-2xl font-semibold text-foreground">{value}</p>
</div>
<div className={`flex h-10 w-10 items-center justify-center rounded-sm ${toneClass}`}>
<Icon className="h-5 w-5" />
</div>
</CardContent>
</Card>
);
}
function EmptyState({ hasQuery }: { hasQuery: boolean }) {
return (
<div className="flex min-h-72 flex-col items-center justify-center rounded-sm border border-dashed border-border bg-muted/20 p-8 text-center">