feat: implement project management features with cover images and planning sections

- Add ProjectsClient component for managing projects with a grid and list view.
- Introduce project cover image functionality and related fields in the database.
- Create a new table for project planning sections with RLS policies for user access.
- Update global styles for scrollbar customization.
- Enhance Next.js configuration for server actions with increased body size limit.
- Document new database migration for project planning assets.
This commit is contained in:
Poyraz Avsever
2026-06-04 15:45:51 +03:00
parent 367d143f12
commit 35dd88b5af
9 changed files with 1513 additions and 354 deletions
@@ -0,0 +1,695 @@
"use client";
import {
completeProjectRecord,
createProjectRecord,
updateProjectRecord,
} from "@/app/(dashboard)/projects/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 {
CalendarDays,
CheckCircle2,
FolderKanban,
ImageIcon,
LayoutGrid,
List,
Pencil,
Plus,
Target,
Wallet,
} from "lucide-react";
import { useEffect, useState, type ChangeEvent } from "react";
export type ProjectClientOption = {
id: string;
name: string;
};
export type ProjectListItem = {
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_path: string | null;
cover_image_alt: string | null;
coverImageUrl: string | null;
taskCount: number;
doneTaskCount: number;
};
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",
};
type ProjectsClientProps = {
projects: ProjectListItem[];
clients: ProjectClientOption[];
};
export function ProjectsClient({ projects, clients }: ProjectsClientProps) {
const [query, setQuery] = useState("");
const [view, setView] = useState<"grid" | "list">("grid");
const normalizedQuery = query.trim().toLowerCase();
const filteredProjects = normalizedQuery
? projects.filter((project) =>
[project.name, project.description, project.clientName, typeLabels[project.type]]
.filter(Boolean)
.some((value) => value!.toLowerCase().includes(normalizedQuery)),
)
: projects;
const activeCount = projects.filter((project) => project.status === "active").length;
const sideProjectCount = projects.filter((project) => project.type === "side_project").length;
const averageProgress = projects.length
? Math.round(projects.reduce((sum, project) => sum + project.progress, 0) / projects.length)
: 0;
const totalBudget = projects.reduce((sum, project) => sum + (project.budget_amount || 0), 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-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<FolderKanban className="h-4 w-4" />
İş ve side project yönetimi
</div>
<div>
<h1 className="text-3xl font-semibold tracking-normal text-foreground">
Projeler
</h1>
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
Müşteri işleri ve kişisel side projectleri aynı yerde takip et.
</p>
</div>
</div>
<ProjectDialog mode="create" clients={clients} />
</div>
<div className="grid gap-3 md:grid-cols-4">
<StatCard label="Aktif proje" value={activeCount.toString()} icon={FolderKanban} tone="green" />
<StatCard label="Side project" value={sideProjectCount.toString()} icon={Target} tone="blue" />
<StatCard label="Ortalama ilerleme" value={`${averageProgress}%`} icon={CheckCircle2} tone="amber" />
<StatCard label="Toplam bütçe" value={formatCurrency(totalBudget)} icon={Wallet} tone="red" />
</div>
<Card>
<CardContent className="space-y-4 p-4">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div>
<h2 className="text-base font-semibold text-foreground">Proje listesi</h2>
<p className="text-sm text-muted-foreground">
{filteredProjects.length} kayıt görüntüleniyor.
</p>
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Proje, müşteri veya açıklama ara"
className="sm:w-80"
/>
<div className="flex rounded-sm border border-border p-1">
<Button
type="button"
variant={view === "grid" ? "default" : "ghost"}
className="h-8 gap-2 px-3"
onClick={() => setView("grid")}
>
<LayoutGrid className="h-4 w-4" />
Kart
</Button>
<Button
type="button"
variant={view === "list" ? "default" : "ghost"}
className="h-8 gap-2 px-3"
onClick={() => setView("list")}
>
<List className="h-4 w-4" />
Liste
</Button>
</div>
</div>
</div>
{filteredProjects.length > 0 ? (
view === "grid" ? (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{filteredProjects.map((project) => (
<ProjectCard key={project.id} project={project} clients={clients} />
))}
</div>
) : (
<div className="overflow-hidden rounded-sm border border-border">
<div className="hidden grid-cols-[1.5fr_1fr_1fr_0.8fr_1fr] gap-4 border-b border-border bg-muted/40 px-4 py-3 text-xs font-medium uppercase text-muted-foreground lg:grid">
<span>Proje</span>
<span>Tür</span>
<span>Durum</span>
<span>İlerleme</span>
<span className="text-right">İşlem</span>
</div>
<div className="divide-y divide-border">
{filteredProjects.map((project) => (
<ProjectRow key={project.id} project={project} clients={clients} />
))}
</div>
</div>
)
) : (
<EmptyState hasQuery={Boolean(normalizedQuery)} />
)}
</CardContent>
</Card>
</div>
);
}
function ProjectCard({
project,
clients,
}: {
project: ProjectListItem;
clients: ProjectClientOption[];
}) {
return (
<Card className="transition-colors hover:border-primary/30">
<CardContent className="flex h-full flex-col gap-5 p-5">
<ProjectCover project={project} />
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h3 className="truncate text-lg font-semibold text-foreground">{project.name}</h3>
<p className="mt-1 line-clamp-2 text-sm text-muted-foreground">
{project.description || "Açıklama eklenmedi."}
</p>
</div>
<Badge className={statusClasses[project.status]}>{statusLabels[project.status]}</Badge>
</div>
<ProjectMeta project={project} />
<ProgressBar progress={project.progress} />
<div className="mt-auto flex items-center justify-between gap-2 border-t border-border pt-4">
<div className="text-xs text-muted-foreground">
{project.doneTaskCount}/{project.taskCount} görev tamamlandı
</div>
<ProjectActions project={project} clients={clients} />
</div>
</CardContent>
</Card>
);
}
function ProjectCover({ project }: { project: ProjectListItem }) {
if (project.coverImageUrl) {
return (
<div className="aspect-[16/9] overflow-hidden rounded-sm border border-border bg-muted">
<img
src={project.coverImageUrl}
alt={project.cover_image_alt || project.name}
className="h-full w-full object-cover"
/>
</div>
);
}
return (
<div className="flex aspect-[16/9] items-center justify-center rounded-sm border border-dashed border-border bg-muted/30 text-sm text-muted-foreground">
Kapak görseli yok
</div>
);
}
function ProjectRow({
project,
clients,
}: {
project: ProjectListItem;
clients: ProjectClientOption[];
}) {
return (
<div className="grid gap-4 px-4 py-4 lg:grid-cols-[1.5fr_1fr_1fr_0.8fr_1fr] lg:items-center">
<div className="min-w-0">
<div className="font-medium text-foreground">{project.name}</div>
<div className="truncate text-sm text-muted-foreground">
{project.clientName || "Bağımsız side project"}
</div>
</div>
<div className="text-sm text-muted-foreground">{typeLabels[project.type]}</div>
<div>
<Badge className={statusClasses[project.status]}>{statusLabels[project.status]}</Badge>
</div>
<div>
<ProgressBar progress={project.progress} compact />
</div>
<div className="flex justify-start lg:justify-end">
<ProjectActions project={project} clients={clients} />
</div>
</div>
);
}
function ProjectMeta({ project }: { project: ProjectListItem }) {
return (
<div className="grid gap-2 text-sm text-muted-foreground">
<div>{typeLabels[project.type]}</div>
<div>{project.clientName || "Müşteri bağlantısı yok"}</div>
<div className="flex items-center gap-2">
<CalendarDays className="h-4 w-4" />
{project.due_date ? formatDate(project.due_date) : "Deadline yok"}
</div>
<div>{project.budget_amount ? formatCurrency(project.budget_amount) : "Bütçe yok"}</div>
</div>
);
}
function ProjectActions({
project,
clients,
}: {
project: ProjectListItem;
clients: ProjectClientOption[];
}) {
return (
<div className="flex gap-2">
<ProjectDialog mode="edit" project={project} clients={clients} />
{project.status !== "completed" ? (
<form action={completeProjectRecord}>
<input type="hidden" name="id" value={project.id} />
<Button type="submit" variant="outline" className="h-9 min-w-24 gap-2 px-3">
<CheckCircle2 className="h-4 w-4" />
Tamamla
</Button>
</form>
) : null}
</div>
);
}
function ProjectDialog({
mode,
project,
clients,
}: {
mode: "create" | "edit";
project?: ProjectListItem;
clients: ProjectClientOption[];
}) {
const [open, setOpen] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [projectType, setProjectType] = useState(project?.type || "client_project");
const action = mode === "create" ? createProjectRecord : updateProjectRecord;
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 min-w-24 gap-2 px-3"
>
{mode === "create" ? <Plus className="h-4 w-4" /> : <Pencil className="h-4 w-4" />}
{mode === "create" ? "Proje ekle" : "Düzenle"}
</Button>
</DialogTrigger>
<DialogContent className="max-h-[min(720px,calc(100dvh-6rem))] overflow-hidden sm:max-w-2xl data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95">
<form action={handleSubmit} className="flex max-h-[min(680px,calc(100dvh-9rem))] flex-col">
{project ? <input type="hidden" name="id" value={project.id} /> : null}
<DialogHeader className="shrink-0 pb-5">
<DialogTitle>{mode === "create" ? "Yeni proje" : "Projeyi düzenle"}</DialogTitle>
<DialogDescription>
Müşteri projelerini ve kişisel side projectleri aynı modelde takip et.
</DialogDescription>
</DialogHeader>
<div className="tiny-scrollbar min-h-0 flex-1 overflow-y-auto pr-2">
<ProjectFormFields
project={project}
clients={clients}
projectType={projectType}
onProjectTypeChange={setProjectType}
/>
</div>
<DialogFooter className="shrink-0 border-t border-border pt-5">
<Button type="submit" disabled={isSubmitting} className="gap-2">
{mode === "create" ? <Plus className="h-4 w-4" /> : <Pencil className="h-4 w-4" />}
{isSubmitting
? "Kaydediliyor"
: mode === "create"
? "Projeyi ekle"
: "Değişiklikleri kaydet"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
function CoverImageInput({ project }: { project?: ProjectListItem }) {
const inputId = `cover-${project?.id || "new"}`;
const [previewUrl, setPreviewUrl] = useState(project?.coverImageUrl || "");
useEffect(() => {
return () => {
if (previewUrl.startsWith("blob:")) {
URL.revokeObjectURL(previewUrl);
}
};
}, [previewUrl]);
function handleFileChange(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
if (!file) {
setPreviewUrl(project?.coverImageUrl || "");
return;
}
const nextPreviewUrl = URL.createObjectURL(file);
setPreviewUrl((currentPreviewUrl) => {
if (currentPreviewUrl.startsWith("blob:")) {
URL.revokeObjectURL(currentPreviewUrl);
}
return nextPreviewUrl;
});
}
return (
<div className="grid gap-3">
<Label htmlFor={inputId}>Kapak görseli</Label>
<label
htmlFor={inputId}
className="group relative flex aspect-[16/7] cursor-pointer items-center justify-center overflow-hidden rounded-sm border border-dashed border-border bg-muted/20 transition-colors hover:border-primary/50 hover:bg-primary/5"
>
{previewUrl ? (
<img
src={previewUrl}
alt={project?.cover_image_alt || project?.name || "Proje kapak görseli önizlemesi"}
className="h-full w-full object-cover"
/>
) : (
<div className="flex flex-col items-center gap-3 text-muted-foreground transition-colors group-hover:text-primary">
<div className="flex h-12 w-12 items-center justify-center rounded-sm bg-background shadow-sm">
<ImageIcon className="h-6 w-6" />
</div>
<div className="text-center">
<div className="text-sm font-medium">Kapak görseli seç</div>
<div className="text-xs">PNG, JPG, WebP veya GIF</div>
</div>
</div>
)}
{previewUrl ? (
<div className="absolute inset-x-0 bottom-0 bg-background/90 px-3 py-2 text-xs text-muted-foreground backdrop-blur">
Görseli değiştirmek için tıkla.
</div>
) : null}
</label>
<Input
id={inputId}
name="cover_image"
type="file"
accept="image/png,image/jpeg,image/webp,image/gif"
className="sr-only"
onChange={handleFileChange}
/>
<div className="grid gap-2">
<Label htmlFor={`cover-alt-${project?.id || "new"}`}>Görsel alt metni</Label>
<Input
id={`cover-alt-${project?.id || "new"}`}
name="cover_image_alt"
defaultValue={project?.cover_image_alt || ""}
placeholder="Görseli kısaca açıkla"
/>
</div>
</div>
);
}
function ProjectFormFields({
project,
clients,
projectType,
onProjectTypeChange,
}: {
project?: ProjectListItem;
clients: ProjectClientOption[];
projectType: ProjectListItem["type"];
onProjectTypeChange: (value: ProjectListItem["type"]) => void;
}) {
return (
<div className="grid gap-4">
<CoverImageInput project={project} />
<div className="grid gap-2">
<Label htmlFor={`name-${project?.id || "new"}`}>Proje adı</Label>
<Input
id={`name-${project?.id || "new"}`}
name="name"
defaultValue={project?.name || ""}
required
placeholder="Örn. Marka web sitesi"
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label>Tür</Label>
<Select
name="type"
value={projectType}
onValueChange={(value) => onProjectTypeChange(value as ProjectListItem["type"])}
>
<SelectTrigger>
<SelectValue placeholder="Tür seç" />
</SelectTrigger>
<SelectContent>
<SelectItem value="client_project">Müşteri projesi</SelectItem>
<SelectItem value="side_project">Side project</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label>Müşteri</Label>
<Select
name="client_id"
defaultValue={project?.client_id || ""}
disabled={projectType === "side_project"}
>
<SelectTrigger>
<SelectValue placeholder="Müşteri seç" />
</SelectTrigger>
<SelectContent>
{clients.map((client) => (
<SelectItem key={client.id} value={client.id}>
{client.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor={`description-${project?.id || "new"}`}>Açıklama</Label>
<Textarea
id={`description-${project?.id || "new"}`}
name="description"
defaultValue={project?.description || ""}
placeholder="Kapsam, hedef veya teslimat notları..."
rows={3}
/>
</div>
<div className="grid gap-4 md:grid-cols-3">
<div className="grid gap-2">
<Label>Durum</Label>
<Select name="status" defaultValue={project?.status || "planning"}>
<SelectTrigger>
<SelectValue placeholder="Durum seç" />
</SelectTrigger>
<SelectContent>
<SelectItem value="planning">Planlama</SelectItem>
<SelectItem value="active">Aktif</SelectItem>
<SelectItem value="paused">Duraklatıldı</SelectItem>
<SelectItem value="completed">Tamamlandı</SelectItem>
<SelectItem value="cancelled">İptal edildi</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor={`start-${project?.id || "new"}`}>Başlangıç</Label>
<Input id={`start-${project?.id || "new"}`} name="start_date" type="date" defaultValue={project?.start_date || ""} />
</div>
<div className="grid gap-2">
<Label htmlFor={`due-${project?.id || "new"}`}>Deadline</Label>
<Input id={`due-${project?.id || "new"}`} name="due_date" type="date" defaultValue={project?.due_date || ""} />
</div>
</div>
<div className="grid gap-4 md:grid-cols-3">
<div className="grid gap-2">
<Label htmlFor={`budget-${project?.id || "new"}`}>Bütçe / beklenen gelir</Label>
<Input
id={`budget-${project?.id || "new"}`}
name="budget_amount"
type="number"
min="0"
step="0.01"
defaultValue={project?.budget_amount ?? ""}
placeholder="0"
/>
</div>
<div className="grid gap-2">
<Label htmlFor={`currency-${project?.id || "new"}`}>Para birimi</Label>
<Input id={`currency-${project?.id || "new"}`} name="currency" defaultValue={project?.currency || "USD"} maxLength={3} />
</div>
<div className="grid gap-2">
<Label htmlFor={`progress-${project?.id || "new"}`}>İlerleme (%)</Label>
<Input
id={`progress-${project?.id || "new"}`}
name="progress"
type="number"
min="0"
max="100"
defaultValue={project?.progress ?? 0}
/>
</div>
</div>
</div>
);
}
function ProgressBar({ progress, compact = false }: { progress: number; compact?: boolean }) {
return (
<div className="space-y-2">
{!compact ? (
<div className="flex justify-between text-xs text-muted-foreground">
<span>İlerleme</span>
<span>{progress}%</span>
</div>
) : null}
<div className={compact ? "h-2 rounded-full bg-muted" : "h-2.5 rounded-full bg-muted"}>
<div
className="h-full rounded-full bg-primary transition-all"
style={{ width: `${Math.min(Math.max(progress, 0), 100)}%` }}
/>
</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>
);
}
function EmptyState({ hasQuery }: { hasQuery: boolean }) {
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">
<FolderKanban className="h-10 w-10 text-muted-foreground" />
<h3 className="mt-4 text-lg font-semibold text-foreground">
{hasQuery ? "Aramana uygun proje yok" : "Henüz proje eklenmedi"}
</h3>
<p className="mt-2 max-w-md text-sm text-muted-foreground">
{hasQuery
? "Arama metnini sadeleştirerek tekrar deneyebilirsin."
: "İlk müşteri projen veya side project kaydınla operasyon akışını kurmaya başlayabilirsin."}
</p>
</div>
);
}
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 formatCurrency(value: number) {
return new Intl.NumberFormat("tr-TR", {
style: "currency",
currency: "USD",
maximumFractionDigits: 0,
}).format(value);
}