From d41267c29b35ef268a09351b0f1f4841b5650f3d Mon Sep 17 00:00:00 2001 From: Poyraz Avsever Date: Mon, 8 Jun 2026 13:56:06 +0300 Subject: [PATCH] feat: implement portal shell layout with user authentication, sidebar navigation, and progress tracking --- app/(dashboard)/clients/actions.ts | 20 ++ app/(dashboard)/clients/clients-client.tsx | 236 +++++++++++++----- app/(dashboard)/projects/[id]/page.tsx | 6 +- .../projects/[id]/project-detail-client.tsx | 99 ++++++++ app/(dashboard)/projects/actions.ts | 25 ++ app/login/page.tsx | 18 +- app/portal/layout.tsx | 19 ++ app/portal/page.tsx | 2 +- .../projects/[id]/portal-project-client.tsx | 2 +- app/portal/revisions/page.tsx | 26 ++ app/portal/tasks/page.tsx | 26 ++ app/register/page.tsx | 18 +- components/layout/portal-shell.tsx | 54 ++-- config/portal-sidebar.ts | 7 + package.json | 3 + pnpm-lock.yaml | 66 ++++- .../0008_add_project_progress_and_quota.sql | 85 +++++++ 17 files changed, 582 insertions(+), 130 deletions(-) create mode 100644 app/portal/revisions/page.tsx create mode 100644 app/portal/tasks/page.tsx create mode 100644 supabase/migrations/0008_add_project_progress_and_quota.sql diff --git a/app/(dashboard)/clients/actions.ts b/app/(dashboard)/clients/actions.ts index fb12af3..89e6e6b 100644 --- a/app/(dashboard)/clients/actions.ts +++ b/app/(dashboard)/clients/actions.ts @@ -121,3 +121,23 @@ export async function archiveClientRecord(formData: FormData) { revalidatePath("/clients"); } + +export async function updateClientPipelineStage(id: string, stage: string) { + const { supabase, userId } = await getCurrentUserId(); + + if (!id || !stage) { + throw new Error("Eksik bilgi."); + } + + const { error } = await supabase + .from("clients") + .update({ pipeline_stage: stage }) + .eq("id", id) + .eq("user_id", userId); + + if (error) { + throw new Error(`Aşama güncellenemedi: ${error.message}`); + } + + revalidatePath("/clients"); +} diff --git a/app/(dashboard)/clients/clients-client.tsx b/app/(dashboard)/clients/clients-client.tsx index 073ec3d..f176bfe 100644 --- a/app/(dashboard)/clients/clients-client.tsx +++ b/app/(dashboard)/clients/clients-client.tsx @@ -4,6 +4,7 @@ import { archiveClientRecord, createClientRecord, updateClientRecord, + updateClientPipelineStage, } from "@/app/(dashboard)/clients/actions"; import { Badge, Button, Card, CardContent, Input, Label, Textarea } from "poyraz-ui/atoms"; import { @@ -43,6 +44,21 @@ import Link from "next/link"; import { useState } from "react"; import { format, isPast, isToday } from "date-fns"; import { tr } from "date-fns/locale"; +import { + DndContext, + DragEndEvent, + DragOverlay, + DragStartEvent, + PointerSensor, + useSensor, + useSensors, + closestCorners, + useDroppable, + useDraggable, +} from "@dnd-kit/core"; +import { CSS } from "@dnd-kit/utilities"; +import { useEffect } from "react"; +import { cn } from "@/lib/utils"; export type ClientListItem = { id: string; @@ -101,8 +117,52 @@ export function ClientsClient({ const [query, setQuery] = useState(""); const normalizedQuery = query.trim().toLowerCase(); + const [activeDragClient, setActiveDragClient] = useState(null); + const [localClients, setLocalClients] = useState(clients); + + useEffect(() => { + setLocalClients(clients); + }, [clients]); + + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 5, + }, + }) + ); + + function handleDragStart(event: DragStartEvent) { + const { active } = event; + const client = localClients.find(c => c.id === active.id); + if (client) setActiveDragClient(client); + } + + async function handleDragEnd(event: DragEndEvent) { + const { active, over } = event; + setActiveDragClient(null); + + if (!over) return; + + const clientId = active.id as string; + const newStage = over.id as string; + + const client = localClients.find(c => c.id === clientId); + if (!client || client.pipeline_stage === newStage) return; + + setLocalClients(prev => + prev.map(c => c.id === clientId ? { ...c, pipeline_stage: newStage as any } : c) + ); + + try { + await updateClientPipelineStage(clientId, newStage); + } catch (error) { + setLocalClients(clients); + } + } + const filteredClients = normalizedQuery - ? clients.filter((client) => + ? localClients.filter((client) => [ client.name, client.company_name, @@ -114,7 +174,7 @@ export function ClientsClient({ .filter(Boolean) .some((value) => value!.toLowerCase().includes(normalizedQuery)), ) - : clients; + : localClients; return (
@@ -181,83 +241,133 @@ export function ClientsClient({
-
- {pipelineStages.map(stage => { - const stageClients = filteredClients.filter(c => c.pipeline_stage === stage.id && c.status !== 'archived'); - return ( -
-
-

- - {stage.label} -

- {stageClients.length} -
-
+ +
+ {pipelineStages.map(stage => { + const stageClients = filteredClients.filter(c => c.pipeline_stage === stage.id && c.status !== 'archived'); + return ( + {stageClients.map(client => ( - - -
- - {client.name} - - } /> -
- {client.company_name &&

{client.company_name}

} - - {client.next_follow_up_date && ( -
- - - {format(new Date(client.next_follow_up_date), 'd MMM yyyy', { locale: tr })} - -
- )} -
-
+ ))} {stageClients.length === 0 && (
Boş
)} -
-
- ); - })} -
+ + ); + })} +
+ + {activeDragClient ? ( + + ) : null} + +
- - - {filteredClients.length > 0 ? ( -
-
- Müşteri - İletişim - Aşama - Follow-up - Projeler - İşlem -
-
- {filteredClients.map((client) => ( - - ))} -
-
- ) : ( - - )} -
-
+ {filteredClients.length > 0 ? ( +
+
+ Müşteri + İletişim + Aşama + Follow-up + Projeler + İşlem +
+
+ {filteredClients.map((client) => ( + + ))} +
+
+ ) : ( + + )}
); } +function DroppableColumn({ id, title, count, color, children }: { id: string, title: string, count: number, color: string, children: React.ReactNode }) { + const { isOver, setNodeRef } = useDroppable({ id }); + return ( +
+
+

+ + {title} +

+ {count} +
+
+ {children} +
+
+ ); +} + +function DraggableClientCard({ client, isOverlay }: { client: ClientListItem, isOverlay?: boolean }) { + const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ + id: client.id, + data: client, + }); + + const style = { + transform: CSS.Translate.toString(transform), + opacity: isDragging && !isOverlay ? 0.3 : 1, + zIndex: isDragging ? 999 : "auto", + }; + + return ( +
+ + +
+ e.stopPropagation()}> + {client.name} + +
e.stopPropagation()}> + } /> +
+
+ {client.company_name &&

{client.company_name}

} + + {client.next_follow_up_date && ( +
+ + + {format(new Date(client.next_follow_up_date), 'd MMM yyyy', { locale: tr })} + +
+ )} +
+
+
+ ); +} + function ClientRow({ client }: { client: ClientListItem }) { const isFollowUpOverdue = client.next_follow_up_date && (isPast(new Date(client.next_follow_up_date)) || isToday(new Date(client.next_follow_up_date))); const stage = pipelineStages.find(s => s.id === client.pipeline_stage) || pipelineStages[0]; diff --git a/app/(dashboard)/projects/[id]/page.tsx b/app/(dashboard)/projects/[id]/page.tsx index da2069d..b86db75 100644 --- a/app/(dashboard)/projects/[id]/page.tsx +++ b/app/(dashboard)/projects/[id]/page.tsx @@ -20,6 +20,8 @@ type ProjectRow = { budget_amount: number | string | null; currency: string; progress: number; + progress_type: "manual" | "auto" | null; + revision_quota: number | null; cover_image_path: string | null; cover_image_alt: string | null; clients: { name: string } | { name: string }[] | null; @@ -66,7 +68,7 @@ export default async function ProjectDetailPage({ supabase .from("projects") .select( - "id, client_id, name, type, description, status, start_date, due_date, budget_amount, currency, progress, cover_image_path, cover_image_alt, clients(name)", + "id, client_id, name, type, description, status, start_date, due_date, budget_amount, currency, progress, progress_type, revision_quota, cover_image_path, cover_image_alt, clients(name)", ) .eq("id", id) .eq("user_id", user.id) @@ -120,6 +122,8 @@ export default async function ProjectDetailPage({ projectData.budget_amount === null ? null : Number(projectData.budget_amount), currency: projectData.currency, progress: Number(projectData.progress || 0), + progress_type: projectData.progress_type === "auto" ? "auto" : "manual", + revision_quota: Number(projectData.revision_quota || 0), cover_image_alt: projectData.cover_image_alt, coverImageUrl, }; diff --git a/app/(dashboard)/projects/[id]/project-detail-client.tsx b/app/(dashboard)/projects/[id]/project-detail-client.tsx index 5681aa4..f006a76 100644 --- a/app/(dashboard)/projects/[id]/project-detail-client.tsx +++ b/app/(dashboard)/projects/[id]/project-detail-client.tsx @@ -38,6 +38,7 @@ import { Palette, Pencil, Plus, + Settings2, Target, Trash2, Wallet, @@ -58,6 +59,8 @@ export type ProjectDetail = { budget_amount: number | null; currency: string; progress: number; + progress_type: "manual" | "auto"; + revision_quota: number; cover_image_alt: string | null; coverImageUrl: string | null; }; @@ -215,6 +218,7 @@ export function ProjectDetailClient({
+ {project.status !== "completed" ? (
@@ -806,6 +810,101 @@ function ProjectTaskKanban({ ); } +function ProjectSettingsDialog({ project }: { project: ProjectDetail }) { + const [open, setOpen] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const [progressType, setProgressType] = useState<"manual" | "auto">(project.progress_type); + const [progress, setProgress] = useState(project.progress); + const [revisionQuota, setRevisionQuota] = useState(project.revision_quota); + + async function handleSubmit() { + setIsSubmitting(true); + try { + const { updateProjectSettings } = await import("@/app/(dashboard)/projects/actions"); + await updateProjectSettings(project.id, progressType, progress, revisionQuota); + setOpen(false); + } catch (error) { + console.error(error); + } finally { + setIsSubmitting(false); + } + } + + return ( + + + + + + + + Proje ayarları + + İlerleme hesaplama yöntemi ve revizyon kotasını belirle. + + + +
+
+ + +
+ + {progressType === "manual" && ( +
+ +
+ setProgress(Number(e.target.value))} + className="flex-1 accent-primary" + /> + {progress}% +
+
+ )} + {progressType === "auto" && ( +

İlerleme yüzdesi "Görevler" sekmesindeki görevlerin tamamlanma durumuna göre otomatik hesaplanacaktır.

+ )} + +
+ + setRevisionQuota(Number(e.target.value))} + /> +

Müşterinin portal üzerinden talep edebileceği toplam revizyon hakkı.

+
+
+ + + + + +
+
+ ); +} + + function ProjectTaskDialog({ projectId, clientId, diff --git a/app/(dashboard)/projects/actions.ts b/app/(dashboard)/projects/actions.ts index 282cdff..5645287 100644 --- a/app/(dashboard)/projects/actions.ts +++ b/app/(dashboard)/projects/actions.ts @@ -336,3 +336,28 @@ export async function updateRevisionStatus(id: string, projectId: string, status revalidatePath(`/projects/${projectId}`); } + +export async function updateProjectSettings(projectId: string, progressType: "manual" | "auto", progress: number, revisionQuota: number) { + const { supabase, userId } = await getCurrentUserId(); + + if (!projectId) { + throw new Error("Proje ID zorunludur."); + } + + const { error } = await supabase + .from("projects") + .update({ + progress_type: progressType, + progress: progress, + revision_quota: revisionQuota + }) + .eq("id", projectId) + .eq("user_id", userId); + + if (error) { + throw new Error(`Ayarlar güncellenemedi: ${error.message}`); + } + + revalidatePath("/projects"); + revalidatePath(`/projects/${projectId}`); +} diff --git a/app/login/page.tsx b/app/login/page.tsx index 5b5ff42..1362112 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -67,23 +67,7 @@ export default async function LoginPage({ } - secondaryAction={ -
-
-
- -
-
- veya -
-
- - -
- } + secondaryAction={null} footer={
Hesabın yok mu?{" "} diff --git a/app/portal/layout.tsx b/app/portal/layout.tsx index 1f888d7..b569a5b 100644 --- a/app/portal/layout.tsx +++ b/app/portal/layout.tsx @@ -39,6 +39,24 @@ export default async function PortalLayout({ .join("") .slice(0, 2) || "MS"; + const { data: clientData } = await supabase + .from("clients") + .select("id") + .eq("client_auth_id", user.id) + .maybeSingle(); + + let avgProgress = 0; + if (clientData) { + const { data: projectsData } = await supabase + .from("projects") + .select("progress") + .eq("client_id", clientData.id) + .eq("status", "active"); + if (projectsData && projectsData.length > 0) { + avgProgress = Math.round(projectsData.reduce((sum, p) => sum + p.progress, 0) / projectsData.length); + } + } + return ( {children} diff --git a/app/portal/page.tsx b/app/portal/page.tsx index 8907e97..061afb9 100644 --- a/app/portal/page.tsx +++ b/app/portal/page.tsx @@ -42,7 +42,7 @@ export default async function PortalDashboardPage() { const completedProjects = projects.filter(p => p.status === 'completed'); return ( -
+

Hoş Geldiniz, {clientData.name}

İş süreçlerinizi ve aktif projelerinizi buradan takip edebilirsiniz.

diff --git a/app/portal/projects/[id]/portal-project-client.tsx b/app/portal/projects/[id]/portal-project-client.tsx index a5f942d..ac9b71e 100644 --- a/app/portal/projects/[id]/portal-project-client.tsx +++ b/app/portal/projects/[id]/portal-project-client.tsx @@ -32,7 +32,7 @@ export function PortalProjectClient({ project, sections, tasks, revisions, clien const pendingRevisions = revisions.filter((r: any) => r.status === 'pending' || r.status === 'in_progress').length; return ( -
+
{/* Header Info */}
diff --git a/app/portal/revisions/page.tsx b/app/portal/revisions/page.tsx new file mode 100644 index 0000000..578e1e0 --- /dev/null +++ b/app/portal/revisions/page.tsx @@ -0,0 +1,26 @@ +import { MessageSquareDiff } from "lucide-react"; +import Link from "next/link"; + +export default function PortalRevisionsPage() { + return ( +
+
+

Revizyon Taleplerim

+

İlettiğiniz tüm revizyon taleplerinin durumunu buradan takip edebilirsiniz.

+
+ +
+ +

+ Revizyon modülü yapım aşamasında +

+

+ Yakında tüm revizyon taleplerinizi buradan yönetebileceksiniz. Şimdilik proje detay sayfasından revizyon talep edebilirsiniz. +

+ + Dashboard'a dön + +
+
+ ); +} diff --git a/app/portal/tasks/page.tsx b/app/portal/tasks/page.tsx new file mode 100644 index 0000000..d02f684 --- /dev/null +++ b/app/portal/tasks/page.tsx @@ -0,0 +1,26 @@ +import { FolderKanban } from "lucide-react"; +import Link from "next/link"; + +export default function PortalTasksPage() { + return ( +
+
+

Görevlerim

+

Size atanan ve herkese açık olan proje görevleri.

+
+ +
+ +

+ Görev modülü yapım aşamasında +

+

+ Yakında tüm görevlerinizi buradan takip edebileceksiniz. Şimdilik proje detay sayfasından görevlere ulaşabilirsiniz. +

+ + Dashboard'a dön + +
+
+ ); +} diff --git a/app/register/page.tsx b/app/register/page.tsx index 524815c..d46693b 100644 --- a/app/register/page.tsx +++ b/app/register/page.tsx @@ -59,23 +59,7 @@ export default async function RegisterPage({ } - secondaryAction={ -
-
-
- -
-
- veya -
-
- - -
- } + secondaryAction={null} footer={
Zaten hesabın var mı?{" "} diff --git a/components/layout/portal-shell.tsx b/components/layout/portal-shell.tsx index 1467cdb..1ef0ae3 100644 --- a/components/layout/portal-shell.tsx +++ b/components/layout/portal-shell.tsx @@ -39,9 +39,10 @@ type PortalShellProps = { shortName: string; avatarUrl: string | null; }; + progress?: number; }; -export function PortalShell({ children, user }: PortalShellProps) { +export function PortalShell({ children, user, progress }: PortalShellProps) { const pathname = usePathname(); const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false); @@ -49,7 +50,7 @@ export function PortalShell({ children, user }: PortalShellProps) {
{isMobileSidebarOpen ? ( @@ -64,6 +65,7 @@ export function PortalShell({ children, user }: PortalShellProps) { setIsMobileSidebarOpen(false)} /> @@ -71,7 +73,7 @@ export function PortalShell({ children, user }: PortalShellProps) { ) : null}
-
+
- Cognis Portal + Cognis Portal - +
+ +
{children}
@@ -103,10 +107,12 @@ export function PortalShell({ children, user }: PortalShellProps) { function AppSidebar({ pathname, user, + progress = 0, onNavigate, }: { pathname: string; user: PortalShellProps["user"]; + progress?: number; onNavigate?: () => void; }) { return ( @@ -115,20 +121,18 @@ function AppSidebar({ className="flex h-dvh max-h-dvh flex-col overflow-hidden rounded-none border-0" > - +
+ Aktif İlerleme + %{progress} +
+
+
- } - /> +
+
diff --git a/config/portal-sidebar.ts b/config/portal-sidebar.ts index f39edab..1949134 100644 --- a/config/portal-sidebar.ts +++ b/config/portal-sidebar.ts @@ -22,4 +22,11 @@ export const portalSidebarData: PortalSidebarNavGroup[] = [ { title: "Dashboard", href: "/portal", icon: Sparkles }, ], }, + { + title: "SÜREÇLER", + items: [ + { title: "Görevlerim", href: "/portal/tasks", icon: FolderKanban }, + { title: "Revizyon Talepleri", href: "/portal/revisions", icon: Sparkles }, + ], + } ]; diff --git a/package.json b/package.json index e43a3c8..6a7631d 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,9 @@ "@ai-sdk/openai": "^3.0.68", "@ai-sdk/react": "^3.0.199", "@base-ui/react": "^1.5.0", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@ducanh2912/next-pwa": "^10.2.9", "@hookform/resolvers": "^5.4.0", "@iconify/react": "^6.0.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9043c78..3abff40 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,15 @@ importers: '@base-ui/react': specifier: ^1.5.0 version: 1.5.0(@types/react@19.2.16)(date-fns@4.4.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@dnd-kit/core': + specifier: ^6.3.1 + version: 6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@dnd-kit/sortable': + specifier: ^10.0.0 + version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + '@dnd-kit/utilities': + specifier: ^3.2.2 + version: 3.2.2(react@19.2.7) '@ducanh2912/next-pwa': specifier: ^10.2.9 version: 10.2.9(next@16.2.7(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(webpack@5.107.2(postcss@8.5.15)) @@ -758,6 +767,28 @@ packages: '@types/react': optional: true + '@dnd-kit/accessibility@3.1.1': + resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} + peerDependencies: + react: '>=16.8.0' + + '@dnd-kit/core@6.3.1': + resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@dnd-kit/sortable@10.0.0': + resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==} + peerDependencies: + '@dnd-kit/core': ^6.3.0 + react: '>=16.8.0' + + '@dnd-kit/utilities@3.2.2': + resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==} + peerDependencies: + react: '>=16.8.0' + '@dotenvx/dotenvx@1.71.0': resolution: {integrity: sha512-KEUw/mGu+EDRhYWRTNGHIimVCs9NvMFaIXOGrHSXoCteKLE5EsJnmPjOPpYorjXVg/0xG0fbdVw720azw1z4ag==} hasBin: true @@ -6069,6 +6100,31 @@ snapshots: optionalDependencies: '@types/react': 19.2.16 + '@dnd-kit/accessibility@3.1.1(react@19.2.7)': + dependencies: + react: 19.2.7 + tslib: 2.8.1 + + '@dnd-kit/core@6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@dnd-kit/accessibility': 3.1.1(react@19.2.7) + '@dnd-kit/utilities': 3.2.2(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + tslib: 2.8.1 + + '@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)': + dependencies: + '@dnd-kit/core': 6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@dnd-kit/utilities': 3.2.2(react@19.2.7) + react: 19.2.7 + tslib: 2.8.1 + + '@dnd-kit/utilities@3.2.2(react@19.2.7)': + dependencies: + react: 19.2.7 + tslib: 2.8.1 + '@dotenvx/dotenvx@1.71.0': dependencies: commander: 11.1.0 @@ -8414,7 +8470,7 @@ snapshots: '@next/eslint-plugin-next': 16.2.7 eslint: 9.39.4(jiti@2.7.0) eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.7.0)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)) eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.60.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4(jiti@2.7.0)) eslint-plugin-react: 7.37.5(eslint@9.39.4(jiti@2.7.0)) @@ -8437,7 +8493,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.7.0)): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3 @@ -8452,14 +8508,14 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.13.0(@typescript-eslint/parser@8.60.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)): + eslint-module-utils@2.13.0(@typescript-eslint/parser@8.60.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.60.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) eslint: 9.39.4(jiti@2.7.0) eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.7.0)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)) transitivePeerDependencies: - supports-color @@ -8474,7 +8530,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.4(jiti@2.7.0) eslint-import-resolver-node: 0.3.10 - eslint-module-utils: 2.13.0(@typescript-eslint/parser@8.60.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.7.0)) + eslint-module-utils: 2.13.0(@typescript-eslint/parser@8.60.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0)) hasown: 2.0.4 is-core-module: 2.16.2 is-glob: 4.0.3 diff --git a/supabase/migrations/0008_add_project_progress_and_quota.sql b/supabase/migrations/0008_add_project_progress_and_quota.sql new file mode 100644 index 0000000..af9ba52 --- /dev/null +++ b/supabase/migrations/0008_add_project_progress_and_quota.sql @@ -0,0 +1,85 @@ +-- 0008: Add Project Progress Type and Revision Quota + +-- Add columns to projects table +ALTER TABLE public.projects +ADD COLUMN IF NOT EXISTS progress_type text DEFAULT 'manual'::text CHECK (progress_type IN ('manual', 'auto')), +ADD COLUMN IF NOT EXISTS revision_quota integer DEFAULT 0; + +-- Function to update project progress automatically if progress_type is 'auto' +CREATE OR REPLACE FUNCTION public.update_project_progress_on_task_change() +RETURNS TRIGGER AS $$ +DECLARE + v_project_id uuid; + v_progress_type text; + v_total_tasks integer; + v_done_tasks integer; + v_new_progress integer; +BEGIN + IF TG_OP = 'DELETE' THEN + v_project_id := OLD.project_id; + ELSE + v_project_id := NEW.project_id; + END IF; + + IF v_project_id IS NULL THEN + RETURN NULL; + END IF; + + SELECT progress_type INTO v_progress_type FROM public.projects WHERE id = v_project_id; + + IF v_progress_type = 'auto' THEN + SELECT count(*) INTO v_total_tasks FROM public.tasks WHERE project_id = v_project_id; + SELECT count(*) INTO v_done_tasks FROM public.tasks WHERE project_id = v_project_id AND status = 'done'; + + IF v_total_tasks > 0 THEN + v_new_progress := round((v_done_tasks::numeric / v_total_tasks::numeric) * 100); + ELSE + v_new_progress := 0; + END IF; + + UPDATE public.projects SET progress = v_new_progress WHERE id = v_project_id; + END IF; + + RETURN NULL; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- Trigger to recalculate progress when tasks are modified +DROP TRIGGER IF EXISTS trigger_update_project_progress ON public.tasks; +CREATE TRIGGER trigger_update_project_progress +AFTER INSERT OR UPDATE OF status OR DELETE +ON public.tasks +FOR EACH ROW +EXECUTE FUNCTION public.update_project_progress_on_task_change(); + +-- Trigger to recalculate progress when a project's progress_type is changed to 'auto' +CREATE OR REPLACE FUNCTION public.update_project_progress_on_type_change() +RETURNS TRIGGER AS $$ +DECLARE + v_total_tasks integer; + v_done_tasks integer; + v_new_progress integer; +BEGIN + IF NEW.progress_type = 'auto' AND (OLD.progress_type IS DISTINCT FROM NEW.progress_type) THEN + SELECT count(*) INTO v_total_tasks FROM public.tasks WHERE project_id = NEW.id; + SELECT count(*) INTO v_done_tasks FROM public.tasks WHERE project_id = NEW.id AND status = 'done'; + + IF v_total_tasks > 0 THEN + v_new_progress := round((v_done_tasks::numeric / v_total_tasks::numeric) * 100); + ELSE + v_new_progress := 0; + END IF; + + NEW.progress := v_new_progress; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +DROP TRIGGER IF EXISTS trigger_update_project_progress_type ON public.projects; +CREATE TRIGGER trigger_update_project_progress_type +BEFORE UPDATE OF progress_type +ON public.projects +FOR EACH ROW +EXECUTE FUNCTION public.update_project_progress_on_type_change();