From c361fff5a7fbeb81e17f6484b5daf3532c59405c Mon Sep 17 00:00:00 2001 From: poyrazavsever Date: Sat, 6 Jun 2026 22:31:55 +0300 Subject: [PATCH] feat: implement client portal project view with revision request functionality and layout structure --- .../clients/[id]/client-detail-client.tsx | 80 ++++++- app/(dashboard)/clients/[id]/page.tsx | 2 +- app/(dashboard)/layout.tsx | 7 +- app/api/create-client-user/route.ts | 71 +++++++ app/portal/layout.tsx | 54 +++++ app/portal/page.tsx | 122 +++++++++++ app/portal/projects/[id]/actions.ts | 36 ++++ app/portal/projects/[id]/page.tsx | 67 ++++++ .../projects/[id]/portal-project-client.tsx | 199 ++++++++++++++++++ components/layout/portal-shell.tsx | 91 ++++++++ 10 files changed, 724 insertions(+), 5 deletions(-) create mode 100644 app/api/create-client-user/route.ts create mode 100644 app/portal/layout.tsx create mode 100644 app/portal/page.tsx create mode 100644 app/portal/projects/[id]/actions.ts create mode 100644 app/portal/projects/[id]/page.tsx create mode 100644 app/portal/projects/[id]/portal-project-client.tsx create mode 100644 components/layout/portal-shell.tsx diff --git a/app/(dashboard)/clients/[id]/client-detail-client.tsx b/app/(dashboard)/clients/[id]/client-detail-client.tsx index 00fdf30..08b1669 100644 --- a/app/(dashboard)/clients/[id]/client-detail-client.tsx +++ b/app/(dashboard)/clients/[id]/client-detail-client.tsx @@ -4,9 +4,10 @@ import { useState } from "react"; import { format } from "date-fns"; import { tr } from "date-fns/locale"; import { Card, CardContent, Badge, Button, Input, Textarea, Label } from "poyraz-ui/atoms"; -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogFooter, Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "poyraz-ui/molecules"; -import { Phone, Mail, ExternalLink, Calendar, Plus, MessageSquare, Briefcase, FileText } from "lucide-react"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogFooter, Select, SelectTrigger, SelectValue, SelectContent, SelectItem, DialogDescription } from "poyraz-ui/molecules"; +import { Phone, Mail, ExternalLink, Calendar, Plus, MessageSquare, Briefcase, FileText, UserPlus, Loader2 } from "lucide-react"; import Link from "next/link"; +import toast from "react-hot-toast"; import { addClientActivity } from "./actions"; export type ClientDetailData = { @@ -19,6 +20,7 @@ export type ClientDetailData = { pipeline_stage: string; status: string; notes: string | null; + client_auth_id: string | null; }; export type ClientActivity = { @@ -62,6 +64,37 @@ export function ClientDetailClient({ client, activities }: { client: ClientDetai } } + const [isCreatingUser, setIsCreatingUser] = useState(false); + const [createUserOpen, setCreateUserOpen] = useState(false); + + async function handleCreateUser(e: React.FormEvent) { + e.preventDefault(); + const formData = new FormData(e.currentTarget); + const email = formData.get("email") as string; + const password = formData.get("password") as string; + + setIsCreatingUser(true); + try { + const res = await fetch("/api/create-client-user", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password, client_id: client.id }) + }); + const data = await res.json(); + if (!res.ok || data.error) { + throw new Error(data.error || "Kullanıcı oluşturulamadı."); + } + toast.success("Müşteri portal hesabı başarıyla oluşturuldu."); + setCreateUserOpen(false); + // Optional: Refresh page to reflect the new client_auth_id + window.location.reload(); + } catch (err: any) { + toast.error(err.message); + } finally { + setIsCreatingUser(false); + } + } + return (
{/* Header Info */} @@ -75,11 +108,52 @@ export function ClientDetailClient({ client, activities }: { client: ClientDetai {client.company_name &&

{client.company_name}

}
-
+
{client.status} {client.pipeline_stage.replace('_', ' ')} + {!client.client_auth_id && ( + + + + + +
+ + Müşteri Portalı Hesabı Oluştur + + Müşteriniz bu e-posta ve şifre ile sisteme giriş yaparak projelerini takip edebilir. + + +
+
+ + +
+
+ + +
+
+ + + + +
+
+
+ )} + {client.client_auth_id && ( + + Portal Aktif + + )}
diff --git a/app/(dashboard)/clients/[id]/page.tsx b/app/(dashboard)/clients/[id]/page.tsx index 606e9d4..485736e 100644 --- a/app/(dashboard)/clients/[id]/page.tsx +++ b/app/(dashboard)/clients/[id]/page.tsx @@ -11,7 +11,7 @@ export default async function ClientDetailPage({ params }: { params: Promise<{ i const { data: clientData, error } = await supabase .from("clients") - .select("id, name, company_name, email, phone, website, pipeline_stage, status, notes") + .select("id, name, company_name, email, phone, website, pipeline_stage, status, notes, client_auth_id") .eq("id", id) .eq("user_id", user.id) .single(); diff --git a/app/(dashboard)/layout.tsx b/app/(dashboard)/layout.tsx index 3594287..9c682bc 100644 --- a/app/(dashboard)/layout.tsx +++ b/app/(dashboard)/layout.tsx @@ -14,11 +14,16 @@ export default async function DashboardLayout({ const { data: profile } = user ? await supabase .from("profiles") - .select("first_name, last_name, avatar_url") + .select("first_name, last_name, avatar_url, role") .eq("id", user.id) .maybeSingle() : { data: null }; + if (profile?.role === "client") { + const { redirect } = await import("next/navigation"); + redirect("/portal"); + } + const fallbackName = user?.email?.split("@")[0] ?? "Cognis Kullanıcısı"; const displayName = [profile?.first_name, profile?.last_name].filter(Boolean).join(" ") || diff --git a/app/api/create-client-user/route.ts b/app/api/create-client-user/route.ts new file mode 100644 index 0000000..fc0aaff --- /dev/null +++ b/app/api/create-client-user/route.ts @@ -0,0 +1,71 @@ +import { createClient } from "@supabase/supabase-js"; +import { NextResponse } from "next/server"; + +export async function POST(request: Request) { + try { + const { email, password, client_id } = await request.json(); + + if (!email || !password || !client_id) { + return NextResponse.json( + { error: "Email, şifre ve müşteri ID gereklidir." }, + { status: 400 } + ); + } + + // Initialize Supabase Admin client with service role key + const supabaseAdmin = createClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.SUPABASE_SERVICE_ROLE_KEY! + ); + + // 1. Create the user in auth.users + const { data: authData, error: authError } = await supabaseAdmin.auth.admin.createUser({ + email, + password, + email_confirm: true, + }); + + if (authError || !authData.user) { + return NextResponse.json( + { error: authError?.message || "Kullanıcı oluşturulamadı." }, + { status: 400 } + ); + } + + const userId = authData.user.id; + + // 2. Wait for trigger to create the profile (it might take a fraction of a second, but usually synchronous in Postgres) + // We update the profile to set the role to 'client' + const { error: profileError } = await supabaseAdmin + .from("profiles") + .update({ role: "client" }) + .eq("id", userId); + + if (profileError) { + console.error("Profile update error:", profileError); + // Optional: Handle partial failure + } + + // 3. Link the user to the client record + const { error: clientError } = await supabaseAdmin + .from("clients") + .update({ client_auth_id: userId }) + .eq("id", client_id); + + if (clientError) { + console.error("Client link error:", clientError); + return NextResponse.json( + { error: "Kullanıcı oluşturuldu fakat müşteri kaydıyla ilişkilendirilemedi." }, + { status: 500 } + ); + } + + return NextResponse.json({ success: true, user: authData.user }); + } catch (err: any) { + console.error("Create client user error:", err); + return NextResponse.json( + { error: "Sunucu tarafında beklenmeyen bir hata oluştu." }, + { status: 500 } + ); + } +} diff --git a/app/portal/layout.tsx b/app/portal/layout.tsx new file mode 100644 index 0000000..1f888d7 --- /dev/null +++ b/app/portal/layout.tsx @@ -0,0 +1,54 @@ +import { PortalShell } from "@/components/layout/portal-shell"; +import { createClient } from "@/lib/supabase/server"; +import { redirect } from "next/navigation"; + +export default async function PortalLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + + if (!user) { + redirect("/login"); + } + + const { data: profile } = await supabase + .from("profiles") + .select("first_name, last_name, avatar_url, role") + .eq("id", user.id) + .maybeSingle(); + + if (profile?.role !== "client") { + redirect("/"); + } + + const fallbackName = user.email?.split("@")[0] ?? "Müşteri"; + const displayName = + [profile?.first_name, profile?.last_name].filter(Boolean).join(" ") || + fallbackName; + + const shortName = displayName + .split(" ") + .filter(Boolean) + .slice(0, 2) + .map((part) => part[0]?.toUpperCase()) + .join("") + .slice(0, 2) || "MS"; + + return ( + + {children} + + ); +} diff --git a/app/portal/page.tsx b/app/portal/page.tsx new file mode 100644 index 0000000..0481b12 --- /dev/null +++ b/app/portal/page.tsx @@ -0,0 +1,122 @@ +import { createClient } from "@/lib/supabase/server"; +import { Card, CardContent } from "poyraz-ui/atoms"; +import { Badge } from "poyraz-ui/molecules"; +import { FolderKanban, CheckCircle2, Clock, ArrowRight } from "lucide-react"; +import Link from "next/link"; +import { format } from "date-fns"; +import { tr } from "date-fns/locale"; + +export default async function PortalDashboardPage() { + const supabase = await createClient(); + const { data: { user } } = await supabase.auth.getUser(); + + if (!user) return null; + + // 1. Get the Client record + const { data: clientData } = await supabase + .from("clients") + .select("id, name, company_name") + .eq("client_auth_id", user.id) + .single(); + + if (!clientData) { + return ( +
+

Hesabınız Henüz Aktif Değil

+

+ Freelancer'ınız sizin için hesabı oluşturdu ancak müşteri kartınızla henüz eşleşmedi veya bir hata oluştu. Lütfen iletişime geçin. +

+
+ ); + } + + // 2. Get Projects + const { data: projectsData } = await supabase + .from("projects") + .select("id, name, status, progress, due_date, budget_amount, currency") + .eq("client_id", clientData.id) + .order("created_at", { ascending: false }); + + const projects = projectsData || []; + + const activeProjects = projects.filter(p => p.status !== 'completed' && p.status !== 'cancelled'); + const completedProjects = projects.filter(p => p.status === 'completed'); + + return ( +
+
+

Hoş Geldiniz, {clientData.name}

+

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

+
+ +
+ + +
+ +

Aktif Projeler

+
+

{activeProjects.length}

+
+
+ + +
+ +

Tamamlanan

+
+

{completedProjects.length}

+
+
+
+ +
+

Tüm Projeleriniz

+
+ {projects.length === 0 ? ( +
+ Henüz size atanmış bir proje bulunmuyor. +
+ ) : ( + projects.map(project => ( + + + +
+
+

{project.name}

+ + {project.status} + +
+ + {project.due_date && ( +
+ + Son Teslim: {format(new Date(project.due_date), 'd MMM yyyy', { locale: tr })} +
+ )} +
+ +
+
+ İlerleme + %{project.progress} +
+
+
+
+
+ + + + )) + )} +
+
+
+ ); +} diff --git a/app/portal/projects/[id]/actions.ts b/app/portal/projects/[id]/actions.ts new file mode 100644 index 0000000..a914db4 --- /dev/null +++ b/app/portal/projects/[id]/actions.ts @@ -0,0 +1,36 @@ +"use server"; + +import { createClient } from "@/lib/supabase/server"; +import { revalidatePath } from "next/cache"; + +export async function createRevisionRequest(projectId: string, clientId: string, formData: FormData) { + const supabase = await createClient(); + const { data: { user } } = await supabase.auth.getUser(); + + if (!user) { + return { error: "Oturum süresi dolmuş." }; + } + + const description = formData.get("description") as string; + + if (!description?.trim()) { + return { error: "Revizyon açıklaması boş olamaz." }; + } + + const { error } = await supabase + .from("project_revisions") + .insert({ + project_id: projectId, + client_id: clientId, + requested_by: user.id, + description, + status: "pending" + }); + + if (error) { + return { error: error.message }; + } + + revalidatePath(`/portal/projects/${projectId}`); + return { success: true }; +} diff --git a/app/portal/projects/[id]/page.tsx b/app/portal/projects/[id]/page.tsx new file mode 100644 index 0000000..d501e67 --- /dev/null +++ b/app/portal/projects/[id]/page.tsx @@ -0,0 +1,67 @@ +import { createClient } from "@/lib/supabase/server"; +import { notFound } from "next/navigation"; +import { PortalProjectClient } from "./portal-project-client"; + +export default async function PortalProjectPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const supabase = await createClient(); + const { data: { user } } = await supabase.auth.getUser(); + + if (!user) return null; + + // 1. Get Client Record + const { data: clientData } = await supabase + .from("clients") + .select("id") + .eq("client_auth_id", user.id) + .single(); + + if (!clientData) { + notFound(); + } + + // 2. Get Project + const { data: project, error } = await supabase + .from("projects") + .select("*") + .eq("id", id) + .eq("client_id", clientData.id) + .single(); + + if (error || !project) { + notFound(); + } + + // 3. Get Planning Sections (Milestones etc.) + const { data: sectionsData } = await supabase + .from("project_planning_sections") + .select("*") + .eq("project_id", id) + .order("order_index", { ascending: true }); + + // 4. Get Public Tasks + const { data: tasksData } = await supabase + .from("tasks") + .select("*") + .eq("project_id", id) + .eq("is_public_to_client", true) + .order("date", { ascending: false }); + + // 5. Get Revisions + const { data: revisionsData } = await supabase + .from("project_revisions") + .select("id, description, status, created_at, requested_by") + .eq("project_id", id) + .eq("client_id", clientData.id) + .order("created_at", { ascending: false }); + + return ( + + ); +} diff --git a/app/portal/projects/[id]/portal-project-client.tsx b/app/portal/projects/[id]/portal-project-client.tsx new file mode 100644 index 0000000..a5f942d --- /dev/null +++ b/app/portal/projects/[id]/portal-project-client.tsx @@ -0,0 +1,199 @@ +"use client"; + +import { useState } from "react"; +import { format } from "date-fns"; +import { tr } from "date-fns/locale"; +import { Card, CardContent, Badge, Button, Textarea, Label } from "poyraz-ui/atoms"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogFooter } from "poyraz-ui/molecules"; +import { CheckCircle2, Clock, MessageSquare, Loader2, RefreshCw } from "lucide-react"; +import toast from "react-hot-toast"; +import { createRevisionRequest } from "./actions"; + +export function PortalProjectClient({ project, sections, tasks, revisions, clientId }: any) { + const [isSubmitting, setIsSubmitting] = useState(false); + const [openRevision, setOpenRevision] = useState(false); + + const handleRevision = async (e: React.FormEvent) => { + e.preventDefault(); + setIsSubmitting(true); + const formData = new FormData(e.currentTarget); + try { + const res = await createRevisionRequest(project.id, clientId, formData); + if (res.error) throw new Error(res.error); + toast.success("Revizyon talebiniz başarıyla iletildi."); + setOpenRevision(false); + } catch (err: any) { + toast.error(err.message); + } finally { + setIsSubmitting(false); + } + }; + + const pendingRevisions = revisions.filter((r: any) => r.status === 'pending' || r.status === 'in_progress').length; + + return ( +
+ {/* Header Info */} +
+
+

{project.name}

+ {project.description &&

{project.description}

} +
+
+
+ {project.status} + + + + + +
+ + Yeni Revizyon Talebi + +
+ {pendingRevisions > 0 && ( +
+ Şu anda sonuçlanmamış {pendingRevisions} adet revizyon talebiniz var. Yeni bir tane eklemek istediğinize emin misiniz? +
+ )} +
+ +