Files
neta/app/(dashboard)/projects/[id]/project-detail-client.tsx
T
poyrazavsever 4c46a70212 feat: implement project detail page with planning sections, tasks, and finance management
feat: add project planning section CRUD operations and integrate with Supabase
feat: enhance project card interactions with navigation to project details
fix: update task completion logic to revalidate project paths
2026-06-05 09:39:22 +03:00

722 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import {
completeProjectRecord,
createProjectPlanningSectionRecord,
deleteProjectPlanningSectionRecord,
updateProjectPlanningSectionRecord,
} from "@/app/(dashboard)/projects/actions";
import { completeTaskRecord } from "@/app/(dashboard)/tasks/actions";
import { Badge, Button, Card, CardContent, Input, Label, Textarea } from "poyraz-ui/atoms";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "poyraz-ui/molecules";
import {
ArrowLeft,
CalendarDays,
CheckCircle2,
ClipboardList,
FileText,
FolderKanban,
Palette,
Pencil,
Plus,
Target,
Trash2,
Wallet,
} from "lucide-react";
import Link from "next/link";
import { useState } from "react";
export type ProjectDetail = {
id: string;
client_id: string | null;
clientName: string | null;
name: string;
type: "client_project" | "side_project";
description: string | null;
status: "planning" | "active" | "paused" | "completed" | "cancelled";
start_date: string | null;
due_date: string | null;
budget_amount: number | null;
currency: string;
progress: number;
cover_image_alt: string | null;
coverImageUrl: string | null;
};
export type ProjectPlanningSectionItem = {
id: string;
project_id: string;
category:
| "overview"
| "problem"
| "goal"
| "audience"
| "scope"
| "design_system"
| "color_palette"
| "typography"
| "assets"
| "notes";
title: string;
content: string | null;
sort_order: number;
};
export type ProjectDetailTaskItem = {
id: string;
title: string;
status: "todo" | "in_progress" | "done";
priority: "low" | "medium" | "high" | "urgent";
due_at: string | null;
};
export type ProjectFinanceItem = {
id: string;
type: "income" | "expense";
amount: number;
currency: string;
payment_status: "planned" | "pending" | "paid" | "cancelled";
transaction_date: string;
category: string | null;
};
type ProjectDetailClientProps = {
project: ProjectDetail;
sections: ProjectPlanningSectionItem[];
tasks: ProjectDetailTaskItem[];
financeTransactions: ProjectFinanceItem[];
};
const typeLabels = {
client_project: "Müşteri projesi",
side_project: "Side project",
};
const statusLabels = {
planning: "Planlama",
active: "Aktif",
paused: "Duraklatıldı",
completed: "Tamamlandı",
cancelled: "İptal edildi",
};
const statusClasses = {
planning: "border-blue-200 bg-blue-50 text-blue-700",
active: "border-emerald-200 bg-emerald-50 text-emerald-700",
paused: "border-amber-200 bg-amber-50 text-amber-700",
completed: "border-zinc-200 bg-zinc-50 text-zinc-700",
cancelled: "border-rose-200 bg-rose-50 text-rose-700",
};
const priorityClasses = {
low: "border-zinc-200 bg-zinc-50 text-zinc-700",
medium: "border-blue-200 bg-blue-50 text-blue-700",
high: "border-amber-200 bg-amber-50 text-amber-700",
urgent: "border-rose-200 bg-rose-50 text-rose-700",
};
const sectionLabels: Record<ProjectPlanningSectionItem["category"], string> = {
overview: "Genel bakış",
problem: "Çözdüğü problem",
goal: "Amaç",
audience: "Hedef kitle",
scope: "Kapsam",
design_system: "Design system",
color_palette: "Renk paleti",
typography: "Tipografi",
assets: "Görsel varlıklar",
notes: "Notlar",
};
const planningCategories: ProjectPlanningSectionItem["category"][] = [
"overview",
"problem",
"goal",
"audience",
"scope",
"notes",
];
const designCategories: ProjectPlanningSectionItem["category"][] = [
"design_system",
"color_palette",
"typography",
"assets",
];
export function ProjectDetailClient({
project,
sections,
tasks,
financeTransactions,
}: ProjectDetailClientProps) {
const [activeTab, setActiveTab] = useState<"planning" | "design" | "tasks" | "finance">(
"planning",
);
const planningSections = sections.filter((section) =>
planningCategories.includes(section.category),
);
const designSections = sections.filter((section) =>
designCategories.includes(section.category),
);
const doneTaskCount = tasks.filter((task) => task.status === "done").length;
const incomeTotal = financeTransactions
.filter((transaction) => transaction.type === "income")
.reduce((sum, transaction) => sum + transaction.amount, 0);
const expenseTotal = financeTransactions
.filter((transaction) => transaction.type === "expense")
.reduce((sum, transaction) => sum + transaction.amount, 0);
return (
<div className="mx-auto flex max-w-7xl flex-col gap-6">
<div className="flex flex-col gap-4 border-b border-border pb-5 lg:flex-row lg:items-end lg:justify-between">
<div className="space-y-3">
<Button asChild variant="ghost" className="h-8 gap-2 px-0 text-muted-foreground">
<Link href="/projects">
<ArrowLeft className="h-4 w-4" />
Projelere dön
</Link>
</Button>
<div>
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-3xl font-semibold tracking-normal text-foreground">
{project.name}
</h1>
<Badge className={statusClasses[project.status]}>
{statusLabels[project.status]}
</Badge>
</div>
<p className="mt-1 max-w-3xl text-sm text-muted-foreground">
{project.description || "Bu proje için kısa açıklama eklenmedi."}
</p>
</div>
</div>
<div className="flex gap-2">
<SectionDialog projectId={project.id} mode="create" defaultCategory="overview" />
{project.status !== "completed" ? (
<form action={completeProjectRecord}>
<input type="hidden" name="id" value={project.id} />
<Button type="submit" variant="outline" className="gap-2">
<CheckCircle2 className="h-4 w-4" />
Tamamla
</Button>
</form>
) : null}
</div>
</div>
<div className="grid gap-4 lg:grid-cols-[1.4fr_0.8fr]">
<Card>
<CardContent className="p-0">
{project.coverImageUrl ? (
<div className="aspect-[16/7] overflow-hidden rounded-t-sm border-b border-border bg-muted">
<img
src={project.coverImageUrl}
alt={project.cover_image_alt || project.name}
className="h-full w-full object-cover"
/>
</div>
) : (
<div className="flex aspect-[16/7] items-center justify-center rounded-t-sm border-b border-dashed border-border bg-muted/30 text-muted-foreground">
Kapak görseli yok
</div>
)}
<div className="grid gap-4 p-5 md:grid-cols-2">
<InfoItem label="Tür" value={typeLabels[project.type]} icon={FolderKanban} />
<InfoItem
label="Müşteri"
value={project.clientName || "Bağımsız side project"}
icon={Target}
/>
<InfoItem
label="Deadline"
value={project.due_date ? formatDate(project.due_date) : "Deadline yok"}
icon={CalendarDays}
/>
<InfoItem
label="Bütçe"
value={
project.budget_amount
? formatCurrency(project.budget_amount, project.currency)
: "Bütçe yok"
}
icon={Wallet}
/>
</div>
</CardContent>
</Card>
<div className="grid gap-4">
<StatCard label="İlerleme" value={`${project.progress}%`} icon={Target} />
<StatCard label="Görev" value={`${doneTaskCount}/${tasks.length}`} icon={ClipboardList} />
<StatCard
label="Net finans"
value={formatCurrency(incomeTotal - expenseTotal, project.currency)}
icon={Wallet}
/>
</div>
</div>
<div className="flex flex-wrap gap-2 rounded-sm border border-border p-1">
<TabButton active={activeTab === "planning"} onClick={() => setActiveTab("planning")}>
Planlama
</TabButton>
<TabButton active={activeTab === "design"} onClick={() => setActiveTab("design")}>
Design system
</TabButton>
<TabButton active={activeTab === "tasks"} onClick={() => setActiveTab("tasks")}>
Görevler
</TabButton>
<TabButton active={activeTab === "finance"} onClick={() => setActiveTab("finance")}>
Finans
</TabButton>
</div>
{activeTab === "planning" ? (
<SectionGrid
projectId={project.id}
title="Planlama alanları"
description="Problem, amaç, hedef kitle, kapsam ve proje notlarını burada tut."
sections={planningSections}
defaultCategory="overview"
/>
) : null}
{activeTab === "design" ? (
<SectionGrid
projectId={project.id}
title="Design system"
description="Renk paleti, tipografi, görsel dil ve asset notlarını proje kaynağına bağla."
sections={designSections}
defaultCategory="design_system"
/>
) : null}
{activeTab === "tasks" ? <TaskPanel projectId={project.id} tasks={tasks} /> : null}
{activeTab === "finance" ? <FinancePanel transactions={financeTransactions} /> : null}
</div>
);
}
function SectionGrid({
projectId,
title,
description,
sections,
defaultCategory,
}: {
projectId: string;
title: string;
description: string;
sections: ProjectPlanningSectionItem[];
defaultCategory: ProjectPlanningSectionItem["category"];
}) {
return (
<Card>
<CardContent className="space-y-4 p-5">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<h2 className="text-lg font-semibold text-foreground">{title}</h2>
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
</div>
<SectionDialog projectId={projectId} mode="create" defaultCategory={defaultCategory} />
</div>
{sections.length > 0 ? (
<div className="grid gap-4 md:grid-cols-2">
{sections.map((section) => (
<PlanningSectionCard key={section.id} section={section} />
))}
</div>
) : (
<div className="flex min-h-52 flex-col items-center justify-center rounded-sm border border-dashed border-border bg-muted/20 p-8 text-center">
<FileText className="h-9 w-9 text-muted-foreground" />
<h3 className="mt-4 text-base font-semibold text-foreground">Henüz kayıt yok</h3>
<p className="mt-1 max-w-md text-sm text-muted-foreground">
Bu proje için ilk planlama veya design system alanını ekleyerek proje bilgisini
görevlerden bağımsız hale getir.
</p>
</div>
)}
</CardContent>
</Card>
);
}
function PlanningSectionCard({ section }: { section: ProjectPlanningSectionItem }) {
return (
<Card className="transition-colors hover:border-primary/30">
<CardContent className="flex h-full flex-col gap-4 p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<Badge>{sectionLabels[section.category]}</Badge>
<h3 className="mt-3 text-base font-semibold text-foreground">{section.title}</h3>
</div>
<div className="flex gap-2">
<SectionDialog projectId={section.project_id} mode="edit" section={section} />
<form action={deleteProjectPlanningSectionRecord}>
<input type="hidden" name="id" value={section.id} />
<input type="hidden" name="project_id" value={section.project_id} />
<Button type="submit" variant="outline" className="h-9 px-3 text-rose-600">
<Trash2 className="h-4 w-4" />
</Button>
</form>
</div>
</div>
<p className="whitespace-pre-wrap text-sm leading-6 text-muted-foreground">
{section.content || "İçerik eklenmedi."}
</p>
</CardContent>
</Card>
);
}
function SectionDialog({
projectId,
mode,
defaultCategory,
section,
}: {
projectId: string;
mode: "create" | "edit";
defaultCategory?: ProjectPlanningSectionItem["category"];
section?: ProjectPlanningSectionItem;
}) {
const [open, setOpen] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const action =
mode === "create"
? createProjectPlanningSectionRecord
: updateProjectPlanningSectionRecord;
async function handleSubmit(formData: FormData) {
setIsSubmitting(true);
try {
await action(formData);
setOpen(false);
} finally {
setIsSubmitting(false);
}
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button
variant={mode === "create" ? "default" : "outline"}
className="h-9 gap-2 px-3"
>
{mode === "create" ? <Plus className="h-4 w-4" /> : <Pencil className="h-4 w-4" />}
{mode === "create" ? "Alan ekle" : null}
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-xl">
<form action={handleSubmit} className="space-y-5">
<input type="hidden" name="project_id" value={projectId} />
{section ? <input type="hidden" name="id" value={section.id} /> : null}
<DialogHeader>
<DialogTitle>
{mode === "create" ? "Planlama alanı ekle" : "Planlama alanını düzenle"}
</DialogTitle>
<DialogDescription>
Projenin görev dışı bilgisini yapılandırılmış alanlarda sakla.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4">
<div className="grid gap-2">
<Label>Kategori</Label>
<Select name="category" defaultValue={section?.category || defaultCategory || "overview"}>
<SelectTrigger>
<SelectValue placeholder="Kategori seç" />
</SelectTrigger>
<SelectContent>
{Object.entries(sectionLabels).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor={`section-title-${section?.id || "new"}`}>Başlık</Label>
<Input
id={`section-title-${section?.id || "new"}`}
name="title"
defaultValue={section?.title || ""}
required
placeholder="Örn. Başarı kriterleri"
/>
</div>
<div className="grid gap-2">
<Label htmlFor={`section-content-${section?.id || "new"}`}>İçerik</Label>
<Textarea
id={`section-content-${section?.id || "new"}`}
name="content"
defaultValue={section?.content || ""}
rows={8}
placeholder="Kısa notlar, kriterler, renkler, tipografi kararları..."
/>
</div>
<div className="grid gap-2">
<Label htmlFor={`section-order-${section?.id || "new"}`}>Sıra</Label>
<Input
id={`section-order-${section?.id || "new"}`}
name="sort_order"
type="number"
defaultValue={section?.sort_order ?? 0}
/>
</div>
</div>
<DialogFooter>
<Button type="submit" disabled={isSubmitting} className="gap-2">
{isSubmitting ? "Kaydediliyor" : "Kaydet"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
function TaskPanel({ projectId, tasks }: { projectId: string; tasks: ProjectDetailTaskItem[] }) {
return (
<Card>
<CardContent className="space-y-4 p-5">
<div>
<h2 className="text-lg font-semibold text-foreground">Proje görevleri</h2>
<p className="mt-1 text-sm text-muted-foreground">
Bu proje ile bağlantılı görevler aynı task modülünden beslenir.
</p>
</div>
{tasks.length > 0 ? (
<div className="overflow-hidden rounded-sm border border-border">
<div className="hidden grid-cols-[1.5fr_0.8fr_0.8fr_0.8fr] gap-4 border-b border-border bg-muted/40 px-4 py-3 text-xs font-medium uppercase text-muted-foreground lg:grid">
<span>Görev</span>
<span>Öncelik</span>
<span>Son tarih</span>
<span className="text-right">İşlem</span>
</div>
<div className="divide-y divide-border">
{tasks.map((task) => (
<div
key={task.id}
className="grid gap-4 px-4 py-4 lg:grid-cols-[1.5fr_0.8fr_0.8fr_0.8fr] lg:items-center"
>
<div>
<div
className={
task.status === "done"
? "font-medium text-muted-foreground line-through"
: "font-medium text-foreground"
}
>
{task.title}
</div>
<div className="text-sm text-muted-foreground">
{task.status === "done"
? "Tamamlandı"
: task.status === "in_progress"
? "Devam ediyor"
: "Yapılacak"}
</div>
</div>
<div>
<Badge className={priorityClasses[task.priority]}>{task.priority}</Badge>
</div>
<div className="text-sm text-muted-foreground">
{task.due_at ? formatDateTime(task.due_at) : "Yok"}
</div>
<div className="flex justify-start lg:justify-end">
{task.status !== "done" ? (
<form action={completeTaskRecord}>
<input type="hidden" name="id" value={task.id} />
<input type="hidden" name="project_id" value={projectId} />
<Button type="submit" variant="outline" className="h-9 gap-2 px-3">
<CheckCircle2 className="h-4 w-4" />
Tamamla
</Button>
</form>
) : null}
</div>
</div>
))}
</div>
</div>
) : (
<EmptyPanel icon={ClipboardList} title="Bu projeye bağlı görev yok" />
)}
</CardContent>
</Card>
);
}
function FinancePanel({ transactions }: { transactions: ProjectFinanceItem[] }) {
return (
<Card>
<CardContent className="space-y-4 p-5">
<div>
<h2 className="text-lg font-semibold text-foreground">Finans bağlantıları</h2>
<p className="mt-1 text-sm text-muted-foreground">
Bu projeye bağlanan gelir ve gider kayıtları.
</p>
</div>
{transactions.length > 0 ? (
<div className="divide-y divide-border rounded-sm border border-border">
{transactions.map((transaction) => (
<div key={transaction.id} className="flex flex-col gap-2 p-4 md:flex-row md:items-center md:justify-between">
<div>
<div className="font-medium text-foreground">
{transaction.category || (transaction.type === "income" ? "Gelir" : "Gider")}
</div>
<div className="text-sm text-muted-foreground">
{formatDate(transaction.transaction_date)} · {transaction.payment_status}
</div>
</div>
<div
className={
transaction.type === "income"
? "font-semibold text-emerald-700"
: "font-semibold text-rose-700"
}
>
{transaction.type === "income" ? "+" : "-"}
{formatCurrency(transaction.amount, transaction.currency)}
</div>
</div>
))}
</div>
) : (
<EmptyPanel icon={Wallet} title="Bu projeye bağlı finans kaydı yok" />
)}
</CardContent>
</Card>
);
}
function EmptyPanel({
icon: Icon,
title,
}: {
icon: typeof ClipboardList;
title: string;
}) {
return (
<div className="flex min-h-44 flex-col items-center justify-center rounded-sm border border-dashed border-border bg-muted/20 p-8 text-center">
<Icon className="h-9 w-9 text-muted-foreground" />
<h3 className="mt-4 text-base font-semibold text-foreground">{title}</h3>
</div>
);
}
function InfoItem({
label,
value,
icon: Icon,
}: {
label: string;
value: string;
icon: typeof FolderKanban;
}) {
return (
<div className="flex items-center gap-3 rounded-sm border border-border bg-muted/20 p-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-sm bg-background text-primary">
<Icon className="h-4 w-4" />
</div>
<div className="min-w-0">
<div className="text-xs text-muted-foreground">{label}</div>
<div className="truncate text-sm font-medium text-foreground">{value}</div>
</div>
</div>
);
}
function StatCard({
label,
value,
icon: Icon,
}: {
label: string;
value: string;
icon: typeof Palette;
}) {
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 bg-primary/10 text-primary">
<Icon className="h-5 w-5" />
</div>
</CardContent>
</Card>
);
}
function TabButton({
active,
onClick,
children,
}: {
active: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<Button
type="button"
variant={active ? "default" : "ghost"}
className="h-9 px-4"
onClick={onClick}
>
{children}
</Button>
);
}
function formatDate(value: string) {
return new Intl.DateTimeFormat("tr-TR", {
day: "2-digit",
month: "short",
year: "numeric",
}).format(new Date(`${value}T00:00:00`));
}
function formatDateTime(value: string) {
return new Intl.DateTimeFormat("tr-TR", {
day: "2-digit",
month: "short",
hour: "2-digit",
minute: "2-digit",
}).format(new Date(value));
}
function formatCurrency(value: number, currency: string) {
return new Intl.NumberFormat("tr-TR", {
style: "currency",
currency,
maximumFractionDigits: 0,
}).format(value);
}