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:
+246
-21
@@ -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: `<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
|
||||
|
||||
Amac: Girilen verileri anlamli grafiklere ve rapor kartlarina donusturmek.
|
||||
|
||||
@@ -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
@@ -1,337 +1,139 @@
|
||||
"use client";
|
||||
|
||||
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";
|
||||
ProjectsClient,
|
||||
type ProjectClientOption,
|
||||
type ProjectListItem,
|
||||
} from "@/app/(dashboard)/projects/projects-client";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
|
||||
// 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 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;
|
||||
};
|
||||
|
||||
export default function ProjectsPage() {
|
||||
const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
|
||||
const [selectedProject, setSelectedProject] = useState<any>(null);
|
||||
type TaskRow = {
|
||||
project_id: string | null;
|
||||
status: string | null;
|
||||
};
|
||||
|
||||
return (
|
||||
<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">
|
||||
export default async function ProjectsPage() {
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
{/* Top Header */}
|
||||
<div className="flex items-center justify-between pb-4 border-b border-white/5 mt-4 shrink-0">
|
||||
<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>
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
{/* Analytics Overview Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 shrink-0">
|
||||
<ProjectStatCard label="Active Projects" value="4" subtext="+1 from last month" icon={Activity} />
|
||||
<ProjectStatCard label="Average Progress" value="68%" subtext="12% ahead of schedule" icon={TrendingUp} />
|
||||
<ProjectStatCard label="Total Members" value="15" subtext="Across 5 project teams" icon={Users} />
|
||||
</div>
|
||||
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 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 bg-[#150F1D] border border-white/5 rounded-sm p-0.5">
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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 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 <ProjectsClient projects={projects} clients={clients} />;
|
||||
}
|
||||
|
||||
function ProjectStatCard({ label, value, subtext, icon: Icon }: any) {
|
||||
return (
|
||||
<div className="bg-[#0A0710] border border-white/5 rounded-sm p-8 flex items-center gap-6 group relative overflow-hidden shadow-xl">
|
||||
<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" />
|
||||
</div>
|
||||
<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" />
|
||||
</div>
|
||||
<div className="relative z-10">
|
||||
<div className="text-[10px] font-black uppercase tracking-[0.2em] text-muted-foreground mb-1">{label}</div>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
async function createProjectImageUrls(
|
||||
supabase: Awaited<ReturnType<typeof createClient>>,
|
||||
paths: string[],
|
||||
) {
|
||||
const urls = new Map<string, string>();
|
||||
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<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
@@ -80,21 +80,33 @@
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.tiny-scrollbar::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
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 {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb,
|
||||
.tiny-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: color-mix(in srgb, var(--poyraz-primary) 30%, transparent);
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--poyraz-primary) 36%, transparent);
|
||||
border: 2px solid transparent;
|
||||
border-radius: 999px;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
*::-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`.
|
||||
@@ -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 |
|
||||
| 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 |
|
||||
|
||||
## How To Add The Next Query
|
||||
|
||||
+7
-1
@@ -1,5 +1,11 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {};
|
||||
const nextConfig: NextConfig = {
|
||||
experimental: {
|
||||
serverActions: {
|
||||
bodySizeLimit: "8mb",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
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]
|
||||
);
|
||||
Reference in New Issue
Block a user