From 35dd88b5afa71463b73cef52679b34be10a3aac3 Mon Sep 17 00:00:00 2001 From: Poyraz Avsever Date: Thu, 4 Jun 2026 15:45:51 +0300 Subject: [PATCH] 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. --- ROADMAP.md | 267 ++++++- app/(dashboard)/projects/actions.ts | 212 ++++++ app/(dashboard)/projects/page.tsx | 454 ++++-------- app/(dashboard)/projects/projects-client.tsx | 695 ++++++++++++++++++ app/globals.css | 24 +- docs/database/0003-project-planning-assets.md | 92 +++ docs/database/query-order.md | 1 + next.config.ts | 8 +- .../0003_add_project_planning_assets.sql | 114 +++ 9 files changed, 1513 insertions(+), 354 deletions(-) create mode 100644 app/(dashboard)/projects/actions.ts create mode 100644 app/(dashboard)/projects/projects-client.tsx create mode 100644 docs/database/0003-project-planning-assets.md create mode 100644 supabase/migrations/0003_add_project_planning_assets.sql diff --git a/ROADMAP.md b/ROADMAP.md index 409e15d..6a40b3f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -113,40 +113,61 @@ MVP disi: ### 3. Projeler ve Side Project'ler -**Amac:** Musteri projeleri ve kisisel side project'leri ayni mantikta ama ayri turlerle takip etmek. +**Amaç:** Müşteri projeleri ve kişisel side project'leri aynı temel mantıkla ama ayrı türlerle takip etmek. Proje yönetimi yalnızca görevlerden oluşmaz; proje hedefi, çözdüğü problem, kapsamı, görsel kimliği ve planlama notları da projenin ana parçasıdır. -MVP alanlari: +MVP alanları: -- Proje adi -- Tur: client_project, side_project -- Bagli musteri, sadece client_project icin opsiyonel/zorunlu -- Aciklama +- Proje adı +- Tür: client_project, side_project +- Bağlı müşteri, sadece client_project için opsiyonel/zorunlu +- Kapak görseli, kullanıcının bilgisayarından yüklenir +- Kapak görseli alt metni +- Açıklama - Durum: planning, active, paused, completed, cancelled -- Baslangic tarihi +- Başlangıç tarihi - Deadline -- Butce veya beklenen gelir -- Ilerleme yuzdesi, gorevlerden otomatik hesaplanabilir +- Bütçe veya beklenen gelir +- İlerleme yüzdesi, görevlerden otomatik hesaplanabilir veya manuel güncellenebilir -MVP aksiyonlari: +MVP proje detay alanları: + +- Genel bakış: Projenin kısa özeti, mevcut durum ve önemli notlar +- Çözdüğü problem: Hangi kullanıcı/müşteri problemini çözdüğü +- Amaç ve başarı kriterleri: Proje bittiğinde neyin başarılı kabul edileceği +- Hedef kitle: Projenin kimin için yapıldığı +- Kapsam: Dahil olan ve dahil olmayan işler +- Design system: Renk paleti, tipografi, component notları ve görsel dil +- Renk paleti: Hex değerleri, kullanım rolleri ve notlar +- Tipografi: Font ailesi, başlık/gövde kullanımı ve hiyerarşi notları +- Görsel varlıklar: Kapak görseli ve proje içi görsel referanslar +- Serbest notlar: Projeye özel planlama notları + +MVP aksiyonları: - Proje ekleme -- Proje duzenleme -- Projeye gorev baglama -- Proje durumunu guncelleme +- Proje düzenleme +- Projeye bilgisayardan kapak görseli yükleme +- Proje detay sayfasında görev dışı planlama alanlarını yönetme +- Proje detayında design system alanları ekleme/düzenleme +- Projeye görev bağlama +- Proje durumunu güncelleme +- Projeyi tamamlandı olarak işaretleme Raporlar: -- Aktif proje sayisi -- Geciken proje sayisi -- Proje bazli gorev tamamlama orani -- Musteri projeleri / side project dagilimi +- Aktif proje sayısı +- Geciken proje sayısı +- Proje bazlı görev tamamlama oranı +- Müşteri projeleri / side project dağılımı +- Design system alanı eksik olan aktif projeler +- Amaç veya problem tanımı eksik olan projeler -MVP disi: +MVP dışı: -- Ekip uyeleri -- Dosya yonetimi +- Ekip üyeleri +- Gelişmiş dosya/drive yönetimi - Kanban sprint sistemi -- Gelismis risk analizi +- Gelişmiş risk analizi --- @@ -395,6 +416,7 @@ Minimum tablolar: - `profiles` - `clients` - `projects` +- `project_planning_sections` - `tasks` - `calendar_events` - `finance_transactions` @@ -402,10 +424,12 @@ Minimum tablolar: - `chat_sessions` - `chat_messages` - `app_settings` +- Supabase Storage bucket: `project-assets` Temel iliskiler: - `clients` -> `projects` +- `projects` -> `project_planning_sections` - `projects` -> `tasks` - `clients` -> `finance_transactions` - `projects` -> `finance_transactions` @@ -527,6 +551,11 @@ Isler: - Projeler/side project CRUD - Proje kartlari, filtreler, detay paneli ve form akislari Poyraz UI ile kurulur. - Musteri projesi ve side project ayrimi UI'da net gosterilir. + - Proje kapak gorseli bilgisayardan yuklenebilir; harici gorsel linki MVP icin ana akis olmaz. + - Proje detay sayfasi eklenir. + - Proje detayinda gorevler disinda planlama alanlari yonetilir: genel bakis, cozdugu problem, amac, hedef kitle, kapsam, design system, renk paleti, tipografi, gorsel varliklar ve notlar. + - Design system alaninda renk paleti, tipografi ve component/gorsel dil notlari eklenebilir ve duzenlenebilir. + - Proje detay sayfasi proje icin tek kaynak olur; gorevler sadece bu kaynak icindeki operasyon katmanidir. - Gorevler CRUD - Liste ve basit kanban Poyraz UI bilesenleriyle yeniden tasarlanir. - Durum, oncelik, deadline ve proje iliskisi icin Poyraz UI Badge/Form/Select kullanilir. @@ -549,6 +578,202 @@ Kabul kriteri: --- +## Proje Detay MVP Planı + +Bu bölüm, Faz 2 içindeki Projeler/Side Project modülünün görev takibinden daha geniş bir proje yönetim alanına dönüşmesi için detaylı uygulama planıdır. + +### 1. Liste ve Oluşturma + +Amaç: Kullanıcı projeyi hızlıca oluşturabilsin ve görsel kimliğini ilk anda belirleyebilsin. + +Alanlar: + +- Proje adı +- Tür: müşteri projesi veya side project +- Bağlı müşteri +- Kapak görseli +- Kapak görseli alt metni +- Kısa açıklama +- Durum +- Başlangıç tarihi +- Deadline +- Bütçe / beklenen gelir +- İlerleme yüzdesi + +Davranış: + +- Görsel bilgisayardan seçilir ve `project-assets` bucket'ına yüklenir. +- Görsel yolu `projects.cover_image_path` alanında saklanır. +- Görsel link ile ekleme MVP akışı değildir. +- Görsel yüklenmezse proje kartında sade placeholder gösterilir. + +### 2. Proje Detay Sayfası + +Amaç: Proje ile ilgili görev dışı tüm planlama bilgisini tek merkezde yönetmek. + +Route önerisi: + +- `/projects/[id]` + +Ana bölümler: + +- Özet +- Planlama +- Design system +- Görevler +- Finans bağlantıları +- Zaman çizelgesi + +İlk MVP için zorunlu bölümler: + +- Özet +- Planlama +- Design system +- Görevler + +### 3. Özet Bölümü + +İçerik: + +- Kapak görseli +- Proje adı +- Müşteri veya side project etiketi +- Durum +- İlerleme +- Deadline +- Bütçe +- Kısa açıklama + +Amaç: + +- Kullanıcı projeye girdiğinde projenin ne olduğunu ve hangi durumda olduğunu ilk bakışta anlar. + +### 4. Planlama Bölümü + +Bu bölüm `project_planning_sections` tablosundan beslenir. + +MVP kategorileri: + +- Genel bakış (`overview`) +- Çözdüğü problem (`problem`) +- Amaç (`goal`) +- Hedef kitle (`audience`) +- Kapsam (`scope`) +- Notlar (`notes`) + +Her kategori için: + +- Başlık +- Açıklama / içerik +- Sıralama +- Opsiyonel metadata + +Kullanım: + +- Kullanıcı kategori ekleyebilir. +- Kullanıcı kategori düzenleyebilir. +- Kullanıcı kategori silebilir. +- Kategoriler proje detayında ayrı kartlar halinde gösterilir. + +### 5. Design System Bölümü + +Bu bölüm de `project_planning_sections` tablosunu kullanır. + +MVP kategorileri: + +- Design system (`design_system`) +- Renk paleti (`color_palette`) +- Tipografi (`typography`) +- Görsel varlıklar (`assets`) + +Design system alanları: + +- Genel görsel dil +- Kullanılacak UI kit veya referans sistem +- Component notları +- Spacing / radius / shadow notları + +Renk paleti alanları: + +- Renk adı +- Hex değeri +- Kullanım rolü: primary, secondary, accent, background, text, border +- Not + +Tipografi alanları: + +- Font ailesi +- Başlık kullanımı +- Gövde metni kullanımı +- Boyut/hiyerarşi notları + +MVP yaklaşımı: + +- İlk sürümde renk paleti ve tipografi structured JSON metadata içinde tutulabilir. +- UI tarafında bu metadata sade form alanlarıyla düzenlenir. +- Gerekirse MVP sonrası `project_colors` ve `project_typography_tokens` gibi ayrı tablolara bölünebilir. + +### 6. Görevler Bölümü + +Amaç: + +- Projeye bağlı görevleri proje detayından da yönetebilmek. + +MVP kapsamı: + +- Projeye bağlı görevleri listeleme +- Yeni görev ekleme +- Görev durumunu güncelleme +- Görevi tamamlandı işaretleme + +Bu bölüm görev modülüyle aynı veriyi kullanır; ayrı bir görev sistemi kurulmaz. + +### 7. Görsel Varlık Yönetimi + +MVP kapsamı: + +- Proje kapak görseli yükleme +- Kapak görselini değiştirme +- Kapak görseli alt metni düzenleme + +MVP dışı: + +- Çoklu dosya klasör yapısı +- Versiyonlama +- Dosya yorumları +- Gelişmiş medya kütüphanesi + +Storage kuralı: + +- Bucket: `project-assets` +- Path: `/projects//` +- Bucket private olur. +- Uygulama görsel gösterirken signed URL üretir. + +### 8. Uygulama Sırası + +1. Database migration: `0003_add_project_planning_assets.sql` +2. Proje formuna kapak görseli yükleme alanı ekle +3. Proje kartlarında kapak görseli göster +4. `/projects/[id]` detay sayfasını oluştur +5. Proje detay özet bölümünü gerçek veriyle kur +6. `project_planning_sections` CRUD action'larını ekle +7. Planlama bölümü kartlarını ekle +8. Design system bölümünü ekle +9. Projeye bağlı görevler bölümünü detay sayfasına bağla +10. Dashboard raporlarında design system/problem/amaç eksikliği gibi proje sağlık sinyallerini kullan + +### 9. Kabul Kriterleri + +- Kullanıcı proje oluştururken bilgisayarından kapak görseli yükleyebilir. +- Proje kartında ve detay sayfasında kapak görseli görünür. +- Kullanıcı proje detayında görevler dışında proje planlama alanlarını yönetebilir. +- Kullanıcı design system, renk paleti ve tipografi notlarını ekleyebilir. +- Tüm veriler kullanıcı bazlı RLS ile korunur. +- `npm run build` başarılı olur. + +--- + ### Faz 3: Dashboard ve Raporlama Amac: Girilen verileri anlamli grafiklere ve rapor kartlarina donusturmek. diff --git a/app/(dashboard)/projects/actions.ts b/app/(dashboard)/projects/actions.ts new file mode 100644 index 0000000..312fa33 --- /dev/null +++ b/app/(dashboard)/projects/actions.ts @@ -0,0 +1,212 @@ +"use server"; + +import { createClient } from "@/lib/supabase/server"; +import { randomUUID } from "crypto"; +import { revalidatePath } from "next/cache"; + +const PROJECT_TYPES = ["client_project", "side_project"] as const; +const PROJECT_STATUSES = ["planning", "active", "paused", "completed", "cancelled"] as const; +const PROJECT_ASSETS_BUCKET = "project-assets"; + +function cleanText(value: FormDataEntryValue | null) { + const text = typeof value === "string" ? value.trim() : ""; + return text.length > 0 ? text : null; +} + +function readProjectType(value: FormDataEntryValue | null) { + const type = typeof value === "string" ? value : "client_project"; + return PROJECT_TYPES.includes(type as (typeof PROJECT_TYPES)[number]) + ? type + : "client_project"; +} + +function readProjectStatus(value: FormDataEntryValue | null) { + const status = typeof value === "string" ? value : "planning"; + return PROJECT_STATUSES.includes(status as (typeof PROJECT_STATUSES)[number]) + ? status + : "planning"; +} + +function readNumber(value: FormDataEntryValue | null) { + const number = Number(typeof value === "string" ? value.replace(",", ".") : value); + return Number.isFinite(number) ? number : null; +} + +function readProgress(value: FormDataEntryValue | null) { + const progress = Math.round(readNumber(value) ?? 0); + return Math.min(Math.max(progress, 0), 100); +} + +async function getCurrentUserId() { + const supabase = await createClient(); + const { + data: { user }, + error, + } = await supabase.auth.getUser(); + + if (error || !user) { + throw new Error("Proje işlemi için giriş yapmış kullanıcı bulunamadı."); + } + + return { supabase, userId: user.id }; +} + +function readPayload(formData: FormData) { + const type = readProjectType(formData.get("type")); + const clientId = cleanText(formData.get("client_id")); + + return { + name: cleanText(formData.get("name")), + type, + client_id: type === "client_project" ? clientId : null, + description: cleanText(formData.get("description")), + status: readProjectStatus(formData.get("status")), + start_date: cleanText(formData.get("start_date")), + due_date: cleanText(formData.get("due_date")), + budget_amount: readNumber(formData.get("budget_amount")), + currency: cleanText(formData.get("currency")) || "USD", + progress: readProgress(formData.get("progress")), + cover_image_alt: cleanText(formData.get("cover_image_alt")), + }; +} + +function readImageFile(formData: FormData) { + const file = formData.get("cover_image"); + + if (!(file instanceof File) || file.size === 0) { + return null; + } + + if (!file.type.startsWith("image/")) { + throw new Error("Kapak görseli bir görsel dosyası olmalıdır."); + } + + return file; +} + +function sanitizeFileName(name: string) { + return name + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 120); +} + +async function uploadCoverImage({ + supabase, + userId, + projectId, + formData, +}: { + supabase: Awaited>; + userId: string; + projectId: string; + formData: FormData; +}) { + const file = readImageFile(formData); + + if (!file) { + return null; + } + + const fileName = `${Date.now()}-${sanitizeFileName(file.name) || "cover-image"}`; + const path = `${userId}/projects/${projectId}/${fileName}`; + const { error } = await supabase.storage + .from(PROJECT_ASSETS_BUCKET) + .upload(path, file, { + cacheControl: "3600", + contentType: file.type, + upsert: true, + }); + + if (error) { + throw new Error(`Kapak görseli yüklenemedi: ${error.message}`); + } + + return path; +} + +export async function createProjectRecord(formData: FormData) { + const { supabase, userId } = await getCurrentUserId(); + const projectId = randomUUID(); + const payload = readPayload(formData); + + if (!payload.name) { + throw new Error("Proje adı zorunludur."); + } + + const coverImagePath = await uploadCoverImage({ + supabase, + userId, + projectId, + formData, + }); + + const { error } = await supabase.from("projects").insert({ + id: projectId, + user_id: userId, + ...payload, + cover_image_path: coverImagePath, + }); + + if (error) { + throw new Error(`Proje eklenemedi: ${error.message}`); + } + + revalidatePath("/projects"); +} + +export async function updateProjectRecord(formData: FormData) { + const { supabase, userId } = await getCurrentUserId(); + const id = cleanText(formData.get("id")); + const payload = readPayload(formData); + + if (!id || !payload.name) { + throw new Error("Proje güncellemek için proje adı ve kayıt kimliği zorunludur."); + } + + const coverImagePath = await uploadCoverImage({ + supabase, + userId, + projectId: id, + formData, + }); + + const updatePayload = { + ...payload, + ...(coverImagePath ? { cover_image_path: coverImagePath } : {}), + }; + + const { error } = await supabase + .from("projects") + .update(updatePayload) + .eq("id", id) + .eq("user_id", userId); + + if (error) { + throw new Error(`Proje güncellenemedi: ${error.message}`); + } + + revalidatePath("/projects"); +} + +export async function completeProjectRecord(formData: FormData) { + const { supabase, userId } = await getCurrentUserId(); + const id = cleanText(formData.get("id")); + + if (!id) { + throw new Error("Tamamlanacak proje bulunamadı."); + } + + const { error } = await supabase + .from("projects") + .update({ status: "completed", progress: 100 }) + .eq("id", id) + .eq("user_id", userId); + + if (error) { + throw new Error(`Proje tamamlanamadı: ${error.message}`); + } + + revalidatePath("/projects"); +} diff --git a/app/(dashboard)/projects/page.tsx b/app/(dashboard)/projects/page.tsx index 9f00dad..bbe9a28 100644 --- a/app/(dashboard)/projects/page.tsx +++ b/app/(dashboard)/projects/page.tsx @@ -1,337 +1,139 @@ -"use client"; +import { + ProjectsClient, + type ProjectClientOption, + type ProjectListItem, +} from "@/app/(dashboard)/projects/projects-client"; +import { createClient } from "@/lib/supabase/server"; -import { useState } from "react"; -import { - Plus, Search, MoreHorizontal, MessageSquare, Clock, - CheckCircle2, AlertCircle, Layout, List, ArrowRight, - TrendingUp, Users, Calendar, Activity, X, ChevronRight, - Zap, Shield, Globe, Briefcase, Brain -} from "lucide-react"; -import { motion, AnimatePresence } from "framer-motion"; +type ProjectRow = { + id: string; + user_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; +}; -// Mock Data -const projects = [ - { - id: 1, - name: "Marketing Site", - description: "Main landing page and marketing funnel optimizations for the Q3 campaign.", - progress: 75, - members: 4, - tasks: 24, - deadline: "May 25", - status: "Active", - health: "Good", - phases: [ - { name: "Design", status: "Done", date: "Apr 15" }, - { name: "Development", status: "In Progress", date: "May 10" }, - { name: "QA", status: "Upcoming", date: "May 20" }, - ], - aiRisk: "Low risk. Team velocity is 12% higher than average. Content approval is the only potential bottleneck." - }, - { - id: 2, - name: "Infrastructure", - description: "Database and cloud server optimizations to support scaling to 100k users.", - progress: 45, - members: 2, - tasks: 12, - deadline: "Jun 10", - status: "Delayed", - health: "At Risk", - phases: [ - { name: "Migration", status: "In Progress", date: "May 12" }, - { name: "Load Testing", status: "Upcoming", date: "May 25" }, - { name: "Live Deploy", status: "Upcoming", date: "Jun 05" }, - ], - aiRisk: "High risk. Lead developer is over-allocated. Database migration scripts need optimization for the large dataset." - }, - { id: 3, name: "Cognis Mobile", description: "React Native application for iOS/Android including push notifications.", progress: 92, members: 6, tasks: 48, deadline: "May 18", status: "Active", health: "Excell.", phases: [], aiRisk: "" }, - { id: 4, name: "Research", description: "User interview synthesis and competitor analysis for market expansion.", progress: 100, members: 3, tasks: 8, deadline: "May 05", status: "Completed", health: "Stable", phases: [], aiRisk: "" }, - { id: 5, name: "API v2", description: "Restructuring the core API for scale and performance improvements.", progress: 30, members: 5, tasks: 32, deadline: "Jul 15", status: "Active", health: "Good", phases: [], aiRisk: "" }, -]; +type TaskRow = { + project_id: string | null; + status: string | null; +}; -export default function ProjectsPage() { - const [viewMode, setViewMode] = useState<"grid" | "list">("grid"); - const [selectedProject, setSelectedProject] = useState(null); +export default async function ProjectsPage() { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); - return ( -
- - {/* Top Header */} -
-

