feat: implement portal shell layout with user authentication, sidebar navigation, and progress tracking

This commit is contained in:
Poyraz Avsever
2026-06-08 13:56:06 +03:00
parent 47b9f728e6
commit d41267c29b
17 changed files with 582 additions and 130 deletions
+20
View File
@@ -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");
}
+173 -63
View File
@@ -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,83 +241,133 @@ export function ClientsClient({
</div> </div>
<TabsContent value="pipeline" className="mt-0"> <TabsContent value="pipeline" className="mt-0">
<div className="flex gap-4 overflow-x-auto pb-4 snap-x"> <DndContext
{pipelineStages.map(stage => { sensors={sensors}
const stageClients = filteredClients.filter(c => c.pipeline_stage === stage.id && c.status !== 'archived'); collisionDetection={closestCorners}
return ( onDragStart={handleDragStart}
<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]"> onDragEnd={handleDragEnd}
<div className="flex items-center justify-between mb-3 px-1"> >
<h3 className="font-semibold text-sm text-foreground flex items-center gap-2"> <div className="flex gap-4 overflow-x-auto pb-4 snap-x">
<span className={`w-2 h-2 rounded-full ${stage.color.split(' ')[1]}`}></span> {pipelineStages.map(stage => {
{stage.label} const stageClients = filteredClients.filter(c => c.pipeline_stage === stage.id && c.status !== 'archived');
</h3> return (
<Badge variant="secondary" className="text-xs">{stageClients.length}</Badge> <DroppableColumn
</div> key={stage.id}
<div className="flex-1 overflow-y-auto space-y-3 pr-1 tiny-scrollbar"> id={stage.id}
title={stage.label}
count={stageClients.length}
color={stage.color.split(' ')[1]}
>
{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> {filteredClients.length > 0 ? (
<CardContent className="p-0"> <div className="overflow-hidden rounded-sm border border-border">
{filteredClients.length > 0 ? ( <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="overflow-hidden rounded-sm border border-border"> <span>Müşteri</span>
<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"> <span>İletişim</span>
<span>Müşteri</span> <span>Aşama</span>
<span>İletişim</span> <span>Follow-up</span>
<span>Aşama</span> <span>Projeler</span>
<span>Follow-up</span> <span className="text-right">İşlem</span>
<span>Projeler</span> </div>
<span className="text-right">İşlem</span> <div className="divide-y divide-border">
</div> {filteredClients.map((client) => (
<div className="divide-y divide-border"> <ClientRow key={client.id} client={client} />
{filteredClients.map((client) => ( ))}
<ClientRow key={client.id} client={client} /> </div>
))} </div>
</div> ) : (
</div> <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];
+5 -1
View File
@@ -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,
+25
View File
@@ -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
View File
@@ -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?{" "}
+19
View File
@@ -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
View File
@@ -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">
+26
View File
@@ -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>
);
}
+26
View File
@@ -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
View File
@@ -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ı?{" "}
+29 -25
View File
@@ -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>
<Button <div className="flex items-center gap-4">
type="button" <Button
variant="outline" type="button"
className="h-9 w-9 p-0" variant="outline"
onClick={() => setIsMobileSidebarOpen(true)} className="h-9 w-9 p-0 lg:hidden"
> onClick={() => setIsMobileSidebarOpen(true)}
<Menu className="h-4 w-4" /> >
</Button> <Menu className="h-4 w-4" />
</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" />
+7
View File
@@ -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 },
],
}
]; ];
+3
View File
@@ -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",
+61 -5
View File
@@ -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();