From 4c46a7021221e3269a1339977dfa9cf3f1cf78e3 Mon Sep 17 00:00:00 2001 From: poyrazavsever Date: Fri, 5 Jun 2026 09:39:22 +0300 Subject: [PATCH] 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 --- app/(dashboard)/projects/[id]/page.tsx | 228 ++++++ .../projects/[id]/project-detail-client.tsx | 721 ++++++++++++++++++ app/(dashboard)/projects/actions.ts | 110 +++ app/(dashboard)/projects/projects-client.tsx | 67 +- app/(dashboard)/tasks/actions.ts | 5 + 5 files changed, 1122 insertions(+), 9 deletions(-) create mode 100644 app/(dashboard)/projects/[id]/page.tsx create mode 100644 app/(dashboard)/projects/[id]/project-detail-client.tsx diff --git a/app/(dashboard)/projects/[id]/page.tsx b/app/(dashboard)/projects/[id]/page.tsx new file mode 100644 index 0000000..51430bd --- /dev/null +++ b/app/(dashboard)/projects/[id]/page.tsx @@ -0,0 +1,228 @@ +import { + ProjectDetailClient, + type ProjectDetail, + type ProjectDetailTaskItem, + type ProjectFinanceItem, + type ProjectPlanningSectionItem, +} from "@/app/(dashboard)/projects/[id]/project-detail-client"; +import { createClient } from "@/lib/supabase/server"; +import { notFound } from "next/navigation"; + +type ProjectRow = { + id: string; + client_id: 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 | string | null; + currency: string; + progress: number; + cover_image_path: string | null; + cover_image_alt: string | null; + clients: { name: string } | { name: string }[] | null; +}; + +type SectionRow = ProjectPlanningSectionItem; + +type TaskRow = { + id: string; + title: string; + status: string | null; + priority: string | null; + due_at: string | null; +}; + +type FinanceRow = { + id: string; + type: string; + amount: number | string; + currency: string; + payment_status: string; + transaction_date: string; + category: string | null; +}; + +export default async function ProjectDetailPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = await params; + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + + if (!user) { + return null; + } + + const [{ data: projectRow }, { data: sectionRows }, { data: taskRows }, { data: financeRows }] = + await Promise.all([ + supabase + .from("projects") + .select( + "id, client_id, name, type, description, status, start_date, due_date, budget_amount, currency, progress, cover_image_path, cover_image_alt, clients(name)", + ) + .eq("id", id) + .eq("user_id", user.id) + .maybeSingle(), + supabase + .from("project_planning_sections") + .select("id, project_id, category, title, content, sort_order") + .eq("project_id", id) + .eq("user_id", user.id) + .order("sort_order", { ascending: true }) + .order("created_at", { ascending: true }), + supabase + .from("tasks") + .select("id, title, status, priority, due_at") + .eq("project_id", id) + .eq("user_id", user.id) + .order("created_at", { ascending: false }), + supabase + .from("finance_transactions") + .select("id, type, amount, currency, payment_status, transaction_date, category") + .eq("project_id", id) + .eq("user_id", user.id) + .order("transaction_date", { ascending: false }), + ]); + + if (!projectRow) { + notFound(); + } + + const projectData = projectRow as unknown as ProjectRow; + const coverImageUrl = projectData.cover_image_path + ? await createProjectImageUrl(supabase, projectData.cover_image_path) + : null; + + const project: ProjectDetail = { + id: projectData.id, + client_id: projectData.client_id, + clientName: getClientName(projectData.clients), + name: projectData.name, + type: normalizeProjectType(projectData.type), + description: projectData.description, + status: normalizeProjectStatus(projectData.status), + start_date: projectData.start_date, + due_date: projectData.due_date, + budget_amount: + projectData.budget_amount === null ? null : Number(projectData.budget_amount), + currency: projectData.currency, + progress: Number(projectData.progress || 0), + cover_image_alt: projectData.cover_image_alt, + coverImageUrl, + }; + + const sections = ((sectionRows || []) as unknown as SectionRow[]).map((section) => ({ + ...section, + category: normalizeSectionCategory(section.category), + sort_order: Number(section.sort_order || 0), + })); + const tasks: ProjectDetailTaskItem[] = ((taskRows || []) as TaskRow[]).map((task) => ({ + id: task.id, + title: task.title, + status: normalizeTaskStatus(task.status), + priority: normalizeTaskPriority(task.priority), + due_at: task.due_at, + })); + const financeTransactions: ProjectFinanceItem[] = ((financeRows || []) as FinanceRow[]).map( + (transaction) => ({ + id: transaction.id, + type: transaction.type === "income" ? "income" : "expense", + amount: Number(transaction.amount || 0), + currency: transaction.currency, + payment_status: normalizePaymentStatus(transaction.payment_status), + transaction_date: transaction.transaction_date, + category: transaction.category, + }), + ); + + return ( + + ); +} + +async function createProjectImageUrl( + supabase: Awaited>, + path: string, +) { + const { data } = await supabase.storage + .from("project-assets") + .createSignedUrl(path, 60 * 15); + + return data?.signedUrl || null; +} + +function getClientName(client: ProjectRow["clients"]) { + if (!client) return null; + return Array.isArray(client) ? client[0]?.name || null : client.name; +} + +function normalizeProjectType(type: string): ProjectDetail["type"] { + return type === "side_project" ? "side_project" : "client_project"; +} + +function normalizeProjectStatus(status: string): ProjectDetail["status"] { + if ( + status === "active" || + status === "paused" || + status === "completed" || + status === "cancelled" + ) { + return status; + } + + return "planning"; +} + +function normalizeSectionCategory(category: string): ProjectPlanningSectionItem["category"] { + if ( + category === "problem" || + category === "goal" || + category === "audience" || + category === "scope" || + category === "design_system" || + category === "color_palette" || + category === "typography" || + category === "assets" || + category === "notes" + ) { + return category; + } + + return "overview"; +} + +function normalizeTaskStatus(status: string | null): ProjectDetailTaskItem["status"] { + if (status === "in_progress" || status === "done") { + return status; + } + + return "todo"; +} + +function normalizeTaskPriority(priority: string | null): ProjectDetailTaskItem["priority"] { + if (priority === "low" || priority === "high" || priority === "urgent") { + return priority; + } + + return "medium"; +} + +function normalizePaymentStatus(status: string): ProjectFinanceItem["payment_status"] { + if (status === "pending" || status === "paid" || status === "cancelled") { + return status; + } + + return "planned"; +} diff --git a/app/(dashboard)/projects/[id]/project-detail-client.tsx b/app/(dashboard)/projects/[id]/project-detail-client.tsx new file mode 100644 index 0000000..5b395ad --- /dev/null +++ b/app/(dashboard)/projects/[id]/project-detail-client.tsx @@ -0,0 +1,721 @@ +"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 = { + 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 ( +
+
+
+ +
+
+

