feat: implement portal shell layout with user authentication, sidebar navigation, and progress tracking
This commit is contained in:
@@ -121,3 +121,23 @@ export async function archiveClientRecord(formData: FormData) {
|
|||||||
|
|
||||||
revalidatePath("/clients");
|
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");
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
archiveClientRecord,
|
archiveClientRecord,
|
||||||
createClientRecord,
|
createClientRecord,
|
||||||
updateClientRecord,
|
updateClientRecord,
|
||||||
|
updateClientPipelineStage,
|
||||||
} from "@/app/(dashboard)/clients/actions";
|
} from "@/app/(dashboard)/clients/actions";
|
||||||
import { Badge, Button, Card, CardContent, Input, Label, Textarea } from "poyraz-ui/atoms";
|
import { Badge, Button, Card, CardContent, Input, Label, Textarea } from "poyraz-ui/atoms";
|
||||||
import {
|
import {
|
||||||
@@ -43,6 +44,21 @@ import Link from "next/link";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { format, isPast, isToday } from "date-fns";
|
import { format, isPast, isToday } from "date-fns";
|
||||||
import { tr } from "date-fns/locale";
|
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 = {
|
export type ClientListItem = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -101,8 +117,52 @@ export function ClientsClient({
|
|||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const normalizedQuery = query.trim().toLowerCase();
|
const normalizedQuery = query.trim().toLowerCase();
|
||||||
|
|
||||||
|
const [activeDragClient, setActiveDragClient] = useState<ClientListItem | null>(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
|
const filteredClients = normalizedQuery
|
||||||
? clients.filter((client) =>
|
? localClients.filter((client) =>
|
||||||
[
|
[
|
||||||
client.name,
|
client.name,
|
||||||
client.company_name,
|
client.company_name,
|
||||||
@@ -114,7 +174,7 @@ export function ClientsClient({
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.some((value) => value!.toLowerCase().includes(normalizedQuery)),
|
.some((value) => value!.toLowerCase().includes(normalizedQuery)),
|
||||||
)
|
)
|
||||||
: clients;
|
: localClients;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex max-w-7xl flex-col gap-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
<div className="mx-auto flex max-w-7xl flex-col gap-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||||
@@ -181,56 +241,44 @@ export function ClientsClient({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TabsContent value="pipeline" className="mt-0">
|
<TabsContent value="pipeline" className="mt-0">
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCorners}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
>
|
||||||
<div className="flex gap-4 overflow-x-auto pb-4 snap-x">
|
<div className="flex gap-4 overflow-x-auto pb-4 snap-x">
|
||||||
{pipelineStages.map(stage => {
|
{pipelineStages.map(stage => {
|
||||||
const stageClients = filteredClients.filter(c => c.pipeline_stage === stage.id && c.status !== 'archived');
|
const stageClients = filteredClients.filter(c => c.pipeline_stage === stage.id && c.status !== 'archived');
|
||||||
return (
|
return (
|
||||||
<div key={stage.id} className="flex-shrink-0 w-80 bg-muted/30 rounded-lg border border-border p-3 snap-start flex flex-col h-[calc(100vh-320px)] min-h-[500px]">
|
<DroppableColumn
|
||||||
<div className="flex items-center justify-between mb-3 px-1">
|
key={stage.id}
|
||||||
<h3 className="font-semibold text-sm text-foreground flex items-center gap-2">
|
id={stage.id}
|
||||||
<span className={`w-2 h-2 rounded-full ${stage.color.split(' ')[1]}`}></span>
|
title={stage.label}
|
||||||
{stage.label}
|
count={stageClients.length}
|
||||||
</h3>
|
color={stage.color.split(' ')[1]}
|
||||||
<Badge variant="secondary" className="text-xs">{stageClients.length}</Badge>
|
>
|
||||||
</div>
|
|
||||||
<div className="flex-1 overflow-y-auto space-y-3 pr-1 tiny-scrollbar">
|
|
||||||
{stageClients.map(client => (
|
{stageClients.map(client => (
|
||||||
<Card key={client.id} className="cursor-pointer hover:border-primary/50 transition-colors">
|
<DraggableClientCard key={client.id} client={client} />
|
||||||
<CardContent className="p-3">
|
|
||||||
<div className="flex justify-between items-start mb-2">
|
|
||||||
<Link href={`/clients/${client.id}`} className="font-medium text-foreground hover:underline">
|
|
||||||
{client.name}
|
|
||||||
</Link>
|
|
||||||
<ClientDialog mode="edit" client={client} trigger={<Button variant="ghost" className="h-6 w-6 p-0"><Pencil className="h-3 w-3" /></Button>} />
|
|
||||||
</div>
|
|
||||||
{client.company_name && <p className="text-xs text-muted-foreground mb-2">{client.company_name}</p>}
|
|
||||||
|
|
||||||
{client.next_follow_up_date && (
|
|
||||||
<div className="mt-3 flex items-center gap-1.5 text-xs">
|
|
||||||
<Clock className={`h-3 w-3 ${isPast(new Date(client.next_follow_up_date)) ? 'text-rose-500' : 'text-muted-foreground'}`} />
|
|
||||||
<span className={isPast(new Date(client.next_follow_up_date)) ? 'text-rose-500 font-medium' : 'text-muted-foreground'}>
|
|
||||||
{format(new Date(client.next_follow_up_date), 'd MMM yyyy', { locale: tr })}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
))}
|
||||||
{stageClients.length === 0 && (
|
{stageClients.length === 0 && (
|
||||||
<div className="h-24 flex items-center justify-center border-2 border-dashed border-border rounded-md text-xs text-muted-foreground">
|
<div className="h-24 flex items-center justify-center border-2 border-dashed border-border rounded-md text-xs text-muted-foreground">
|
||||||
Boş
|
Boş
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</DroppableColumn>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
<DragOverlay>
|
||||||
|
{activeDragClient ? (
|
||||||
|
<DraggableClientCard client={activeDragClient} isOverlay />
|
||||||
|
) : null}
|
||||||
|
</DragOverlay>
|
||||||
|
</DndContext>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="list" className="mt-0">
|
<TabsContent value="list" className="mt-0">
|
||||||
<Card>
|
|
||||||
<CardContent className="p-0">
|
|
||||||
{filteredClients.length > 0 ? (
|
{filteredClients.length > 0 ? (
|
||||||
<div className="overflow-hidden rounded-sm border border-border">
|
<div className="overflow-hidden rounded-sm border border-border">
|
||||||
<div className="hidden grid-cols-[1.5fr_1fr_1fr_1fr_0.8fr_0.8fr] gap-4 border-b border-border bg-muted/40 px-4 py-3 text-xs font-medium uppercase text-muted-foreground lg:grid">
|
<div className="hidden grid-cols-[1.5fr_1fr_1fr_1fr_0.8fr_0.8fr] gap-4 border-b border-border bg-muted/40 px-4 py-3 text-xs font-medium uppercase text-muted-foreground lg:grid">
|
||||||
@@ -250,14 +298,76 @@ export function ClientsClient({
|
|||||||
) : (
|
) : (
|
||||||
<EmptyState hasQuery={Boolean(normalizedQuery)} />
|
<EmptyState hasQuery={Boolean(normalizedQuery)} />
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DroppableColumn({ id, title, count, color, children }: { id: string, title: string, count: number, color: string, children: React.ReactNode }) {
|
||||||
|
const { isOver, setNodeRef } = useDroppable({ id });
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
className={cn(
|
||||||
|
"flex-shrink-0 w-[340px] px-2 flex flex-col h-[calc(100vh-320px)] min-h-[500px] transition-colors rounded-lg",
|
||||||
|
isOver ? "bg-muted/30" : ""
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-3 px-1">
|
||||||
|
<h3 className="font-semibold text-sm text-foreground flex items-center gap-2">
|
||||||
|
<span className={`w-2 h-2 rounded-full ${color}`}></span>
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
<Badge variant="secondary" className="text-xs">{count}</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto space-y-3 pr-1 tiny-scrollbar">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div ref={setNodeRef} style={style} {...listeners} {...attributes} className={cn("touch-none cursor-grab active:cursor-grabbing", isOverlay && "rotate-2 scale-105 shadow-xl")}>
|
||||||
|
<Card className="hover:border-primary/50 transition-colors">
|
||||||
|
<CardContent className="p-3">
|
||||||
|
<div className="flex justify-between items-start mb-2">
|
||||||
|
<Link href={`/clients/${client.id}`} className="font-medium text-foreground hover:underline" onPointerDown={(e) => e.stopPropagation()}>
|
||||||
|
{client.name}
|
||||||
|
</Link>
|
||||||
|
<div onPointerDown={(e) => e.stopPropagation()}>
|
||||||
|
<ClientDialog mode="edit" client={client} trigger={<Button variant="ghost" className="h-6 w-6 p-0"><Pencil className="h-3 w-3" /></Button>} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{client.company_name && <p className="text-xs text-muted-foreground mb-2 pointer-events-none">{client.company_name}</p>}
|
||||||
|
|
||||||
|
{client.next_follow_up_date && (
|
||||||
|
<div className="mt-3 flex items-center gap-1.5 text-xs pointer-events-none">
|
||||||
|
<Clock className={`h-3 w-3 ${isPast(new Date(client.next_follow_up_date)) ? 'text-rose-500' : 'text-muted-foreground'}`} />
|
||||||
|
<span className={isPast(new Date(client.next_follow_up_date)) ? 'text-rose-500 font-medium' : 'text-muted-foreground'}>
|
||||||
|
{format(new Date(client.next_follow_up_date), 'd MMM yyyy', { locale: tr })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ClientRow({ client }: { client: ClientListItem }) {
|
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 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];
|
const stage = pipelineStages.find(s => s.id === client.pipeline_stage) || pipelineStages[0];
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ type ProjectRow = {
|
|||||||
budget_amount: number | string | null;
|
budget_amount: number | string | null;
|
||||||
currency: string;
|
currency: string;
|
||||||
progress: number;
|
progress: number;
|
||||||
|
progress_type: "manual" | "auto" | null;
|
||||||
|
revision_quota: number | null;
|
||||||
cover_image_path: string | null;
|
cover_image_path: string | null;
|
||||||
cover_image_alt: string | null;
|
cover_image_alt: string | null;
|
||||||
clients: { name: string } | { name: string }[] | null;
|
clients: { name: string } | { name: string }[] | null;
|
||||||
@@ -66,7 +68,7 @@ export default async function ProjectDetailPage({
|
|||||||
supabase
|
supabase
|
||||||
.from("projects")
|
.from("projects")
|
||||||
.select(
|
.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("id", id)
|
||||||
.eq("user_id", user.id)
|
.eq("user_id", user.id)
|
||||||
@@ -120,6 +122,8 @@ export default async function ProjectDetailPage({
|
|||||||
projectData.budget_amount === null ? null : Number(projectData.budget_amount),
|
projectData.budget_amount === null ? null : Number(projectData.budget_amount),
|
||||||
currency: projectData.currency,
|
currency: projectData.currency,
|
||||||
progress: Number(projectData.progress || 0),
|
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,
|
cover_image_alt: projectData.cover_image_alt,
|
||||||
coverImageUrl,
|
coverImageUrl,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import {
|
|||||||
Palette,
|
Palette,
|
||||||
Pencil,
|
Pencil,
|
||||||
Plus,
|
Plus,
|
||||||
|
Settings2,
|
||||||
Target,
|
Target,
|
||||||
Trash2,
|
Trash2,
|
||||||
Wallet,
|
Wallet,
|
||||||
@@ -58,6 +59,8 @@ export type ProjectDetail = {
|
|||||||
budget_amount: number | null;
|
budget_amount: number | null;
|
||||||
currency: string;
|
currency: string;
|
||||||
progress: number;
|
progress: number;
|
||||||
|
progress_type: "manual" | "auto";
|
||||||
|
revision_quota: number;
|
||||||
cover_image_alt: string | null;
|
cover_image_alt: string | null;
|
||||||
coverImageUrl: string | null;
|
coverImageUrl: string | null;
|
||||||
};
|
};
|
||||||
@@ -215,6 +218,7 @@ export function ProjectDetailClient({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
<ProjectSettingsDialog project={project} />
|
||||||
<SectionDialog projectId={project.id} mode="create" defaultCategory="overview" />
|
<SectionDialog projectId={project.id} mode="create" defaultCategory="overview" />
|
||||||
{project.status !== "completed" ? (
|
{project.status !== "completed" ? (
|
||||||
<form action={completeProjectRecord}>
|
<form action={completeProjectRecord}>
|
||||||
@@ -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 (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="outline" className="h-9 gap-2 px-3">
|
||||||
|
<Settings2 className="h-4 w-4" />
|
||||||
|
<span className="hidden sm:inline">Ayarlar</span>
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<form action={handleSubmit} className="space-y-5">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Proje ayarları</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
İlerleme hesaplama yöntemi ve revizyon kotasını belirle.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label>İlerleme Hesaplama</Label>
|
||||||
|
<Select value={progressType} onValueChange={(val: "manual" | "auto") => setProgressType(val)}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="manual">Manuel (Elle girilir)</SelectItem>
|
||||||
|
<SelectItem value="auto">Otomatik (Görevlere göre)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{progressType === "manual" && (
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label>İlerleme Durumu (%)</Label>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
value={progress}
|
||||||
|
onChange={(e) => setProgress(Number(e.target.value))}
|
||||||
|
className="flex-1 accent-primary"
|
||||||
|
/>
|
||||||
|
<span className="w-12 text-right text-sm font-medium">{progress}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{progressType === "auto" && (
|
||||||
|
<p className="text-xs text-muted-foreground">İlerleme yüzdesi "Görevler" sekmesindeki görevlerin tamamlanma durumuna göre otomatik hesaplanacaktır.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label>Müşteri Revizyon Kotası</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={revisionQuota}
|
||||||
|
onChange={(e) => setRevisionQuota(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">Müşterinin portal üzerinden talep edebileceği toplam revizyon hakkı.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="submit" disabled={isSubmitting}>
|
||||||
|
{isSubmitting ? "Kaydediliyor..." : "Kaydet"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
function ProjectTaskDialog({
|
function ProjectTaskDialog({
|
||||||
projectId,
|
projectId,
|
||||||
clientId,
|
clientId,
|
||||||
|
|||||||
@@ -336,3 +336,28 @@ export async function updateRevisionStatus(id: string, projectId: string, status
|
|||||||
|
|
||||||
revalidatePath(`/projects/${projectId}`);
|
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}`);
|
||||||
|
}
|
||||||
|
|||||||
+1
-17
@@ -67,23 +67,7 @@ export default async function LoginPage({
|
|||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
}
|
}
|
||||||
secondaryAction={
|
secondaryAction={null}
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="relative">
|
|
||||||
<div className="absolute inset-0 flex items-center">
|
|
||||||
<span className="w-full border-t border-border" />
|
|
||||||
</div>
|
|
||||||
<div className="relative flex justify-center text-xs uppercase">
|
|
||||||
<span className="bg-background px-2 text-muted-foreground">veya</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button type="button" variant="outline" className="h-11 w-full gap-2">
|
|
||||||
<Search className="h-4 w-4" />
|
|
||||||
Google ile devam et
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
footer={
|
footer={
|
||||||
<div className="text-center text-sm">
|
<div className="text-center text-sm">
|
||||||
Hesabın yok mu?{" "}
|
Hesabın yok mu?{" "}
|
||||||
|
|||||||
@@ -39,6 +39,24 @@ export default async function PortalLayout({
|
|||||||
.join("")
|
.join("")
|
||||||
.slice(0, 2) || "MS";
|
.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 (
|
return (
|
||||||
<PortalShell
|
<PortalShell
|
||||||
user={{
|
user={{
|
||||||
@@ -47,6 +65,7 @@ export default async function PortalLayout({
|
|||||||
shortName,
|
shortName,
|
||||||
avatarUrl: profile?.avatar_url || null,
|
avatarUrl: profile?.avatar_url || null,
|
||||||
}}
|
}}
|
||||||
|
progress={avgProgress}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</PortalShell>
|
</PortalShell>
|
||||||
|
|||||||
+1
-1
@@ -42,7 +42,7 @@ export default async function PortalDashboardPage() {
|
|||||||
const completedProjects = projects.filter(p => p.status === 'completed');
|
const completedProjects = projects.filter(p => p.status === 'completed');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
<div className="mx-auto flex max-w-7xl flex-col gap-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<h1 className="text-3xl font-semibold tracking-tight">Hoş Geldiniz, {clientData.name}</h1>
|
<h1 className="text-3xl font-semibold tracking-tight">Hoş Geldiniz, {clientData.name}</h1>
|
||||||
<p className="text-muted-foreground">İş süreçlerinizi ve aktif projelerinizi buradan takip edebilirsiniz.</p>
|
<p className="text-muted-foreground">İş süreçlerinizi ve aktif projelerinizi buradan takip edebilirsiniz.</p>
|
||||||
|
|||||||
@@ -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;
|
const pendingRevisions = revisions.filter((r: any) => r.status === 'pending' || r.status === 'in_progress').length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex max-w-5xl flex-col gap-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
<div className="mx-auto flex max-w-7xl flex-col gap-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||||
{/* Header Info */}
|
{/* Header Info */}
|
||||||
<div className="flex flex-col gap-4 md:flex-row md:items-start justify-between">
|
<div className="flex flex-col gap-4 md:flex-row md:items-start justify-between">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { MessageSquareDiff } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export default function PortalRevisionsPage() {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex max-w-7xl flex-col gap-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h1 className="text-3xl font-semibold tracking-tight">Revizyon Taleplerim</h1>
|
||||||
|
<p className="text-muted-foreground">İlettiğiniz tüm revizyon taleplerinin durumunu buradan takip edebilirsiniz.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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">
|
||||||
|
<MessageSquareDiff className="h-10 w-10 text-muted-foreground" />
|
||||||
|
<h3 className="mt-4 text-lg font-semibold text-foreground">
|
||||||
|
Revizyon modülü yapım aşamasında
|
||||||
|
</h3>
|
||||||
|
<p className="mt-2 max-w-md text-sm text-muted-foreground">
|
||||||
|
Yakında tüm revizyon taleplerinizi buradan yönetebileceksiniz. Şimdilik proje detay sayfasından revizyon talep edebilirsiniz.
|
||||||
|
</p>
|
||||||
|
<Link href="/portal" className="mt-4 text-primary hover:underline text-sm font-medium">
|
||||||
|
Dashboard'a dön
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { FolderKanban } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export default function PortalTasksPage() {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex max-w-7xl flex-col gap-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<h1 className="text-3xl font-semibold tracking-tight">Görevlerim</h1>
|
||||||
|
<p className="text-muted-foreground">Size atanan ve herkese açık olan proje görevleri.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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">
|
||||||
|
Görev modülü yapım aşamasında
|
||||||
|
</h3>
|
||||||
|
<p className="mt-2 max-w-md text-sm text-muted-foreground">
|
||||||
|
Yakında tüm görevlerinizi buradan takip edebileceksiniz. Şimdilik proje detay sayfasından görevlere ulaşabilirsiniz.
|
||||||
|
</p>
|
||||||
|
<Link href="/portal" className="mt-4 text-primary hover:underline text-sm font-medium">
|
||||||
|
Dashboard'a dön
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+1
-17
@@ -59,23 +59,7 @@ export default async function RegisterPage({
|
|||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
}
|
}
|
||||||
secondaryAction={
|
secondaryAction={null}
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="relative">
|
|
||||||
<div className="absolute inset-0 flex items-center">
|
|
||||||
<span className="w-full border-t border-border" />
|
|
||||||
</div>
|
|
||||||
<div className="relative flex justify-center text-xs uppercase">
|
|
||||||
<span className="bg-background px-2 text-muted-foreground">veya</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button type="button" variant="outline" className="h-11 w-full gap-2">
|
|
||||||
<Search className="h-4 w-4" />
|
|
||||||
Google ile devam et
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
footer={
|
footer={
|
||||||
<div className="text-center text-sm">
|
<div className="text-center text-sm">
|
||||||
Zaten hesabın var mı?{" "}
|
Zaten hesabın var mı?{" "}
|
||||||
|
|||||||
@@ -39,9 +39,10 @@ type PortalShellProps = {
|
|||||||
shortName: string;
|
shortName: string;
|
||||||
avatarUrl: string | null;
|
avatarUrl: string | null;
|
||||||
};
|
};
|
||||||
|
progress?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function PortalShell({ children, user }: PortalShellProps) {
|
export function PortalShell({ children, user, progress }: PortalShellProps) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);
|
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);
|
||||||
|
|
||||||
@@ -49,7 +50,7 @@ export function PortalShell({ children, user }: PortalShellProps) {
|
|||||||
<div className="min-h-screen bg-background text-foreground">
|
<div className="min-h-screen bg-background text-foreground">
|
||||||
<div className="flex min-h-screen">
|
<div className="flex min-h-screen">
|
||||||
<aside className="sticky top-0 hidden h-dvh shrink-0 self-start border-r border-border bg-background lg:block">
|
<aside className="sticky top-0 hidden h-dvh shrink-0 self-start border-r border-border bg-background lg:block">
|
||||||
<AppSidebar pathname={pathname} user={user} />
|
<AppSidebar pathname={pathname} user={user} progress={progress} />
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{isMobileSidebarOpen ? (
|
{isMobileSidebarOpen ? (
|
||||||
@@ -64,6 +65,7 @@ export function PortalShell({ children, user }: PortalShellProps) {
|
|||||||
<AppSidebar
|
<AppSidebar
|
||||||
pathname={pathname}
|
pathname={pathname}
|
||||||
user={user}
|
user={user}
|
||||||
|
progress={progress}
|
||||||
onNavigate={() => setIsMobileSidebarOpen(false)}
|
onNavigate={() => setIsMobileSidebarOpen(false)}
|
||||||
/>
|
/>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -71,7 +73,7 @@ export function PortalShell({ children, user }: PortalShellProps) {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="flex flex-1 flex-col min-w-0">
|
<div className="flex flex-1 flex-col min-w-0">
|
||||||
<header className="sticky top-0 z-30 flex h-14 items-center justify-between border-b border-border bg-background/95 px-4 backdrop-blur lg:hidden">
|
<header className="sticky top-0 z-30 flex h-14 items-center justify-between border-b border-border bg-background/95 px-4 backdrop-blur">
|
||||||
<Link href="/portal" className="flex items-center gap-2 font-semibold">
|
<Link href="/portal" className="flex items-center gap-2 font-semibold">
|
||||||
<Image
|
<Image
|
||||||
src="/logo/LogoWithBg.png"
|
src="/logo/LogoWithBg.png"
|
||||||
@@ -81,16 +83,18 @@ export function PortalShell({ children, user }: PortalShellProps) {
|
|||||||
className="rounded-sm object-cover"
|
className="rounded-sm object-cover"
|
||||||
priority
|
priority
|
||||||
/>
|
/>
|
||||||
Cognis Portal
|
<span className="hidden sm:inline">Cognis Portal</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="h-9 w-9 p-0"
|
className="h-9 w-9 p-0 lg:hidden"
|
||||||
onClick={() => setIsMobileSidebarOpen(true)}
|
onClick={() => setIsMobileSidebarOpen(true)}
|
||||||
>
|
>
|
||||||
<Menu className="h-4 w-4" />
|
<Menu className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="flex-1 p-4 lg:p-8 min-w-0">{children}</main>
|
<main className="flex-1 p-4 lg:p-8 min-w-0">{children}</main>
|
||||||
@@ -103,10 +107,12 @@ export function PortalShell({ children, user }: PortalShellProps) {
|
|||||||
function AppSidebar({
|
function AppSidebar({
|
||||||
pathname,
|
pathname,
|
||||||
user,
|
user,
|
||||||
|
progress = 0,
|
||||||
onNavigate,
|
onNavigate,
|
||||||
}: {
|
}: {
|
||||||
pathname: string;
|
pathname: string;
|
||||||
user: PortalShellProps["user"];
|
user: PortalShellProps["user"];
|
||||||
|
progress?: number;
|
||||||
onNavigate?: () => void;
|
onNavigate?: () => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@@ -115,20 +121,18 @@ function AppSidebar({
|
|||||||
className="flex h-dvh max-h-dvh flex-col overflow-hidden rounded-none border-0"
|
className="flex h-dvh max-h-dvh flex-col overflow-hidden rounded-none border-0"
|
||||||
>
|
>
|
||||||
<SidebarHeader className="shrink-0 border-b-0 px-6 py-6">
|
<SidebarHeader className="shrink-0 border-b-0 px-6 py-6">
|
||||||
<SidebarBranding
|
<div className="flex flex-col gap-2">
|
||||||
title="Cognis"
|
<div className="flex items-center justify-between text-sm font-medium">
|
||||||
subtitle="Client Portal"
|
<span className="text-muted-foreground">Aktif İlerleme</span>
|
||||||
logo={
|
<span className="text-primary">%{progress}</span>
|
||||||
<Image
|
</div>
|
||||||
src="/logo/LogoWithBg.png"
|
<div className="h-2 w-full bg-secondary rounded-full overflow-hidden">
|
||||||
alt="Cognis"
|
<div
|
||||||
width={36}
|
className="h-full bg-primary transition-all duration-500"
|
||||||
height={36}
|
style={{ width: `${progress}%` }}
|
||||||
className="h-9 w-9 rounded-sm object-cover"
|
|
||||||
priority
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
|
|
||||||
<SidebarSeparator className="mx-0 my-0 w-full" />
|
<SidebarSeparator className="mx-0 my-0 w-full" />
|
||||||
|
|||||||
@@ -22,4 +22,11 @@ export const portalSidebarData: PortalSidebarNavGroup[] = [
|
|||||||
{ title: "Dashboard", href: "/portal", icon: Sparkles },
|
{ 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 },
|
||||||
|
],
|
||||||
|
}
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -13,6 +13,9 @@
|
|||||||
"@ai-sdk/openai": "^3.0.68",
|
"@ai-sdk/openai": "^3.0.68",
|
||||||
"@ai-sdk/react": "^3.0.199",
|
"@ai-sdk/react": "^3.0.199",
|
||||||
"@base-ui/react": "^1.5.0",
|
"@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",
|
"@ducanh2912/next-pwa": "^10.2.9",
|
||||||
"@hookform/resolvers": "^5.4.0",
|
"@hookform/resolvers": "^5.4.0",
|
||||||
"@iconify/react": "^6.0.2",
|
"@iconify/react": "^6.0.2",
|
||||||
|
|||||||
Generated
+61
-5
@@ -20,6 +20,15 @@ importers:
|
|||||||
'@base-ui/react':
|
'@base-ui/react':
|
||||||
specifier: ^1.5.0
|
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)
|
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':
|
'@ducanh2912/next-pwa':
|
||||||
specifier: ^10.2.9
|
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))
|
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':
|
'@types/react':
|
||||||
optional: true
|
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':
|
'@dotenvx/dotenvx@1.71.0':
|
||||||
resolution: {integrity: sha512-KEUw/mGu+EDRhYWRTNGHIimVCs9NvMFaIXOGrHSXoCteKLE5EsJnmPjOPpYorjXVg/0xG0fbdVw720azw1z4ag==}
|
resolution: {integrity: sha512-KEUw/mGu+EDRhYWRTNGHIimVCs9NvMFaIXOGrHSXoCteKLE5EsJnmPjOPpYorjXVg/0xG0fbdVw720azw1z4ag==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@@ -6069,6 +6100,31 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.2.16
|
'@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':
|
'@dotenvx/dotenvx@1.71.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
commander: 11.1.0
|
commander: 11.1.0
|
||||||
@@ -8414,7 +8470,7 @@ snapshots:
|
|||||||
'@next/eslint-plugin-next': 16.2.7
|
'@next/eslint-plugin-next': 16.2.7
|
||||||
eslint: 9.39.4(jiti@2.7.0)
|
eslint: 9.39.4(jiti@2.7.0)
|
||||||
eslint-import-resolver-node: 0.3.10
|
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-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-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))
|
eslint-plugin-react: 7.37.5(eslint@9.39.4(jiti@2.7.0))
|
||||||
@@ -8437,7 +8493,7 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- 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:
|
dependencies:
|
||||||
'@nolyfill/is-core-module': 1.0.39
|
'@nolyfill/is-core-module': 1.0.39
|
||||||
debug: 4.4.3
|
debug: 4.4.3
|
||||||
@@ -8452,14 +8508,14 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- 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:
|
dependencies:
|
||||||
debug: 3.2.7
|
debug: 3.2.7
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@typescript-eslint/parser': 8.60.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)
|
'@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-import-resolver-node: 0.3.10
|
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:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
@@ -8474,7 +8530,7 @@ snapshots:
|
|||||||
doctrine: 2.1.0
|
doctrine: 2.1.0
|
||||||
eslint: 9.39.4(jiti@2.7.0)
|
eslint: 9.39.4(jiti@2.7.0)
|
||||||
eslint-import-resolver-node: 0.3.10
|
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
|
hasown: 2.0.4
|
||||||
is-core-module: 2.16.2
|
is-core-module: 2.16.2
|
||||||
is-glob: 4.0.3
|
is-glob: 4.0.3
|
||||||
|
|||||||
@@ -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();
|
||||||
Reference in New Issue
Block a user