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
+246 -21
View File
@@ -113,40 +113,61 @@ MVP disi:
### 3. Projeler ve Side Project'ler ### 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 - Proje adı
- Tur: client_project, side_project - Tür: client_project, side_project
- Bagli musteri, sadece client_project icin opsiyonel/zorunlu - Bağlı müşteri, sadece client_project için opsiyonel/zorunlu
- Aciklama - Kapak görseli, kullanıcının bilgisayarından yüklenir
- Kapak görseli alt metni
- Açıklama
- Durum: planning, active, paused, completed, cancelled - Durum: planning, active, paused, completed, cancelled
- Baslangic tarihi - Başlangıç tarihi
- Deadline - Deadline
- Butce veya beklenen gelir - Bütçe veya beklenen gelir
- Ilerleme yuzdesi, gorevlerden otomatik hesaplanabilir - İ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 ekleme
- Proje duzenleme - Proje düzenleme
- Projeye gorev baglama - Projeye bilgisayardan kapak görseli yükleme
- Proje durumunu guncelleme - 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: Raporlar:
- Aktif proje sayisi - Aktif proje sayısı
- Geciken proje sayisi - Geciken proje sayısı
- Proje bazli gorev tamamlama orani - Proje bazlı görev tamamlama oranı
- Musteri projeleri / side project dagilimi - 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 - Ekip üyeleri
- Dosya yonetimi - Gelişmiş dosya/drive yönetimi
- Kanban sprint sistemi - Kanban sprint sistemi
- Gelismis risk analizi - Gelişmiş risk analizi
--- ---
@@ -395,6 +416,7 @@ Minimum tablolar:
- `profiles` - `profiles`
- `clients` - `clients`
- `projects` - `projects`
- `project_planning_sections`
- `tasks` - `tasks`
- `calendar_events` - `calendar_events`
- `finance_transactions` - `finance_transactions`
@@ -402,10 +424,12 @@ Minimum tablolar:
- `chat_sessions` - `chat_sessions`
- `chat_messages` - `chat_messages`
- `app_settings` - `app_settings`
- Supabase Storage bucket: `project-assets`
Temel iliskiler: Temel iliskiler:
- `clients` -> `projects` - `clients` -> `projects`
- `projects` -> `project_planning_sections`
- `projects` -> `tasks` - `projects` -> `tasks`
- `clients` -> `finance_transactions` - `clients` -> `finance_transactions`
- `projects` -> `finance_transactions` - `projects` -> `finance_transactions`
@@ -527,6 +551,11 @@ Isler:
- Projeler/side project CRUD - Projeler/side project CRUD
- Proje kartlari, filtreler, detay paneli ve form akislari Poyraz UI ile kurulur. - Proje kartlari, filtreler, detay paneli ve form akislari Poyraz UI ile kurulur.
- Musteri projesi ve side project ayrimi UI'da net gosterilir. - 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 - Gorevler CRUD
- Liste ve basit kanban Poyraz UI bilesenleriyle yeniden tasarlanir. - Liste ve basit kanban Poyraz UI bilesenleriyle yeniden tasarlanir.
- Durum, oncelik, deadline ve proje iliskisi icin Poyraz UI Badge/Form/Select kullanilir. - 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: `<user_id>/projects/<project_id>/<file_name>`
- 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 ### Faz 3: Dashboard ve Raporlama
Amac: Girilen verileri anlamli grafiklere ve rapor kartlarina donusturmek. Amac: Girilen verileri anlamli grafiklere ve rapor kartlarina donusturmek.
+212
View File
@@ -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<ReturnType<typeof createClient>>;
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");
}
+127 -325
View File
@@ -1,337 +1,139 @@
"use client";
import { useState } from "react";
import { import {
Plus, Search, MoreHorizontal, MessageSquare, Clock, ProjectsClient,
CheckCircle2, AlertCircle, Layout, List, ArrowRight, type ProjectClientOption,
TrendingUp, Users, Calendar, Activity, X, ChevronRight, type ProjectListItem,
Zap, Shield, Globe, Briefcase, Brain } from "@/app/(dashboard)/projects/projects-client";
} from "lucide-react"; import { createClient } from "@/lib/supabase/server";
import { motion, AnimatePresence } from "framer-motion";
// Mock Data type ProjectRow = {
const projects = [ id: string;
{ user_id: string;
id: 1, client_id: string | null;
name: "Marketing Site", name: string;
description: "Main landing page and marketing funnel optimizations for the Q3 campaign.", type: "client_project" | "side_project";
progress: 75, description: string | null;
members: 4, status: "planning" | "active" | "paused" | "completed" | "cancelled";
tasks: 24, start_date: string | null;
deadline: "May 25", due_date: string | null;
status: "Active", budget_amount: number | string | null;
health: "Good", currency: string;
phases: [ progress: number;
{ name: "Design", status: "Done", date: "Apr 15" }, cover_image_path: string | null;
{ name: "Development", status: "In Progress", date: "May 10" }, cover_image_alt: string | null;
{ name: "QA", status: "Upcoming", date: "May 20" }, clients: { name: string } | { name: string }[] | null;
], };
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: "" },
];
export default function ProjectsPage() { type TaskRow = {
const [viewMode, setViewMode] = useState<"grid" | "list">("grid"); project_id: string | null;
const [selectedProject, setSelectedProject] = useState<any>(null); status: string | null;
};
return ( export default async function ProjectsPage() {
<div className="mx-auto max-w-7xl animate-in fade-in slide-in-from-bottom-4 duration-500 h-full flex flex-col text-foreground font-sans space-y-6 pb-12 relative"> const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
{/* Top Header */} if (!user) {
<div className="flex items-center justify-between pb-4 border-b border-white/5 mt-4 shrink-0"> return null;
<h1 className="text-lg font-medium text-muted-foreground"> }
<span className="text-foreground">Management</span> / Projects
</h1>
<div className="flex items-center gap-3">
<div className="bg-[#150F1D] border border-white/5 rounded-sm px-3 py-1.5 flex items-center gap-2">
<Search className="h-4 w-4 text-muted-foreground" />
<input
type="text"
placeholder="Search projects..."
className="bg-transparent border-none outline-none text-xs w-48 placeholder:text-muted-foreground/50 text-foreground"
/>
</div>
<button className="bg-primary hover:bg-primary/90 text-primary-foreground border border-primary/20 px-4 py-1.5 rounded-sm text-xs font-bold tracking-widest flex items-center gap-2 transition-all shadow-lg shadow-primary/20">
<Plus className="h-4 w-4" />
NEW PROJECT
</button>
</div>
</div>
{/* Analytics Overview Cards */} const [{ data: projectRows }, { data: clientRows }, { data: taskRows }] =
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 shrink-0"> await Promise.all([
<ProjectStatCard label="Active Projects" value="4" subtext="+1 from last month" icon={Activity} /> supabase
<ProjectStatCard label="Average Progress" value="68%" subtext="12% ahead of schedule" icon={TrendingUp} /> .from("projects")
<ProjectStatCard label="Total Members" value="15" subtext="Across 5 project teams" icon={Users} /> .select(
</div> "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 */} const taskStats = countTasksByProject((taskRows || []) as TaskRow[]);
<div className="flex items-center justify-between"> const clients = (clientRows || []) as ProjectClientOption[];
<div className="flex items-center gap-2 bg-[#150F1D] border border-white/5 rounded-sm p-0.5"> const signedUrls = await createProjectImageUrls(
<button onClick={() => setViewMode("grid")} className={`px-3 py-1.5 text-[10px] font-bold uppercase tracking-widest rounded-sm transition-all flex items-center gap-2 ${viewMode === 'grid' ? 'bg-[#2B2538] text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'}`}><Layout className="h-3 w-3" /> Grid</button> supabase,
<button onClick={() => setViewMode("list")} className={`px-3 py-1.5 text-[10px] font-bold uppercase tracking-widest rounded-sm transition-all flex items-center gap-2 ${viewMode === 'list' ? 'bg-[#2B2538] text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'}`}><List className="h-3 w-3" /> List</button> ((projectRows || []) as unknown as ProjectRow[])
</div> .map((project) => project.cover_image_path)
</div> .filter(Boolean) as string[],
{/* Projects Container */}
<div className="flex-1 overflow-y-auto tiny-scrollbar">
{viewMode === "grid" ? (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
{projects.map((project) => (
<motion.div
key={project.id}
whileHover={{ y: -4 }}
onClick={() => 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"
>
<div className={`absolute top-0 right-0 w-32 h-32 translate-x-16 -translate-y-16 opacity-10 blur-3xl rounded-full ${project.status === 'Delayed' ? 'bg-rose-500' : 'bg-primary'}`} />
<div className="flex justify-between items-start relative z-10">
<div className="flex-1 min-w-0">
<h3 className="text-lg font-black truncate group-hover:text-primary transition-colors">{project.name}</h3>
<p className="text-xs text-muted-foreground mt-1 line-clamp-1">{project.description}</p>
</div>
<button className="p-1.5 hover:bg-white/5 rounded-sm transition-colors text-muted-foreground"><MoreHorizontal className="h-4 w-4" /></button>
</div>
<div className="space-y-3">
<div className="flex justify-between text-[10px] font-bold">
<span className="text-muted-foreground uppercase tracking-widest">Global Progress</span>
<span>{project.progress}%</span>
</div>
<div className="h-1 w-full bg-white/5 rounded-full overflow-hidden">
<motion.div
initial={{ width: 0 }}
animate={{ width: `${project.progress}%` }}
transition={{ duration: 1, ease: "easeOut" }}
className={`h-full rounded-full ${project.progress === 100 ? 'bg-emerald-500' : project.status === 'Delayed' ? 'bg-rose-500' : 'bg-primary shadow-[0_0_12px_rgba(108,91,176,0.6)]'}`}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="flex items-center gap-2">
<div className="p-1.5 bg-white/5 rounded-sm">
<Calendar className="h-3 w-3 text-muted-foreground" />
</div>
<span className="text-[10px] font-bold text-muted-foreground">{project.deadline}</span>
</div>
<div className="flex items-center gap-2 justify-end">
<div className="flex -space-x-2">
{[1, 2, 3].map(i => (
<div key={i} className="h-5 w-5 rounded-full border border-[#0A0710] bg-[#1F172B] flex items-center justify-center text-[8px] font-bold">U{i}</div>
))}
</div>
<span className="text-[10px] font-bold text-muted-foreground ml-1">{project.members} Team</span>
</div>
</div>
<div className="pt-4 border-t border-white/5 flex items-center justify-between">
<span className={`text-[9px] font-black uppercase tracking-[0.2em] px-2 py-1 rounded-sm border ${
project.health === 'Good' ? 'text-blue-400 border-blue-500/20 bg-blue-500/5' :
project.health === 'Excell.' ? 'text-emerald-400 border-emerald-500/20 bg-emerald-500/5' :
project.health === 'At Risk' ? 'text-rose-400 border-rose-500/20 bg-rose-500/5' : 'text-muted-foreground border-white/10'
}`}>
{project.health}
</span>
<button className="text-[9px] font-black uppercase tracking-widest flex items-center gap-1 text-primary group/btn">
VIEW DETAILS <ArrowRight className="h-3 w-3 group-hover/btn:translate-x-1 transition-transform" />
</button>
</div>
</motion.div>
))}
</div>
) : (
<div className="bg-[#0A0710] border border-white/5 rounded-sm overflow-hidden">
<div className="grid grid-cols-12 gap-4 p-4 border-b border-white/5 bg-[#0F0B15]/50 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
<div className="col-span-4">Project Name</div>
<div className="col-span-2">Health Status</div>
<div className="col-span-3">Progress Metrics</div>
<div className="col-span-2">Final Deadline</div>
<div className="col-span-1"></div>
</div>
<div className="divide-y divide-white/5">
{projects.map(project => (
<div key={project.id} onClick={() => setSelectedProject(project)} className="grid grid-cols-12 gap-4 p-5 items-center hover:bg-white/[0.02] transition-colors group cursor-pointer">
<div className="col-span-4">
<div className="text-sm font-black group-hover:text-primary transition-colors">{project.name}</div>
<div className="text-[10px] text-muted-foreground mt-0.5 tracking-wider">{project.tasks} Active Deliverables</div>
</div>
<div className="col-span-2">
<span className={`text-[9px] font-black uppercase tracking-widest px-2 py-1 rounded-sm border ${
project.health === 'Good' ? 'text-blue-400 border-blue-500/20 bg-blue-500/5' :
project.health === 'Excell.' ? 'text-emerald-400 border-emerald-500/20 bg-emerald-500/5' : 'text-rose-400 border-rose-500/20 bg-rose-500/5'
}`}>
{project.health}
</span>
</div>
<div className="col-span-3 pr-12">
<div className="flex justify-between text-[10px] font-bold mb-2">
<span className="text-muted-foreground">{project.progress}% Complete</span>
</div>
<div className="h-1.5 w-full bg-white/5 rounded-full overflow-hidden">
<div className={`h-full rounded-full ${project.progress === 100 ? 'bg-emerald-500' : 'bg-primary'}`} style={{ width: `${project.progress}%` }} />
</div>
</div>
<div className="col-span-2 text-xs font-bold text-muted-foreground uppercase">{project.deadline}</div>
<div className="col-span-1 flex justify-end">
<button className="p-1.5 hover:bg-white/10 rounded-sm text-muted-foreground transition-colors"><MoreHorizontal className="h-4 w-4" /></button>
</div>
</div>
))}
</div>
</div>
)}
</div>
{/* Project Detail Sheet */}
<AnimatePresence>
{selectedProject && (
<>
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} onClick={() => setSelectedProject(null)} className="fixed inset-0 bg-black/80 backdrop-blur-md z-[100]" />
<motion.div initial={{ x: "100%" }} animate={{ x: 0 }} exit={{ x: "100%" }} transition={{ type: "spring", damping: 25, stiffness: 200 }} className="fixed top-0 right-0 h-full w-full max-w-2xl bg-[#0A0710] border-l border-white/5 z-[101] shadow-2xl flex flex-col">
<div className="p-10 border-b border-white/5 flex items-center justify-between bg-primary/5">
<div className="space-y-2">
<div className="flex items-center gap-3">
<span className={`text-[10px] font-black uppercase tracking-widest px-2 py-1 rounded-sm border ${
selectedProject.health === 'Good' ? 'text-blue-400 border-blue-500/20' :
selectedProject.health === 'Excell.' ? 'text-emerald-400 border-emerald-500/20' : 'text-rose-400 border-rose-500/20'
}`}>
{selectedProject.health} Health
</span>
<span className="text-[10px] font-bold text-muted-foreground uppercase tracking-widest">Due: {selectedProject.deadline}</span>
</div>
<h2 className="text-3xl font-black tracking-tighter text-foreground">{selectedProject.name}</h2>
</div>
<button onClick={() => setSelectedProject(null)} className="p-3 hover:bg-white/10 rounded-sm text-muted-foreground transition-colors"><X className="h-6 w-6" /></button>
</div>
<div className="flex-1 overflow-y-auto p-10 space-y-12 tiny-scrollbar">
{/* Description & KPI */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
<div className="md:col-span-2 space-y-4">
<h3 className="text-[10px] font-black uppercase tracking-[0.2em] text-primary">Overview</h3>
<p className="text-sm text-muted-foreground leading-relaxed">{selectedProject.description}</p>
</div>
<div className="bg-[#150F1D] border border-white/5 rounded-sm p-6 flex flex-col items-center justify-center text-center">
<div className="text-4xl font-black text-primary mb-1">{selectedProject.progress}%</div>
<div className="text-[9px] font-bold text-muted-foreground uppercase tracking-widest">Global Progress</div>
</div>
</div>
{/* AI Risk Intelligence */}
{selectedProject.aiRisk && (
<div className="rounded-sm border border-primary/20 bg-primary/5 p-6 space-y-3 relative overflow-hidden">
<div className="absolute -right-4 -top-4 opacity-5">
<Brain className="h-20 w-20 text-primary" />
</div>
<h3 className="text-[10px] font-black uppercase tracking-[0.2em] text-primary flex items-center gap-2">
<Brain className="h-3.5 w-3.5" /> AI Risk Assessment
</h3>
<p className="text-xs font-medium text-foreground/90 leading-relaxed italic">
"{selectedProject.aiRisk}"
</p>
</div>
)}
{/* Timeline / Phases */}
<div className="space-y-6">
<h3 className="text-[10px] font-black uppercase tracking-[0.2em] text-primary">Project Roadmap</h3>
<div className="relative space-y-8 pl-6 before:absolute before:left-[11px] before:top-2 before:bottom-2 before:w-px before:bg-white/10">
{selectedProject.phases?.map((phase: any, i: number) => (
<div key={i} className="relative group">
<div className={`absolute -left-[23px] top-1 h-3 w-3 rounded-full border-2 border-[#0A0710] z-10 ${
phase.status === 'Done' ? 'bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.5)]' :
phase.status === 'In Progress' ? 'bg-primary shadow-[0_0_8px_rgba(108,91,176,0.5)]' : 'bg-white/10'
}`} />
<div className="flex justify-between items-start">
<div>
<div className="text-sm font-bold group-hover:text-primary transition-colors">{phase.name}</div>
<div className="text-[10px] text-muted-foreground mt-1 uppercase font-medium">{phase.date} {phase.status}</div>
</div>
{phase.status === 'Done' && <CheckCircle2 className="h-4 w-4 text-emerald-500" />}
</div>
</div>
))}
</div>
</div>
{/* Team & Resources */}
<div className="space-y-6">
<div className="flex justify-between items-center">
<h3 className="text-[10px] font-black uppercase tracking-[0.2em] text-primary">Team Allocation</h3>
<button className="text-[10px] font-bold text-muted-foreground hover:text-foreground">MANAGE TEAM</button>
</div>
<div className="grid grid-cols-2 gap-4">
{[1, 2, 3, 4].slice(0, selectedProject.members).map(i => (
<div key={i} className="p-4 rounded-sm bg-[#150F1D] border border-white/5 flex items-center gap-3">
<div className="h-10 w-10 rounded-sm bg-primary/10 border border-primary/20 flex items-center justify-center font-black text-primary text-xs">U{i}</div>
<div>
<div className="text-xs font-bold text-foreground">Project Member {i}</div>
<div className="text-[10px] text-muted-foreground uppercase tracking-widest mt-1">Strategic Lead</div>
</div>
</div>
))}
</div>
</div>
</div>
<div className="p-10 border-t border-white/5 bg-[#0F0B15]/50 flex gap-4">
<button className="flex-1 bg-primary hover:bg-primary/90 text-primary-foreground py-4 rounded-sm text-[11px] font-black uppercase tracking-widest shadow-xl shadow-primary/30 transition-all active:scale-[0.98]">
LAUNCH PROJECT CONSOLE
</button>
<button className="p-4 border border-white/10 rounded-sm text-muted-foreground hover:bg-white/5 transition-colors">
<Activity className="h-6 w-6" />
</button>
</div>
</motion.div>
</>
)}
</AnimatePresence>
</div>
); );
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 <ProjectsClient projects={projects} clients={clients} />;
} }
function ProjectStatCard({ label, value, subtext, icon: Icon }: any) { async function createProjectImageUrls(
return ( supabase: Awaited<ReturnType<typeof createClient>>,
<div className="bg-[#0A0710] border border-white/5 rounded-sm p-8 flex items-center gap-6 group relative overflow-hidden shadow-xl"> paths: string[],
<div className="absolute top-0 right-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity"> ) {
<Icon className="h-20 w-20 text-foreground" /> const urls = new Map<string, string>();
</div> const uniquePaths = Array.from(new Set(paths));
<div className="p-4 bg-white/5 rounded-sm group-hover:bg-primary/10 transition-colors relative z-10">
<Icon className="h-6 w-6 text-muted-foreground group-hover:text-primary transition-colors" /> await Promise.all(
</div> uniquePaths.map(async (path) => {
<div className="relative z-10"> const { data } = await supabase.storage
<div className="text-[10px] font-black uppercase tracking-[0.2em] text-muted-foreground mb-1">{label}</div> .from("project-assets")
<div className="flex items-baseline gap-2"> .createSignedUrl(path, 60 * 15);
<span className="text-3xl font-black text-foreground tracking-tighter">{value}</span>
<span className="text-[10px] text-emerald-500 font-bold uppercase tracking-widest">{subtext}</span> if (data?.signedUrl) {
</div> urls.set(path, data.signedUrl);
</div> }
</div> }),
); );
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<string, { total: number; done: number }>();
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;
} }
@@ -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);
}
+18 -6
View File
@@ -80,21 +80,33 @@
} }
@layer utilities { @layer utilities {
.tiny-scrollbar::-webkit-scrollbar { * {
width: 4px; scrollbar-width: thin;
height: 4px; scrollbar-color: color-mix(in srgb, var(--poyraz-primary) 45%, transparent) transparent;
} }
*::-webkit-scrollbar,
.tiny-scrollbar::-webkit-scrollbar {
width: 6px;
height: 6px;
}
*::-webkit-scrollbar-track,
.tiny-scrollbar::-webkit-scrollbar-track { .tiny-scrollbar::-webkit-scrollbar-track {
background: transparent; background: transparent;
} }
*::-webkit-scrollbar-thumb,
.tiny-scrollbar::-webkit-scrollbar-thumb { .tiny-scrollbar::-webkit-scrollbar-thumb {
background: color-mix(in srgb, var(--poyraz-primary) 30%, transparent); background: color-mix(in srgb, var(--poyraz-primary) 36%, transparent);
border-radius: 4px; border: 2px solid transparent;
border-radius: 999px;
background-clip: padding-box;
} }
*::-webkit-scrollbar-thumb:hover,
.tiny-scrollbar::-webkit-scrollbar-thumb:hover { .tiny-scrollbar::-webkit-scrollbar-thumb:hover {
background: color-mix(in srgb, var(--poyraz-primary) 55%, transparent); background: color-mix(in srgb, var(--poyraz-primary) 58%, transparent);
background-clip: padding-box;
} }
} }
@@ -0,0 +1,92 @@
# 0003 - Project Planning Assets
SQL file: `supabase/migrations/0003_add_project_planning_assets.sql`
## Purpose
This migration extends project management beyond task tracking.
It adds:
- Project cover image fields on `projects`
- A private Supabase Storage bucket for uploaded project images
- A structured `project_planning_sections` table for project planning categories
## Project Image Fields
The `projects` table receives:
- `cover_image_path`: path of the uploaded file in the `project-assets` bucket
- `cover_image_alt`: optional alt text for the cover image
Images are uploaded from the user's computer, not saved as external image links.
## Storage Bucket
Bucket:
- `project-assets`
Configuration:
- Private bucket
- Max file size: 5 MB
- Allowed MIME types:
- `image/jpeg`
- `image/png`
- `image/webp`
- `image/gif`
Storage object paths must start with the authenticated user id:
```txt
<user_id>/projects/<project_id>/<file_name>
```
This keeps Storage RLS simple and user-scoped.
## Planning Sections
Table:
- `project_planning_sections`
Core fields:
- `user_id`
- `project_id`
- `category`
- `title`
- `content`
- `metadata`
- `sort_order`
Allowed categories:
- `overview`
- `problem`
- `goal`
- `audience`
- `scope`
- `design_system`
- `color_palette`
- `typography`
- `assets`
- `notes`
## RLS
RLS is enabled for `project_planning_sections`.
Users can only select, insert, update, and delete their own planning sections.
Storage policies allow users to select, upload, update, and delete only files under their own user id folder inside `project-assets`.
## Execution
Run after:
1. `supabase/schema.sql`
2. `supabase/migrations/0002_add_freelancer_os_core_tables.sql`
After running this SQL in Supabase, add an execution record to `docs/database/query-log.md`.
+1
View File
@@ -6,6 +6,7 @@ This file is the canonical order of SQL files for database setup and migration.
| --- | --- | --- | --- | | --- | --- | --- | --- |
| 0001 | `supabase/schema.sql` | `docs/database/0001-initial-schema.md` | Baseline registered | | 0001 | `supabase/schema.sql` | `docs/database/0001-initial-schema.md` | Baseline registered |
| 0002 | `supabase/migrations/0002_add_freelancer_os_core_tables.sql` | `docs/database/0002-freelancer-os-core-tables.md` | Pending execution | | 0002 | `supabase/migrations/0002_add_freelancer_os_core_tables.sql` | `docs/database/0002-freelancer-os-core-tables.md` | Pending execution |
| 0003 | `supabase/migrations/0003_add_project_planning_assets.sql` | `docs/database/0003-project-planning-assets.md` | Pending execution |
| seed-0001 | `supabase/seeds/0001_demo_freelancer_os_data.sql` | `docs/database/seed-0001-demo-freelancer-os-data.md` | Optional demo seed, pending execution | | seed-0001 | `supabase/seeds/0001_demo_freelancer_os_data.sql` | `docs/database/seed-0001-demo-freelancer-os-data.md` | Optional demo seed, pending execution |
## How To Add The Next Query ## How To Add The Next Query
+7 -1
View File
@@ -1,5 +1,11 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const nextConfig: NextConfig = {}; const nextConfig: NextConfig = {
experimental: {
serverActions: {
bodySizeLimit: "8mb",
},
},
};
export default nextConfig; export default nextConfig;
@@ -0,0 +1,114 @@
-- 0003: Add project planning sections and project visual storage
-- Run after: supabase/migrations/0002_add_freelancer_os_core_tables.sql
alter table public.projects
add column if not exists cover_image_path text,
add column if not exists cover_image_alt text;
create table if not exists public.project_planning_sections (
id uuid default uuid_generate_v4() primary key,
user_id uuid references auth.users(id) on delete cascade not null,
project_id uuid references public.projects(id) on delete cascade not null,
category text not null,
title text not null,
content text,
metadata jsonb default '{}'::jsonb not null,
sort_order integer default 0 not null,
created_at timestamp with time zone default timezone('utc'::text, now()) not null,
updated_at timestamp with time zone default timezone('utc'::text, now()) not null
);
do $$
begin
if not exists (select 1 from pg_constraint where conname = 'project_planning_sections_category_check') then
alter table public.project_planning_sections
add constraint project_planning_sections_category_check
check (
category in (
'overview',
'problem',
'goal',
'audience',
'scope',
'design_system',
'color_palette',
'typography',
'assets',
'notes'
)
);
end if;
end $$;
create index if not exists project_planning_sections_user_id_idx
on public.project_planning_sections(user_id);
create index if not exists project_planning_sections_project_id_idx
on public.project_planning_sections(project_id);
create index if not exists project_planning_sections_category_idx
on public.project_planning_sections(category);
alter table public.project_planning_sections enable row level security;
drop policy if exists "Users can view their own project planning sections." on public.project_planning_sections;
create policy "Users can view their own project planning sections." on public.project_planning_sections
for select using (auth.uid() = user_id);
drop policy if exists "Users can insert their own project planning sections." on public.project_planning_sections;
create policy "Users can insert their own project planning sections." on public.project_planning_sections
for insert with check (auth.uid() = user_id);
drop policy if exists "Users can update their own project planning sections." on public.project_planning_sections;
create policy "Users can update their own project planning sections." on public.project_planning_sections
for update using (auth.uid() = user_id);
drop policy if exists "Users can delete their own project planning sections." on public.project_planning_sections;
create policy "Users can delete their own project planning sections." on public.project_planning_sections
for delete using (auth.uid() = user_id);
drop trigger if exists set_project_planning_sections_updated_at on public.project_planning_sections;
create trigger set_project_planning_sections_updated_at
before update on public.project_planning_sections
for each row execute procedure public.set_updated_at();
insert into storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
values (
'project-assets',
'project-assets',
false,
5242880,
array['image/jpeg', 'image/png', 'image/webp', 'image/gif']
)
on conflict (id) do update set
public = excluded.public,
file_size_limit = excluded.file_size_limit,
allowed_mime_types = excluded.allowed_mime_types;
drop policy if exists "Users can view their own project assets." on storage.objects;
create policy "Users can view their own project assets." on storage.objects
for select using (
bucket_id = 'project-assets'
and auth.uid()::text = (storage.foldername(name))[1]
);
drop policy if exists "Users can upload their own project assets." on storage.objects;
create policy "Users can upload their own project assets." on storage.objects
for insert with check (
bucket_id = 'project-assets'
and auth.uid()::text = (storage.foldername(name))[1]
);
drop policy if exists "Users can update their own project assets." on storage.objects;
create policy "Users can update their own project assets." on storage.objects
for update using (
bucket_id = 'project-assets'
and auth.uid()::text = (storage.foldername(name))[1]
);
drop policy if exists "Users can delete their own project assets." on storage.objects;
create policy "Users can delete their own project assets." on storage.objects
for delete using (
bucket_id = 'project-assets'
and auth.uid()::text = (storage.foldername(name))[1]
);