feat: implement client portal project view with revision request functionality and layout structure
This commit is contained in:
@@ -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<HTMLFormElement>) {
|
||||
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 (
|
||||
<div className="mx-auto flex max-w-5xl flex-col gap-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||
{/* Header Info */}
|
||||
@@ -75,11 +108,52 @@ export function ClientDetailClient({ client, activities }: { client: ClientDetai
|
||||
{client.company_name && <p className="text-muted-foreground mt-1">{client.company_name}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex gap-2 items-center">
|
||||
<Badge variant="outline" className="px-3 py-1 capitalize text-sm">{client.status}</Badge>
|
||||
<Badge className="bg-blue-500/10 text-blue-500 border-blue-500/20 px-3 py-1 capitalize text-sm">
|
||||
{client.pipeline_stage.replace('_', ' ')}
|
||||
</Badge>
|
||||
{!client.client_auth_id && (
|
||||
<Dialog open={createUserOpen} onOpenChange={setCreateUserOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="gap-2 ml-2 border-dashed">
|
||||
<UserPlus className="h-4 w-4" /> Portal Hesabı Aç
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<form onSubmit={handleCreateUser}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Müşteri Portalı Hesabı Oluştur</DialogTitle>
|
||||
<DialogDescription>
|
||||
Müşteriniz bu e-posta ve şifre ile sisteme giriş yaparak projelerini takip edebilir.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">E-posta Adresi</Label>
|
||||
<Input id="email" name="email" type="email" required defaultValue={client.email || ""} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Geçici Şifre</Label>
|
||||
<Input id="password" name="password" type="text" required minLength={6} placeholder="Min 6 karakter" />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={() => setCreateUserOpen(false)}>İptal</Button>
|
||||
<Button type="submit" disabled={isCreatingUser}>
|
||||
{isCreatingUser && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Hesabı Oluştur
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
{client.client_auth_id && (
|
||||
<Badge className="bg-emerald-500/10 text-emerald-600 border-emerald-500/20 px-3 py-1 text-sm flex items-center gap-1.5 ml-2">
|
||||
<UserPlus className="h-3.5 w-3.5" /> Portal Aktif
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(" ") ||
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
<PortalShell
|
||||
user={{
|
||||
email: user.email ?? "bilinmiyor@mindspace.local",
|
||||
displayName,
|
||||
shortName,
|
||||
avatarUrl: profile?.avatar_url || null,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</PortalShell>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="flex flex-col items-center justify-center h-[50vh] text-center gap-4">
|
||||
<h2 className="text-2xl font-semibold">Hesabınız Henüz Aktif Değil</h2>
|
||||
<p className="text-muted-foreground max-w-md">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div className="flex 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">Hoş Geldiniz, {clientData.name}</h1>
|
||||
<p className="text-muted-foreground">İş süreçlerinizi ve aktif projelerinizi buradan takip edebilirsiniz.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardContent className="p-6 flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2 text-primary">
|
||||
<FolderKanban className="h-5 w-5" />
|
||||
<h3 className="font-semibold">Aktif Projeler</h3>
|
||||
</div>
|
||||
<p className="text-3xl font-bold">{activeProjects.length}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-6 flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2 text-emerald-600">
|
||||
<CheckCircle2 className="h-5 w-5" />
|
||||
<h3 className="font-semibold">Tamamlanan</h3>
|
||||
</div>
|
||||
<p className="text-3xl font-bold">{completedProjects.length}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-xl font-semibold">Tüm Projeleriniz</h2>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{projects.length === 0 ? (
|
||||
<div className="col-span-full py-10 text-center border rounded-lg border-dashed text-muted-foreground">
|
||||
Henüz size atanmış bir proje bulunmuyor.
|
||||
</div>
|
||||
) : (
|
||||
projects.map(project => (
|
||||
<Link key={project.id} href={`/portal/projects/${project.id}`}>
|
||||
<Card className="hover:border-primary/50 transition-colors h-full">
|
||||
<CardContent className="p-5 flex flex-col h-full justify-between gap-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-start justify-between">
|
||||
<h3 className="font-semibold text-lg line-clamp-2">{project.name}</h3>
|
||||
<Badge variant={project.status === 'completed' ? 'secondary' : 'default'} className="capitalize shrink-0">
|
||||
{project.status}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{project.due_date && (
|
||||
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<Clock className="h-4 w-4" />
|
||||
<span>Son Teslim: {format(new Date(project.due_date), 'd MMM yyyy', { locale: tr })}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between text-xs font-medium">
|
||||
<span>İlerleme</span>
|
||||
<span>%{project.progress}</span>
|
||||
</div>
|
||||
<div className="h-2 w-full bg-secondary rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary transition-all duration-500"
|
||||
style={{ width: `${project.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 (
|
||||
<PortalProjectClient
|
||||
project={project}
|
||||
sections={sectionsData || []}
|
||||
tasks={tasksData || []}
|
||||
revisions={revisionsData || []}
|
||||
clientId={clientData.id}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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<HTMLFormElement>) => {
|
||||
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 (
|
||||
<div className="mx-auto flex max-w-5xl flex-col gap-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||
{/* Header Info */}
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-start justify-between">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-3xl font-bold text-foreground">{project.name}</h1>
|
||||
{project.description && <p className="text-muted-foreground">{project.description}</p>}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 md:items-end">
|
||||
<div className="flex gap-2">
|
||||
<Badge variant="outline" className="px-3 py-1 capitalize text-sm">{project.status}</Badge>
|
||||
<Dialog open={openRevision} onOpenChange={setOpenRevision}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="gap-2 shrink-0">
|
||||
<RefreshCw className="h-4 w-4" /> Revizyon Talep Et
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<form onSubmit={handleRevision}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Yeni Revizyon Talebi</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="py-4 space-y-4">
|
||||
{pendingRevisions > 0 && (
|
||||
<div className="p-3 bg-amber-500/10 text-amber-600 rounded-md text-sm border border-amber-500/20">
|
||||
Şu anda sonuçlanmamış {pendingRevisions} adet revizyon talebiniz var. Yeni bir tane eklemek istediğinize emin misiniz?
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label>Lütfen yapılmasını istediğiniz değişiklikleri detaylıca açıklayın</Label>
|
||||
<Textarea name="description" required rows={5} placeholder="Şu kısmın rengi mavi olabilir mi? Ayrıca metinleri güncelleyelim..." />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={() => setOpenRevision(false)}>İptal</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Talebi Gönder
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
{project.due_date && (
|
||||
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<Clock className="h-4 w-4" />
|
||||
<span>Teslim: {format(new Date(project.due_date), 'd MMM yyyy', { locale: tr })}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="md:col-span-2 space-y-6">
|
||||
{/* Project Planning / Milestones */}
|
||||
<Card>
|
||||
<CardContent className="p-5 space-y-6">
|
||||
<h3 className="font-semibold text-lg border-b border-border pb-2">Proje Planı & Aşamalar</h3>
|
||||
{sections.length === 0 ? (
|
||||
<p className="text-muted-foreground italic">Henüz bir plan yüklenmemiş.</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{sections.map((section: any) => (
|
||||
<div key={section.id} className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="font-medium text-foreground">{section.title}</h4>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{section.type === 'milestone' ? 'Milestone' : section.type === 'deliverable' ? 'Teslimat' : 'Not'}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground whitespace-pre-wrap">{section.content}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Revisions */}
|
||||
{revisions.length > 0 && (
|
||||
<Card>
|
||||
<CardContent className="p-5 space-y-6">
|
||||
<h3 className="font-semibold text-lg border-b border-border pb-2">Revizyon Talepleriniz</h3>
|
||||
<div className="space-y-4">
|
||||
{revisions.map((rev: any) => (
|
||||
<div key={rev.id} className="p-4 rounded-md border border-border bg-muted/20">
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Clock className="h-3 w-3" />
|
||||
{format(new Date(rev.created_at), "d MMM yyyy, HH:mm", { locale: tr })}
|
||||
</div>
|
||||
<Badge variant={
|
||||
rev.status === 'completed' ? 'default' :
|
||||
rev.status === 'rejected' ? 'destructive' : 'secondary'
|
||||
}>
|
||||
{rev.status === 'pending' ? 'Bekliyor' :
|
||||
rev.status === 'in_progress' ? 'İşleniyor' :
|
||||
rev.status === 'completed' ? 'Tamamlandı' : 'Reddedildi'}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-foreground whitespace-pre-wrap">{rev.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right Sidebar */}
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardContent className="p-5 space-y-4">
|
||||
<h3 className="font-semibold">İlerleme Durumu</h3>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-2xl font-bold">
|
||||
<span>%{project.progress}</span>
|
||||
</div>
|
||||
<div className="h-3 w-full bg-secondary rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary transition-all duration-500"
|
||||
style={{ width: `${project.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-5 space-y-4">
|
||||
<h3 className="font-semibold flex items-center gap-2">
|
||||
<CheckCircle2 className="h-4 w-4" /> Tamamlanan İşler
|
||||
</h3>
|
||||
{tasks.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground italic">Listelenecek açık görev bulunmuyor.</p>
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
{tasks.map((task: any) => (
|
||||
<li key={task.id} className="text-sm flex gap-2">
|
||||
{task.status === 'completed' ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-emerald-500 shrink-0 mt-0.5" />
|
||||
) : (
|
||||
<div className="h-4 w-4 rounded-full border-2 border-muted-foreground shrink-0 mt-0.5" />
|
||||
)}
|
||||
<div>
|
||||
<span className={task.status === 'completed' ? "line-through text-muted-foreground" : "text-foreground"}>
|
||||
{task.title}
|
||||
</span>
|
||||
{task.date && (
|
||||
<div className="text-xs text-muted-foreground mt-0.5">
|
||||
{format(new Date(task.date), 'd MMM yyyy', { locale: tr })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import { signOut } from "@/app/login/actions";
|
||||
import { Button } from "poyraz-ui/atoms";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "poyraz-ui/molecules";
|
||||
import { LogOut, User } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
type PortalShellProps = {
|
||||
children: React.ReactNode;
|
||||
user: {
|
||||
email: string;
|
||||
displayName: string;
|
||||
shortName: string;
|
||||
avatarUrl: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
export function PortalShell({ children, user }: PortalShellProps) {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-foreground flex flex-col">
|
||||
<header className="sticky top-0 z-30 flex h-16 items-center justify-between border-b border-border bg-background/95 px-4 md:px-8 backdrop-blur">
|
||||
<div className="flex items-center gap-6">
|
||||
<Link href="/portal" className="flex items-center gap-2 font-semibold">
|
||||
<Image
|
||||
src="/logo/LogoWithBg.png"
|
||||
alt="Cognis"
|
||||
width={32}
|
||||
height={32}
|
||||
className="rounded-sm object-cover"
|
||||
priority
|
||||
/>
|
||||
Cognis Portal
|
||||
</Link>
|
||||
<nav className="hidden md:flex items-center gap-4 text-sm font-medium">
|
||||
<Link
|
||||
href="/portal"
|
||||
className={`transition-colors hover:text-primary ${pathname === '/portal' ? 'text-primary' : 'text-muted-foreground'}`}
|
||||
>
|
||||
Dashboard
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-9 w-9 rounded-full p-0 border border-border overflow-hidden">
|
||||
{user.avatarUrl ? (
|
||||
<Image src={user.avatarUrl} alt={user.displayName} width={36} height={36} className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<span className="text-xs font-medium">{user.shortName}</span>
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuLabel className="font-normal">
|
||||
<div className="flex flex-col space-y-1">
|
||||
<p className="text-sm font-medium leading-none">{user.displayName}</p>
|
||||
<p className="text-xs leading-none text-muted-foreground">
|
||||
{user.email}
|
||||
</p>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="text-rose-500" onClick={() => signOut()}>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
Çıkış Yap
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 w-full max-w-7xl mx-auto p-4 md:p-8">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user