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
This commit is contained in:
@@ -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 (
|
||||
<ProjectDetailClient
|
||||
project={project}
|
||||
sections={sections}
|
||||
tasks={tasks}
|
||||
financeTransactions={financeTransactions}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
async function createProjectImageUrl(
|
||||
supabase: Awaited<ReturnType<typeof createClient>>,
|
||||
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";
|
||||
}
|
||||
@@ -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<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);
|
||||
}
|
||||
Reference in New Issue
Block a user