- Management / Projects -

-
-
- - -
- -
-
+ if (!user) { + return null; + } - {/* Analytics Overview Cards */} -
- - - -
+ const [{ data: projectRows }, { data: clientRows }, { data: taskRows }] = + await Promise.all([ + supabase + .from("projects") + .select( + "id, user_id, client_id, name, type, description, status, start_date, due_date, budget_amount, currency, progress, cover_image_path, cover_image_alt, clients(name)", + ) + .eq("user_id", user.id) + .order("created_at", { ascending: false }), + supabase + .from("clients") + .select("id, name") + .eq("user_id", user.id) + .neq("status", "archived") + .order("name", { ascending: true }), + supabase.from("tasks").select("project_id, status").eq("user_id", user.id), + ]); - {/* View Toggle */} -
-
- - -
-
- - {/* Projects Container */} -
- {viewMode === "grid" ? ( -
- {projects.map((project) => ( - setSelectedProject(project)} - className="bg-[#0A0710] border border-white/5 rounded-sm p-6 flex flex-col gap-6 relative group overflow-hidden shadow-2xl cursor-pointer hover:border-primary/30 transition-all" - > -
- -
-
-

{project.name}

-

{project.description}

-
- -
- -
-
- Global Progress - {project.progress}% -
-
- -
-
- -
-
-
- -
- {project.deadline} -
-
-
- {[1, 2, 3].map(i => ( -
U{i}
- ))} -
- {project.members} Team -
-
- -
- - {project.health} - - -
- - ))} -
- ) : ( -
-
-
Project Name
-
Health Status
-
Progress Metrics
-
Final Deadline
-
-
-
- {projects.map(project => ( -
setSelectedProject(project)} className="grid grid-cols-12 gap-4 p-5 items-center hover:bg-white/[0.02] transition-colors group cursor-pointer"> -
-
{project.name}
-
{project.tasks} Active Deliverables
-
-
- - {project.health} - -
-
-
- {project.progress}% Complete -
-
-
-
-
-
{project.deadline}
-
- -
-
- ))} -
-
- )} -
- - {/* Project Detail Sheet */} - - {selectedProject && ( - <> - setSelectedProject(null)} className="fixed inset-0 bg-black/80 backdrop-blur-md z-[100]" /> - - -
-
-
- - {selectedProject.health} Health - - Due: {selectedProject.deadline} -
-

{selectedProject.name}

-
- -
- -
- - {/* Description & KPI */} -
-
-

Overview

-

{selectedProject.description}

-
-
-
{selectedProject.progress}%
-
Global Progress
-
-
- - {/* AI Risk Intelligence */} - {selectedProject.aiRisk && ( -
-
- -
-

- AI Risk Assessment -

-

- "{selectedProject.aiRisk}" -

-
- )} - - {/* Timeline / Phases */} -
-

Project Roadmap

-
- {selectedProject.phases?.map((phase: any, i: number) => ( -
-
-
-
-
{phase.name}
-
{phase.date} • {phase.status}
-
- {phase.status === 'Done' && } -
-
- ))} -
-
- - {/* Team & Resources */} -
-
-

Team Allocation

- -
-
- {[1, 2, 3, 4].slice(0, selectedProject.members).map(i => ( -
-
U{i}
-
-
Project Member {i}
-
Strategic Lead
-
-
- ))} -
-
- -
- -
- - -
- - - )} - - -
+ const taskStats = countTasksByProject((taskRows || []) as TaskRow[]); + const clients = (clientRows || []) as ProjectClientOption[]; + const signedUrls = await createProjectImageUrls( + supabase, + ((projectRows || []) as unknown as ProjectRow[]) + .map((project) => project.cover_image_path) + .filter(Boolean) as string[], ); + + const projects: ProjectListItem[] = ((projectRows || []) as unknown as ProjectRow[]).map((project) => { + const stats = taskStats.get(project.id) || { total: 0, done: 0 }; + + return { + id: project.id, + client_id: project.client_id, + clientName: getClientName(project.clients), + name: project.name, + type: project.type, + description: project.description, + status: project.status, + start_date: project.start_date, + due_date: project.due_date, + budget_amount: project.budget_amount === null ? null : Number(project.budget_amount), + currency: project.currency, + progress: project.progress, + cover_image_path: project.cover_image_path, + cover_image_alt: project.cover_image_alt, + coverImageUrl: project.cover_image_path ? signedUrls.get(project.cover_image_path) || null : null, + taskCount: stats.total, + doneTaskCount: stats.done, + }; + }); + + return ; } -function ProjectStatCard({ label, value, subtext, icon: Icon }: any) { - return ( -
-
- -
-
- -
-
-
{label}
-
- {value} - {subtext} -
-
-
+async function createProjectImageUrls( + supabase: Awaited>, + paths: string[], +) { + const urls = new Map(); + const uniquePaths = Array.from(new Set(paths)); + + await Promise.all( + uniquePaths.map(async (path) => { + const { data } = await supabase.storage + .from("project-assets") + .createSignedUrl(path, 60 * 15); + + if (data?.signedUrl) { + urls.set(path, data.signedUrl); + } + }), ); + + return urls; +} + +function getClientName(client: ProjectRow["clients"]) { + if (!client) return null; + return Array.isArray(client) ? client[0]?.name || null : client.name; +} + +function countTasksByProject(tasks: TaskRow[]) { + const statsByProject = new Map(); + + for (const task of tasks) { + if (!task.project_id) continue; + + const current = statsByProject.get(task.project_id) || { total: 0, done: 0 }; + current.total += 1; + + if (task.status === "done") { + current.done += 1; + } + + statsByProject.set(task.project_id, current); + } + + return statsByProject; } diff --git a/app/(dashboard)/projects/projects-client.tsx b/app/(dashboard)/projects/projects-client.tsx new file mode 100644 index 0000000..361a4af --- /dev/null +++ b/app/(dashboard)/projects/projects-client.tsx @@ -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 ( +
+
+
+
+ + İş ve side project yönetimi +
+
+

+ Projeler +

+

+ Müşteri işleri ve kişisel side projectleri aynı yerde takip et. +

+
+
+ + +
+ +
+ + + + +
+ + + +
+
+

Proje listesi

+

+ {filteredProjects.length} kayıt görüntüleniyor. +

+
+
+ setQuery(event.target.value)} + placeholder="Proje, müşteri veya açıklama ara" + className="sm:w-80" + /> +
+ + +
+
+
+ + {filteredProjects.length > 0 ? ( + view === "grid" ? ( +
+ {filteredProjects.map((project) => ( + + ))} +
+ ) : ( +
+
+ Proje + Tür + Durum + İlerleme + İşlem +
+
+ {filteredProjects.map((project) => ( + + ))} +
+
+ ) + ) : ( + + )} +
+
+
+ ); +} + +function ProjectCard({ + project, + clients, +}: { + project: ProjectListItem; + clients: ProjectClientOption[]; +}) { + return ( + + + +
+
+

{project.name}

+

+ {project.description || "Açıklama eklenmedi."} +

+
+ {statusLabels[project.status]} +
+ + + + +
+
+ {project.doneTaskCount}/{project.taskCount} görev tamamlandı +
+ +
+
+
+ ); +} + +function ProjectCover({ project }: { project: ProjectListItem }) { + if (project.coverImageUrl) { + return ( +
+ {project.cover_image_alt +
+ ); + } + + return ( +
+ Kapak görseli yok +
+ ); +} + +function ProjectRow({ + project, + clients, +}: { + project: ProjectListItem; + clients: ProjectClientOption[]; +}) { + return ( +
+
+
{project.name}
+
+ {project.clientName || "Bağımsız side project"} +
+
+
{typeLabels[project.type]}
+
+ {statusLabels[project.status]} +
+
+ +
+
+ +
+
+ ); +} + +function ProjectMeta({ project }: { project: ProjectListItem }) { + return ( +
+
{typeLabels[project.type]}
+
{project.clientName || "Müşteri bağlantısı yok"}
+
+ + {project.due_date ? formatDate(project.due_date) : "Deadline yok"} +
+
{project.budget_amount ? formatCurrency(project.budget_amount) : "Bütçe yok"}
+
+ ); +} + +function ProjectActions({ + project, + clients, +}: { + project: ProjectListItem; + clients: ProjectClientOption[]; +}) { + return ( +
+ + {project.status !== "completed" ? ( +
+ + +
+ ) : null} +
+ ); +} + +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 ( + + + + + +
+ {project ? : null} + + {mode === "create" ? "Yeni proje" : "Projeyi düzenle"} + + Müşteri projelerini ve kişisel side projectleri aynı modelde takip et. + + + +
+ +
+ + + + +
+
+
+ ); +} + +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) { + 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 ( +
+ + + +
+ + +
+
+ ); +} + +function ProjectFormFields({ + project, + clients, + projectType, + onProjectTypeChange, +}: { + project?: ProjectListItem; + clients: ProjectClientOption[]; + projectType: ProjectListItem["type"]; + onProjectTypeChange: (value: ProjectListItem["type"]) => void; +}) { + return ( +
+ + +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ +