diff --git a/app/(dashboard)/clients/clients-client.tsx b/app/(dashboard)/clients/clients-client.tsx index 7a7f82d..65a0896 100644 --- a/app/(dashboard)/clients/clients-client.tsx +++ b/app/(dashboard)/clients/clients-client.tsx @@ -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" /> 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" /> @@ -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 ( - - -
-
-

{label}

-

{value}

- {description ?

{description}

: null} -
-
- -
-
-
-
- ); -} - function EmptyState({ hasQuery }: { hasQuery: boolean }) { return (
diff --git a/app/(dashboard)/dashboard-client.tsx b/app/(dashboard)/dashboard-client.tsx index de638a2..896c3b1 100644 --- a/app/(dashboard)/dashboard-client.tsx +++ b/app/(dashboard)/dashboard-client.tsx @@ -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) {
); } - -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 ( - - -
-

{label}

-

{value}

-
-
- -
-
-
- ); -} diff --git a/app/(dashboard)/finance/finance-client.tsx b/app/(dashboard)/finance/finance-client.tsx index 1c9079d..93e35d5 100644 --- a/app/(dashboard)/finance/finance-client.tsx +++ b/app/(dashboard)/finance/finance-client.tsx @@ -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(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 (
@@ -129,14 +158,70 @@ export function FinanceClient({ transactions, clients, projects }: FinanceClient
-
- - - - - - -
+
+
+
+

+ Finans özeti +

+

+ Öne çıkan metrikler önce gösterilir; diğer kartlar arasında kaydırarak ilerleyebilirsin. +

+
+
+ + +
+
+ +
{ + 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) => ( + + ))} +
+
@@ -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 ( - - -
-

{label}

-

{value}

-
-
- -
-
-
- ); -} - function EmptyState({ hasQuery }: { hasQuery: boolean }) { return (
diff --git a/app/(dashboard)/journal/journal-client.tsx b/app/(dashboard)/journal/journal-client.tsx index b2032c7..763fc47 100644 --- a/app/(dashboard)/journal/journal-client.tsx +++ b/app/(dashboard)/journal/journal-client.tsx @@ -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) { } + icon={Smile} tone="primary" /> } + icon={Battery} tone="green" /> } + icon={LineChartIcon} tone="blue" /> } + icon={CalendarDays} tone="amber" />
@@ -399,39 +399,6 @@ function ScoreBadge({ score, tone }: { score: number; tone: "primary" | "green" return {score}/5 · {scoreLabels[score]}; } -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 ( - - -
-

{label}

-

{value}

-
-
- {icon} -
-
-
- ); -} - function EmptyState() { return (
diff --git a/app/(dashboard)/projects/projects-client.tsx b/app/(dashboard)/projects/projects-client.tsx index fe7a25c..dacb498 100644 --- a/app/(dashboard)/projects/projects-client.tsx +++ b/app/(dashboard)/projects/projects-client.tsx @@ -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 ( - - -
-

{label}

-

{value}

-
-
- -
-
-
- ); -} - function EmptyState({ hasQuery }: { hasQuery: boolean }) { return (
diff --git a/components/system/stat-card.tsx b/components/system/stat-card.tsx new file mode 100644 index 0000000..7bb8602 --- /dev/null +++ b/components/system/stat-card.tsx @@ -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 = { + 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 ( + + +
+

{label}

+

+ {value} +

+ {description ? ( +

{description}

+ ) : null} +
+ +
+
+ ); +} diff --git a/scripts/phase4-ui-boundary.mjs b/scripts/phase4-ui-boundary.mjs index 43ca333..c026939 100644 --- a/scripts/phase4-ui-boundary.mjs +++ b/scripts/phase4-ui-boundary.mjs @@ -22,6 +22,10 @@ assert.ok( globalsCss.includes('@import "poyraz-ui/preset.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 [ "SidebarProvider", @@ -62,7 +66,11 @@ assert.doesNotMatch( assert.match(settingsPage, /\bRadioGroup\b/, "Settings must use the Poyraz RadioGroup for theme selection"); for (const workspaceControl of [ 'name="workspaceName"', - 'name="logo"', + 'name="metaTitle"', + 'name="shortName"', + 'name="lightLogo"', + 'name="darkLogo"', + 'name="favicon"', 'name="primaryColor"', ]) { assert.ok( @@ -70,6 +78,12 @@ for (const workspaceControl of [ `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"]) { assert.ok( settingsPage.includes(`value: "${colorMode}"`), @@ -88,17 +102,77 @@ assert.ok( fs.existsSync(path.join(repoRoot, "server/settings/preferences.ts")), "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 = [ "components/system/page-header.tsx", "components/system/feedback-state.tsx", "components/system/status-badge.tsx", "components/system/destructive-confirmation.tsx", + "components/system/stat-card.tsx", ]; for (const file of requiredSystemCompositions) { 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([ "pending-link.tsx", "pending-submit-button.tsx",