feat(ui): add theme-aware stats and finance carousel
This commit is contained in:
@@ -40,7 +40,6 @@ import {
|
|||||||
Wallet,
|
Wallet,
|
||||||
Clock,
|
Clock,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
type LucideIcon,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
@@ -48,6 +47,7 @@ import { format, isPast, isToday } from "date-fns";
|
|||||||
import { tr } from "date-fns/locale";
|
import { tr } from "date-fns/locale";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { StatCard } from "@/components/system/stat-card";
|
||||||
|
|
||||||
export type ClientListItem = {
|
export type ClientListItem = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -180,26 +180,26 @@ export function ClientsClient({
|
|||||||
label="Potansiyel (Lead)"
|
label="Potansiyel (Lead)"
|
||||||
value={clients.filter(c => c.pipeline_stage === 'lead' || c.pipeline_stage === 'contacted').length.toString()}
|
value={clients.filter(c => c.pipeline_stage === 'lead' || c.pipeline_stage === 'contacted').length.toString()}
|
||||||
icon={Users}
|
icon={Users}
|
||||||
iconClassName="bg-blue-50 text-blue-700"
|
tone="blue"
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
label="Aktif Müşteri"
|
label="Aktif Müşteri"
|
||||||
value={activeCount.toString()}
|
value={activeCount.toString()}
|
||||||
icon={UserCheck}
|
icon={UserCheck}
|
||||||
iconClassName="bg-emerald-50 text-emerald-700"
|
tone="green"
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
label="Bekleyen Follow-up"
|
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()}
|
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}
|
icon={Clock}
|
||||||
iconClassName="bg-rose-50 text-rose-700"
|
tone="rose"
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
label="Kayıtlı Gelir"
|
label="Kayıtlı Gelir"
|
||||||
value={formatCurrency(totalRevenue)}
|
value={formatCurrency(totalRevenue)}
|
||||||
description="Ödenmiş gelir işlemleri"
|
description="Ödenmiş gelir işlemleri"
|
||||||
icon={Wallet}
|
icon={Wallet}
|
||||||
iconClassName="bg-primary/10 text-primary"
|
tone="primary"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 }) {
|
function EmptyState({ hasQuery }: { hasQuery: boolean }) {
|
||||||
return (
|
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">
|
<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">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useRouter, usePathname, useSearchParams } from "next/navigation";
|
import { useRouter, usePathname, useSearchParams } from "next/navigation";
|
||||||
import { PendingLink } from "@/components/ui/pending-link";
|
import { PendingLink } from "@/components/ui/pending-link";
|
||||||
|
import { StatCard } from "@/components/system/stat-card";
|
||||||
import { Badge, Card, CardContent } from "poyraz-ui/atoms";
|
import { Badge, Card, CardContent } from "poyraz-ui/atoms";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "poyraz-ui/molecules";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "poyraz-ui/molecules";
|
||||||
import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis, Line, LineChart } from "recharts";
|
import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis, Line, LineChart } from "recharts";
|
||||||
@@ -289,36 +290,3 @@ export function DashboardClient({ data }: DashboardClientProps) {
|
|||||||
</div>
|
</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ import {
|
|||||||
import {
|
import {
|
||||||
ArrowDownRight,
|
ArrowDownRight,
|
||||||
ArrowUpRight,
|
ArrowUpRight,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
Pencil,
|
Pencil,
|
||||||
Plus,
|
Plus,
|
||||||
Trash2,
|
Trash2,
|
||||||
@@ -31,7 +33,8 @@ import {
|
|||||||
Brain,
|
Brain,
|
||||||
Loader2,
|
Loader2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useRef, useState } from "react";
|
||||||
|
import { StatCard } from "@/components/system/stat-card";
|
||||||
|
|
||||||
export type FinanceRelationOption = {
|
export type FinanceRelationOption = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -82,6 +85,17 @@ const currencyOptions = [
|
|||||||
{ value: "AUD", label: "Avustralya doları (AUD)" },
|
{ 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 = {
|
type FinanceClientProps = {
|
||||||
transactions: FinanceTransactionItem[];
|
transactions: FinanceTransactionItem[];
|
||||||
clients: FinanceRelationOption[];
|
clients: FinanceRelationOption[];
|
||||||
@@ -91,6 +105,7 @@ type FinanceClientProps = {
|
|||||||
export function FinanceClient({ transactions, clients, projects }: FinanceClientProps) {
|
export function FinanceClient({ transactions, clients, projects }: FinanceClientProps) {
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [monthFilter, setMonthFilter] = useState(() => new Date().toISOString().slice(0, 7));
|
const [monthFilter, setMonthFilter] = useState(() => new Date().toISOString().slice(0, 7));
|
||||||
|
const summaryTrackRef = useRef<HTMLDivElement>(null);
|
||||||
const normalizedQuery = query.trim().toLowerCase();
|
const normalizedQuery = query.trim().toLowerCase();
|
||||||
const filteredByMonth = transactions.filter((transaction) =>
|
const filteredByMonth = transactions.filter((transaction) =>
|
||||||
transaction.transaction_date.startsWith(monthFilter),
|
transaction.transaction_date.startsWith(monthFilter),
|
||||||
@@ -111,6 +126,20 @@ export function FinanceClient({ transactions, clients, projects }: FinanceClient
|
|||||||
|
|
||||||
const summary = useMemo(() => calculateSummary(filteredByMonth), [filteredByMonth]);
|
const summary = useMemo(() => calculateSummary(filteredByMonth), [filteredByMonth]);
|
||||||
const categoryBreakdown = useMemo(() => calculateExpenseCategories(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 (
|
return (
|
||||||
<div className="mx-auto flex max-w-7xl flex-col gap-6">
|
<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>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-3 md:grid-cols-3 lg:grid-cols-6">
|
<section aria-labelledby="finance-summary-title" className="space-y-3">
|
||||||
<StatCard label="Aylık gelir" value={formatCurrency(summary.income)} tone="green" />
|
<div className="flex items-end justify-between gap-4">
|
||||||
<StatCard label="Aylık gider" value={formatCurrency(summary.expense)} tone="rose" />
|
<div>
|
||||||
<StatCard label="Brüt kazanç" value={formatCurrency(summary.net)} tone="primary" />
|
<h2 id="finance-summary-title" className="text-base font-semibold text-foreground">
|
||||||
<StatCard label="KDV Tahmini (%20)" value={formatCurrency(summary.tax)} tone="amber" />
|
Finans özeti
|
||||||
<StatCard label="Vergi Sonrası Net" value={formatCurrency(summary.afterTax)} tone="green" />
|
</h2>
|
||||||
<StatCard label="Bekleyen" value={formatCurrency(summary.pending)} tone="amber" />
|
<p className="text-sm text-muted-foreground">
|
||||||
</div>
|
Ö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]">
|
<div className="grid gap-6 lg:grid-cols-[1fr_320px]">
|
||||||
<Card>
|
<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 }) {
|
function EmptyState({ hasQuery }: { hasQuery: boolean }) {
|
||||||
return (
|
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">
|
<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">
|
||||||
|
|||||||
@@ -35,8 +35,8 @@ import {
|
|||||||
XAxis,
|
XAxis,
|
||||||
YAxis,
|
YAxis,
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
import type { ReactNode } from "react";
|
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
|
import { StatCard } from "@/components/system/stat-card";
|
||||||
|
|
||||||
export type DailyLogItem = {
|
export type DailyLogItem = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -95,25 +95,25 @@ export function JournalClient({ logs }: JournalClientProps) {
|
|||||||
<StatCard
|
<StatCard
|
||||||
label="Ortalama mood"
|
label="Ortalama mood"
|
||||||
value={summary.moodAverage ? summary.moodAverage.toFixed(1) : "-"}
|
value={summary.moodAverage ? summary.moodAverage.toFixed(1) : "-"}
|
||||||
icon={<Smile className="h-5 w-5" />}
|
icon={Smile}
|
||||||
tone="primary"
|
tone="primary"
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
label="Ortalama enerji"
|
label="Ortalama enerji"
|
||||||
value={summary.energyAverage ? summary.energyAverage.toFixed(1) : "-"}
|
value={summary.energyAverage ? summary.energyAverage.toFixed(1) : "-"}
|
||||||
icon={<Battery className="h-5 w-5" />}
|
icon={Battery}
|
||||||
tone="green"
|
tone="green"
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
label="Memnuniyet"
|
label="Memnuniyet"
|
||||||
value={summary.satisfactionAverage ? summary.satisfactionAverage.toFixed(1) : "-"}
|
value={summary.satisfactionAverage ? summary.satisfactionAverage.toFixed(1) : "-"}
|
||||||
icon={<LineChartIcon className="h-5 w-5" />}
|
icon={LineChartIcon}
|
||||||
tone="blue"
|
tone="blue"
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
label="Kayıtlı gün"
|
label="Kayıtlı gün"
|
||||||
value={String(logs.length)}
|
value={String(logs.length)}
|
||||||
icon={<CalendarDays className="h-5 w-5" />}
|
icon={CalendarDays}
|
||||||
tone="amber"
|
tone="amber"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -399,39 +399,6 @@ function ScoreBadge({ score, tone }: { score: number; tone: "primary" | "green"
|
|||||||
return <Badge className={className}>{score}/5 · {scoreLabels[score]}</Badge>;
|
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() {
|
function EmptyState() {
|
||||||
return (
|
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">
|
<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">
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
import { useEffect, useState, type ChangeEvent } from "react";
|
import { useEffect, useState, type ChangeEvent } from "react";
|
||||||
|
import { StatCard } from "@/components/system/stat-card";
|
||||||
|
|
||||||
export type ProjectClientOption = {
|
export type ProjectClientOption = {
|
||||||
id: string;
|
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 }) {
|
function EmptyState({ hasQuery }: { hasQuery: boolean }) {
|
||||||
return (
|
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">
|
<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">
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
import { Card, CardContent } from "poyraz-ui/atoms";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export type StatCardTone =
|
||||||
|
| "primary"
|
||||||
|
| "green"
|
||||||
|
| "blue"
|
||||||
|
| "amber"
|
||||||
|
| "red"
|
||||||
|
| "rose";
|
||||||
|
|
||||||
|
const iconToneClasses: Record<StatCardTone, string> = {
|
||||||
|
primary: "bg-primary/10 text-primary",
|
||||||
|
green: "bg-success text-success-icon",
|
||||||
|
blue: "bg-info text-info-icon",
|
||||||
|
amber: "bg-warning text-warning-icon",
|
||||||
|
red: "bg-destructive-muted text-destructive-muted-foreground",
|
||||||
|
rose: "bg-destructive-muted text-destructive-muted-foreground",
|
||||||
|
};
|
||||||
|
|
||||||
|
type StatCardProps = {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
icon: LucideIcon;
|
||||||
|
tone?: StatCardTone;
|
||||||
|
description?: string;
|
||||||
|
featured?: boolean;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function StatCard({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
icon: Icon,
|
||||||
|
tone = "primary",
|
||||||
|
description,
|
||||||
|
featured = false,
|
||||||
|
className,
|
||||||
|
}: StatCardProps) {
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
className={cn(
|
||||||
|
"h-full",
|
||||||
|
featured && "border-primary/30 bg-primary/[0.025] shadow-sm",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CardContent className="flex h-full items-center justify-between gap-3 p-4">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm text-muted-foreground">{label}</p>
|
||||||
|
<p className="mt-1 truncate text-2xl font-semibold text-foreground">
|
||||||
|
{value}
|
||||||
|
</p>
|
||||||
|
{description ? (
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">{description}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn(
|
||||||
|
"flex h-10 w-10 shrink-0 items-center justify-center rounded-sm",
|
||||||
|
iconToneClasses[tone],
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -22,6 +22,10 @@ assert.ok(
|
|||||||
globalsCss.includes('@import "poyraz-ui/preset.css";'),
|
globalsCss.includes('@import "poyraz-ui/preset.css";'),
|
||||||
"Poyraz UI preset must be imported by app/globals.css",
|
"Poyraz UI preset must be imported by app/globals.css",
|
||||||
);
|
);
|
||||||
|
assert.ok(
|
||||||
|
globalsCss.includes("@custom-variant dark (&:where(.dark, .dark *));"),
|
||||||
|
"Tailwind dark utilities must follow Neta's explicit root color mode",
|
||||||
|
);
|
||||||
|
|
||||||
for (const component of [
|
for (const component of [
|
||||||
"SidebarProvider",
|
"SidebarProvider",
|
||||||
@@ -62,7 +66,11 @@ assert.doesNotMatch(
|
|||||||
assert.match(settingsPage, /\bRadioGroup\b/, "Settings must use the Poyraz RadioGroup for theme selection");
|
assert.match(settingsPage, /\bRadioGroup\b/, "Settings must use the Poyraz RadioGroup for theme selection");
|
||||||
for (const workspaceControl of [
|
for (const workspaceControl of [
|
||||||
'name="workspaceName"',
|
'name="workspaceName"',
|
||||||
'name="logo"',
|
'name="metaTitle"',
|
||||||
|
'name="shortName"',
|
||||||
|
'name="lightLogo"',
|
||||||
|
'name="darkLogo"',
|
||||||
|
'name="favicon"',
|
||||||
'name="primaryColor"',
|
'name="primaryColor"',
|
||||||
]) {
|
]) {
|
||||||
assert.ok(
|
assert.ok(
|
||||||
@@ -70,6 +78,12 @@ for (const workspaceControl of [
|
|||||||
`Settings must expose workspace branding control ${workspaceControl}`,
|
`Settings must expose workspace branding control ${workspaceControl}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
assert.ok(settingsPage.includes('useState("Genel")'), "Workspace and appearance settings must share the General tab");
|
||||||
|
assert.doesNotMatch(
|
||||||
|
settingsPage,
|
||||||
|
/\{\s*name:\s*"Görünüm"/,
|
||||||
|
"Appearance must not remain as a separate settings tab",
|
||||||
|
);
|
||||||
for (const colorMode of ["light", "dark", "system"]) {
|
for (const colorMode of ["light", "dark", "system"]) {
|
||||||
assert.ok(
|
assert.ok(
|
||||||
settingsPage.includes(`value: "${colorMode}"`),
|
settingsPage.includes(`value: "${colorMode}"`),
|
||||||
@@ -88,17 +102,77 @@ assert.ok(
|
|||||||
fs.existsSync(path.join(repoRoot, "server/settings/preferences.ts")),
|
fs.existsSync(path.join(repoRoot, "server/settings/preferences.ts")),
|
||||||
"Missing database-backed user preferences service",
|
"Missing database-backed user preferences service",
|
||||||
);
|
);
|
||||||
|
assert.match(
|
||||||
|
settingsPage,
|
||||||
|
/md:sticky md:top-8/,
|
||||||
|
"The desktop settings navigation must remain sticky while its content scrolls",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
fs.existsSync(path.join(repoRoot, "app/favicon.ico")),
|
||||||
|
false,
|
||||||
|
"Static App Router favicon must not override database-backed branding metadata",
|
||||||
|
);
|
||||||
|
|
||||||
const requiredSystemCompositions = [
|
const requiredSystemCompositions = [
|
||||||
"components/system/page-header.tsx",
|
"components/system/page-header.tsx",
|
||||||
"components/system/feedback-state.tsx",
|
"components/system/feedback-state.tsx",
|
||||||
"components/system/status-badge.tsx",
|
"components/system/status-badge.tsx",
|
||||||
"components/system/destructive-confirmation.tsx",
|
"components/system/destructive-confirmation.tsx",
|
||||||
|
"components/system/stat-card.tsx",
|
||||||
];
|
];
|
||||||
for (const file of requiredSystemCompositions) {
|
for (const file of requiredSystemCompositions) {
|
||||||
assert.ok(fs.existsSync(path.join(repoRoot, file)), `Missing Neta system composition: ${file}`);
|
assert.ok(fs.existsSync(path.join(repoRoot, file)), `Missing Neta system composition: ${file}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const statCard = fs.readFileSync(
|
||||||
|
path.join(repoRoot, "components/system/stat-card.tsx"),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
for (const semanticTone of [
|
||||||
|
"bg-success text-success-icon",
|
||||||
|
"bg-info text-info-icon",
|
||||||
|
"bg-warning text-warning-icon",
|
||||||
|
"bg-destructive-muted text-destructive-muted-foreground",
|
||||||
|
]) {
|
||||||
|
assert.ok(
|
||||||
|
statCard.includes(semanticTone),
|
||||||
|
`Stat cards must use Poyraz's theme-aware ${semanticTone} tokens`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const statPage of [
|
||||||
|
"app/(dashboard)/dashboard-client.tsx",
|
||||||
|
"app/(dashboard)/clients/clients-client.tsx",
|
||||||
|
"app/(dashboard)/projects/projects-client.tsx",
|
||||||
|
"app/(dashboard)/journal/journal-client.tsx",
|
||||||
|
"app/(dashboard)/finance/finance-client.tsx",
|
||||||
|
]) {
|
||||||
|
const content = fs.readFileSync(path.join(repoRoot, statPage), "utf8");
|
||||||
|
assert.ok(
|
||||||
|
content.includes('from "@/components/system/stat-card"'),
|
||||||
|
`${statPage} must use the shared theme-aware stat card`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const financePage = fs.readFileSync(
|
||||||
|
path.join(repoRoot, "app/(dashboard)/finance/finance-client.tsx"),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
for (const sliderBehavior of [
|
||||||
|
"financeSummaryCardConfig",
|
||||||
|
"featured: true",
|
||||||
|
"snap-mandatory",
|
||||||
|
"overflow-x-auto",
|
||||||
|
"scrollBy",
|
||||||
|
'event.key === "ArrowLeft"',
|
||||||
|
'event.key === "ArrowRight"',
|
||||||
|
]) {
|
||||||
|
assert.ok(
|
||||||
|
financePage.includes(sliderBehavior),
|
||||||
|
`Finance summary slider is missing ${sliderBehavior}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const allowedLocalUiFiles = new Set([
|
const allowedLocalUiFiles = new Set([
|
||||||
"pending-link.tsx",
|
"pending-link.tsx",
|
||||||
"pending-submit-button.tsx",
|
"pending-submit-button.tsx",
|
||||||
|
|||||||
Reference in New Issue
Block a user