+ {project.name} +

+ + {statusLabels[project.status]} + +
+

+ {project.description || "Bu proje için kısa açıklama eklenmedi."} +

+
+
+ +
+ + {project.status !== "completed" ? ( +
+ + +
+ ) : null} +
+
+ +
+ + + {project.coverImageUrl ? ( +
+ {project.cover_image_alt +
+ ) : ( +
+ Kapak görseli yok +
+ )} +
+ + + + +
+
+
+ +
+ + + +
+
+ +
+ setActiveTab("planning")}> + Planlama + + setActiveTab("design")}> + Design system + + setActiveTab("tasks")}> + Görevler + + setActiveTab("finance")}> + Finans + +
+ + {activeTab === "planning" ? ( + + ) : null} + + {activeTab === "design" ? ( + + ) : null} + + {activeTab === "tasks" ? : null} + {activeTab === "finance" ? : null} +
+ ); +} + +function SectionGrid({ + projectId, + title, + description, + sections, + defaultCategory, +}: { + projectId: string; + title: string; + description: string; + sections: ProjectPlanningSectionItem[]; + defaultCategory: ProjectPlanningSectionItem["category"]; +}) { + return ( + + +
+
+

{title}

+

{description}

+
+ +
+ + {sections.length > 0 ? ( +
+ {sections.map((section) => ( + + ))} +
+ ) : ( +
+ +

Henüz kayıt yok

+

+ Bu proje için ilk planlama veya design system alanını ekleyerek proje bilgisini + görevlerden bağımsız hale getir. +

+
+ )} +
+
+ ); +} + +function PlanningSectionCard({ section }: { section: ProjectPlanningSectionItem }) { + return ( + + +
+
+ {sectionLabels[section.category]} +

{section.title}

+
+
+ +
+ + + +
+
+
+

+ {section.content || "İçerik eklenmedi."} +

+
+
+ ); +} + +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 ( + + + + + +
+ + {section ? : null} + + + {mode === "create" ? "Planlama alanı ekle" : "Planlama alanını düzenle"} + + + Projenin görev dışı bilgisini yapılandırılmış alanlarda sakla. + + + +
+
+ + +
+
+ + +
+
+ +