feat(backend): migrate freelancer and portal runtimes

This commit is contained in:
poyrazavsever
2026-07-17 00:16:38 +03:00
parent 561af11b70
commit 678c0236db
41 changed files with 5293 additions and 2324 deletions
+8 -3
View File
@@ -1,14 +1,19 @@
import { PortalShell } from "@/components/layout/portal-shell";
import { requireClientUser } from "@/server/auth/session";
import { getPublicBranding } from "@/server/branding/runtime";
import { requirePortalBackend } from "@/server/web/portal";
export default async function PortalLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const { user, profile } = await requireClientUser();
const { context, actor, service } = await requirePortalBackend();
const { user, profile } = context;
const branding = getPublicBranding();
const projects = service.listProjects(actor);
const progress = projects.length
? Math.round(projects.reduce((sum, project) => sum + project.progress, 0) / projects.length)
: 0;
const displayName = profile.displayName || user.name || user.email.split("@")[0] || "Müşteri";
const shortName =
@@ -34,7 +39,7 @@ export default async function PortalLayout({
shortName,
avatarUrl: user.image || null,
}}
progress={0}
progress={progress}
>
{children}
</PortalShell>
+49 -95
View File
@@ -1,51 +1,22 @@
import { createClient } from "@/lib/supabase/server";
import { Card, CardContent, Badge } from "poyraz-ui/atoms";
import { FolderKanban, CheckCircle2, Clock, Activity, BarChart } from "lucide-react";
import { FolderKanban, CheckCircle2, Clock, Activity, BarChart, type LucideIcon } from "lucide-react";
import Link from "next/link";
import { format } from "date-fns";
import { tr } from "date-fns/locale";
import { requirePortalBackend } from "@/server/web/portal";
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, created_at")
.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');
const avgProgress = projects.length > 0 ? (projects.reduce((sum, p) => sum + (p.progress || 0), 0) / projects.length).toFixed(0) : "0";
const { context, actor, service } = await requirePortalBackend();
const client = service.getClient(actor, context.profile.clientId!);
const projects = service.listProjects(actor);
const activeProjects = projects.filter((project) => project.status !== "completed" && project.status !== "cancelled");
const completedProjects = projects.filter((project) => project.status === "completed");
const avgProgress = projects.length
? Math.round(projects.reduce((sum, project) => sum + project.progress, 0) / projects.length)
: 0;
return (
<div className="mx-auto flex max-w-7xl flex-col gap-6">
{/* Header */}
<div className="flex flex-col gap-4 border-b border-border pb-5 lg:flex-row lg:items-end lg:justify-between">
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
@@ -53,24 +24,20 @@ export default async function PortalDashboardPage() {
Genel Bakış
</div>
<div>
<h1 className="text-3xl font-semibold tracking-normal text-foreground">
Müşteri Paneli
</h1>
<h1 className="text-3xl font-semibold tracking-normal text-foreground">Müşteri Paneli</h1>
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
Hoş geldiniz, {clientData.name}. Aktif projelerinizi ve ilerlemeleri buradan takip edin.
Hoş geldiniz, {client.name}. Aktif projelerinizi ve ilerlemeleri buradan takip edin.
</p>
</div>
</div>
</div>
{/* KPI Cards */}
<div className="grid gap-4 md:grid-cols-3">
<StatCard label="Aktif Projeler" value={activeProjects.length.toString()} icon={FolderKanban} tone="blue" />
<StatCard label="Tamamlanan" value={completedProjects.length.toString()} icon={CheckCircle2} tone="green" />
<StatCard label="Aktif Projeler" value={String(activeProjects.length)} icon={FolderKanban} tone="blue" />
<StatCard label="Tamamlanan" value={String(completedProjects.length)} icon={CheckCircle2} tone="green" />
<StatCard label="Ortalama İlerleme" value={`%${avgProgress}`} icon={BarChart} tone="amber" />
</div>
{/* Projects */}
<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">
@@ -78,64 +45,52 @@ export default async function PortalDashboardPage() {
<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-3">
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
<div className={`h-2 w-2 shrink-0 rounded-full ${project.status === 'completed' ? 'bg-emerald-500' : project.status === 'active' ? 'bg-blue-500' : 'bg-amber-500'}`} />
<h3 className="font-semibold text-base line-clamp-2 leading-tight">{project.name}</h3>
) : 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-3">
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
<div className={`h-2 w-2 shrink-0 rounded-full ${project.status === "completed" ? "bg-emerald-500" : project.status === "active" ? "bg-blue-500" : "bg-amber-500"}`} />
<h3 className="font-semibold text-base line-clamp-2 leading-tight">{project.name}</h3>
</div>
</div>
<div className="flex items-center gap-2 flex-wrap">
<Badge variant={project.status === "completed" ? "secondary" : "default"} className="capitalize text-[10px] px-1.5 py-0">
{project.status === "completed" ? "Tamamlandı" : project.status === "active" ? "Aktif" : "Beklemede"}
</Badge>
{project.dueDate && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Clock className="h-3.5 w-3.5" />
<span>Teslim: {format(new Date(project.dueDate), "d MMM yyyy", { locale: tr })}</span>
</div>
</div>
<div className="flex items-center gap-2 flex-wrap">
<Badge variant={project.status === 'completed' ? 'secondary' : 'default'} className="capitalize text-[10px] px-1.5 py-0">
{project.status === 'completed' ? 'Tamamlandı' : project.status === 'active' ? 'Aktif' : 'Beklemede'}
</Badge>
{project.due_date && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Clock className="h-3.5 w-3.5" />
<span>Teslim: {format(new Date(project.due_date), 'd MMM yyyy', { locale: tr })}</span>
</div>
)}
</div>
)}
</div>
<div className="space-y-1.5 mt-2">
<div className="flex items-center justify-between text-xs font-medium">
<span className="text-muted-foreground">İ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>
<div className="space-y-1.5 mt-2">
<div className="flex items-center justify-between text-xs font-medium">
<span className="text-muted-foreground">İlerleme</span>
<span>%{project.progress}</span>
</div>
</CardContent>
</Card>
</Link>
))
)}
<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>
);
}
function StatCard({
label,
value,
icon: Icon,
tone,
}: {
function StatCard({ label, value, icon: Icon, tone }: {
label: string;
value: string;
icon: any;
icon: LucideIcon;
tone: "green" | "blue" | "amber";
}) {
const toneClass = {
@@ -143,7 +98,6 @@ function StatCard({
blue: "bg-blue-50 text-blue-700",
amber: "bg-amber-50 text-amber-700",
}[tone];
return (
<Card>
<CardContent className="flex items-center justify-between gap-3 p-4">
+15 -29
View File
@@ -1,36 +1,22 @@
"use server";
import { createClient } from "@/lib/supabase/server";
import { revalidatePath } from "next/cache";
import { cleanText } from "@/server/web/form-data";
import { requirePortalBackend } from "@/server/web/portal";
export async function createRevisionRequest(projectId: string, clientId: string, formData: FormData) {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
export async function createRevisionRequest(projectId: string, formData: FormData) {
try {
const { actor, service } = await requirePortalBackend();
const description = cleanText(formData.get("description"));
if (!description) return { error: "Revizyon açıklaması boş olamaz." };
if (!user) {
return { error: "Oturum süresi dolmuş." };
service.requestRevision(actor, { projectId, description });
revalidatePath(`/portal/projects/${projectId}`);
revalidatePath("/portal/revisions");
return { success: true };
} catch (error) {
return {
error: error instanceof Error ? error.message : "Revizyon talebi oluşturulamadı.",
};
}
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 };
}
+58 -55
View File
@@ -1,67 +1,70 @@
import { createClient } from "@/lib/supabase/server";
import { notFound } from "next/navigation";
import { PortalProjectClient } from "./portal-project-client";
import { DomainError } from "@/server/domain/errors";
import { requirePortalBackend } from "@/server/web/portal";
import {
PortalProjectClient,
type PortalPlanningSection,
type PortalProjectDetail,
type PortalRevision,
type PortalTask,
} 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();
const { actor, service } = await requirePortalBackend();
let data: {
project: PortalProjectDetail;
sections: PortalPlanningSection[];
tasks: PortalTask[];
revisions: PortalRevision[];
};
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();
try {
const row = service.getProject(actor, id);
const allowance = service.getRevisionAllowance(actor, id);
data = {
project: {
id: row.id,
name: row.name,
description: row.description,
status: row.status,
progress: row.progress,
due_date: row.dueDate,
revision_quota: allowance.remaining,
can_request_revision: allowance.canRequest,
},
sections: service.listPlanningSections(actor, id).map((section) => ({
id: section.id,
title: section.title,
content: section.content,
type: section.category,
})),
tasks: service.listTasks(actor, id)
.filter((task) => task.status !== "cancelled")
.map((task) => ({
id: task.id,
title: task.title,
status: task.status as PortalTask["status"],
date: task.dueAt?.toISOString() ?? task.scheduledDate,
})),
revisions: service.listRevisions(actor, id).map((revision) => ({
id: revision.id,
description: revision.description,
status: revision.status,
created_at: revision.createdAt.toISOString(),
})),
};
} catch (error) {
if (error instanceof DomainError && error.code === "NOT_FOUND") notFound();
throw error;
}
// 2. Get Project
const { data: project, error } = await supabase
.from("projects")
.select("id, name, description, status, progress, due_date, revision_quota")
.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}
project={data.project}
sections={data.sections}
tasks={data.tasks}
revisions={data.revisions}
/>
);
}
@@ -11,7 +11,46 @@ import { createRevisionRequest } from "./actions";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "poyraz-ui/molecules";
export function PortalProjectClient({ project, sections, tasks, revisions, clientId }: any) {
export type PortalProjectDetail = {
id: string;
name: string;
description: string | null;
status: "planning" | "active" | "paused" | "completed" | "cancelled";
progress: number;
due_date: string | null;
revision_quota: number;
can_request_revision: boolean;
};
export type PortalPlanningSection = {
id: string;
title: string;
content: string | null;
type: string;
};
export type PortalTask = {
id: string;
title: string;
status: "todo" | "in_progress" | "done";
date: string | null;
};
export type PortalRevision = {
id: string;
description: string;
status: "pending" | "in_progress" | "completed" | "rejected";
created_at: string;
};
type PortalProjectClientProps = {
project: PortalProjectDetail;
sections: PortalPlanningSection[];
tasks: PortalTask[];
revisions: PortalRevision[];
};
export function PortalProjectClient({ project, sections, tasks, revisions }: PortalProjectClientProps) {
const [isSubmitting, setIsSubmitting] = useState(false);
const [openRevision, setOpenRevision] = useState(false);
@@ -20,19 +59,19 @@ export function PortalProjectClient({ project, sections, tasks, revisions, clien
setIsSubmitting(true);
const formData = new FormData(e.currentTarget);
try {
const res = await createRevisionRequest(project.id, clientId, formData);
const res = await createRevisionRequest(project.id, 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);
} catch (error: unknown) {
toast.error(error instanceof Error ? error.message : "Revizyon talebi oluşturulamadı.");
} finally {
setIsSubmitting(false);
}
};
const pendingRevisions = revisions.filter((r: any) => r.status === 'pending' || r.status === 'in_progress').length;
const hasRevisionQuota = project.revision_quota === null || project.revision_quota > 0;
const pendingRevisions = revisions.filter((revision) => revision.status === 'pending' || revision.status === 'in_progress').length;
const hasRevisionQuota = project.can_request_revision;
return (
<div className="mx-auto flex max-w-7xl flex-col gap-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
@@ -64,8 +103,8 @@ export function PortalProjectClient({ project, sections, tasks, revisions, clien
</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..." />
<Label htmlFor="revision-description">Lütfen yapılmasını istediğiniz değişiklikleri detaylıca açıklayın</Label>
<Textarea id="revision-description" name="description" required rows={5} placeholder="Şu kısmın rengi mavi olabilir mi? Ayrıca metinleri güncelleyelim..." />
</div>
</div>
<DialogFooter>
@@ -138,15 +177,15 @@ export function PortalProjectClient({ project, sections, tasks, revisions, clien
<p className="text-sm text-muted-foreground italic">Listelenecek görev bulunmuyor.</p>
) : (
<ul className="space-y-3 max-h-60 overflow-y-auto tiny-scrollbar pr-2">
{tasks.map((task: any) => (
{tasks.map((task) => (
<li key={task.id} className="text-sm flex gap-3 p-2 rounded hover:bg-muted/30 transition-colors">
{task.status === 'completed' || task.status === 'done' ? (
{task.status === 'done' ? (
<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/30 shrink-0 mt-0.5" />
)}
<div>
<span className={task.status === 'completed' || task.status === 'done' ? "text-muted-foreground" : "text-foreground font-medium"}>
<span className={task.status === 'done' ? "text-muted-foreground" : "text-foreground font-medium"}>
{task.title}
</span>
{task.date && (
@@ -171,7 +210,7 @@ export function PortalProjectClient({ project, sections, tasks, revisions, clien
</div>
) : (
<div className="space-y-4">
{sections.map((section: any) => (
{sections.map((section) => (
<Card key={section.id}>
<CardContent className="p-5 space-y-3">
<div className="flex items-center justify-between">
@@ -205,7 +244,7 @@ export function PortalProjectClient({ project, sections, tasks, revisions, clien
</div>
) : (
<div className="space-y-4">
{revisions.map((rev: any) => (
{revisions.map((rev) => (
<Card key={rev.id} className="transition-colors hover:border-primary/30">
<CardContent className="p-5">
<div className="flex justify-between items-start mb-3">
+31 -62
View File
@@ -1,37 +1,13 @@
import { createClient } from "@/lib/supabase/server";
import { Card, CardContent, Badge } from "poyraz-ui/atoms";
import { FolderKanban, Clock } from "lucide-react";
import Link from "next/link";
import { format } from "date-fns";
import { tr } from "date-fns/locale";
import { requirePortalBackend } from "@/server/web/portal";
export default async function PortalProjectsPage() {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return null;
const { data: clientData } = await supabase
.from("clients")
.select("id")
.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>
</div>
);
}
const { data: projectsData } = await supabase
.from("projects")
.select("id, name, status, progress, due_date")
.eq("client_id", clientData.id)
.order("created_at", { ascending: false });
const projects = projectsData || [];
const { actor, service } = await requirePortalBackend();
const projects = service.listProjects(actor);
return (
<div className="mx-auto flex max-w-7xl flex-col gap-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
@@ -46,44 +22,37 @@ export default async function PortalProjectsPage() {
<FolderKanban className="w-10 h-10 text-muted-foreground/50" />
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>
)}
) : 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>
<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}%` }}
/>
{project.dueDate && (
<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.dueDate), "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>
</CardContent>
</Card>
</Link>
))
)}
<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>
);
+35 -84
View File
@@ -1,56 +1,16 @@
import { createClient } from "@/lib/supabase/server";
import { Card, CardContent, Badge } from "poyraz-ui/atoms";
import { Clock, MessageSquare } from "lucide-react";
import Link from "next/link";
import { format } from "date-fns";
import { tr } from "date-fns/locale";
type RevisionRow = {
id: string;
description: string;
status: string;
project_id: string;
created_at: string;
};
import { requirePortalBackend } from "@/server/web/portal";
export default async function PortalRevisionsPage() {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return null;
const { data: clientData } = await supabase
.from("clients")
.select("id")
.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>
</div>
);
}
const { data: projectsData } = await supabase
.from("projects")
.select("id, name")
.eq("client_id", clientData.id);
const projectIds = projectsData?.map(p => p.id) || [];
let revisions: RevisionRow[] = [];
if (projectIds.length > 0) {
const { data: revisionsData } = await supabase
.from("project_revisions")
.select("id, description, status, project_id, created_at")
.in("project_id", projectIds)
.order("created_at", { ascending: false });
revisions = revisionsData || [];
}
const getProjectName = (id: string) => projectsData?.find(p => p.id === id)?.name || "Bilinmeyen Proje";
const { actor, service } = await requirePortalBackend();
const projects = service.listProjects(actor);
const projectNames = new Map(projects.map((project) => [project.id, project.name]));
const revisions = service.listPortalRevisions(actor)
.filter((revision) => projectNames.has(revision.projectId));
return (
<div className="mx-auto flex max-w-7xl flex-col gap-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
@@ -65,47 +25,38 @@ export default async function PortalRevisionsPage() {
<MessageSquare className="w-10 h-10 text-muted-foreground/50" />
Henüz bir revizyon talebinde bulunmadınız.
</div>
) : (
revisions.map(rev => (
<Card key={rev.id} className="h-full">
<CardContent className="p-5 flex flex-col h-full justify-between gap-4">
<div className="space-y-4">
<div className="flex items-start justify-between gap-2 border-b border-border pb-3">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Clock className="h-4 w-4" />
{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'
} className="capitalize shrink-0">
{rev.status === 'pending' ? 'Bekliyor' :
rev.status === 'in_progress' ? 'İşleniyor' :
rev.status === 'completed' ? 'Tamamlandı' : 'Reddedildi'}
</Badge>
) : revisions.map((revision) => (
<Card key={revision.id} className="h-full">
<CardContent className="p-5 flex flex-col h-full justify-between gap-4">
<div className="space-y-4">
<div className="flex items-start justify-between gap-2 border-b border-border pb-3">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Clock className="h-4 w-4" />
{format(revision.createdAt, "d MMM yyyy, HH:mm", { locale: tr })}
</div>
<div className="flex flex-col gap-2">
<span className="text-xs font-medium uppercase text-muted-foreground">Proje:</span>
<span className="text-sm font-semibold truncate bg-muted/30 p-2 rounded-md">
{getProjectName(rev.project_id)}
</span>
</div>
<p className="text-sm text-foreground whitespace-pre-wrap leading-relaxed">
{rev.description}
</p>
<Badge
variant={revision.status === "completed" ? "default" : revision.status === "rejected" ? "destructive" : "secondary"}
className="capitalize shrink-0"
>
{revision.status === "pending" ? "Bekliyor" : revision.status === "in_progress" ? "İşleniyor" : revision.status === "completed" ? "Tamamlandı" : "Reddedildi"}
</Badge>
</div>
<div className="flex items-center justify-end border-t border-border pt-4">
<Link href={`/portal/projects/${rev.project_id}`} className="text-xs text-primary font-medium hover:underline">
Projeye Git &rarr;
</Link>
<div className="flex flex-col gap-2">
<span className="text-xs font-medium uppercase text-muted-foreground">Proje:</span>
<span className="text-sm font-semibold truncate bg-muted/30 p-2 rounded-md">
{projectNames.get(revision.projectId)}
</span>
</div>
</CardContent>
</Card>
))
)}
<p className="text-sm text-foreground whitespace-pre-wrap leading-relaxed">{revision.description}</p>
</div>
<div className="flex items-center justify-end border-t border-border pt-4">
<Link href={`/portal/projects/${revision.projectId}`} className="text-xs text-primary font-medium hover:underline">
Projeye Git &rarr;
</Link>
</div>
</CardContent>
</Card>
))}
</div>
</div>
);
+19 -66
View File
@@ -1,59 +1,16 @@
import { createClient } from "@/lib/supabase/server";
import { Card, CardContent, Badge } from "poyraz-ui/atoms";
import { CheckCircle2, Clock, CalendarDays, KanbanSquare } from "lucide-react";
import Link from "next/link";
import { format } from "date-fns";
import { tr } from "date-fns/locale";
type PortalTaskRow = {
id: string;
title: string;
status: string;
project_id: string;
created_at: string;
date: string | null;
priority: string | null;
};
import { requirePortalBackend } from "@/server/web/portal";
export default async function PortalTasksPage() {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return null;
const { data: clientData } = await supabase
.from("clients")
.select("id")
.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>
</div>
);
}
const { data: projectsData } = await supabase
.from("projects")
.select("id, name")
.eq("client_id", clientData.id);
const projectIds = projectsData?.map(p => p.id) || [];
let tasks: PortalTaskRow[] = [];
if (projectIds.length > 0) {
const { data: tasksData } = await supabase
.from("tasks")
.select("id, title, status, project_id, created_at, date, priority")
.in("project_id", projectIds)
.eq("is_public_to_client", true)
.order("created_at", { ascending: false });
tasks = tasksData || [];
}
const getProjectName = (id: string) => projectsData?.find(p => p.id === id)?.name || "Bilinmeyen Proje";
const { actor, service } = await requirePortalBackend();
const projects = service.listProjects(actor);
const projectNames = new Map(projects.map((project) => [project.id, project.name]));
const tasks = service.listTasks(actor)
.filter((task) => task.projectId && projectNames.has(task.projectId) && task.status !== "cancelled")
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
return (
<div className="mx-auto flex max-w-7xl flex-col gap-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
@@ -68,40 +25,36 @@ export default async function PortalTasksPage() {
<KanbanSquare className="w-10 h-10 text-muted-foreground/50" />
Henüz sizinle paylaşılan bir görev bulunmuyor.
</div>
) : (
tasks.map(task => (
) : tasks.map((task) => {
const isDone = task.status === "done";
const date = task.dueAt?.toISOString() ?? task.scheduledDate;
return (
<Card key={task.id} className="h-full">
<CardContent className="p-5 flex flex-col h-full justify-between gap-4">
<div className="space-y-3">
<div className="flex items-start justify-between gap-2">
<h3 className={task.status === 'completed' || task.status === 'done' ? "font-semibold text-lg line-through text-muted-foreground line-clamp-2" : "font-semibold text-lg line-clamp-2"}>
<h3 className={isDone ? "font-semibold text-lg line-through text-muted-foreground line-clamp-2" : "font-semibold text-lg line-clamp-2"}>
{task.title}
</h3>
<Badge variant={task.status === 'completed' || task.status === 'done' ? 'secondary' : 'outline'} className="capitalize shrink-0">
{task.status === 'todo' ? 'Bekliyor' : task.status === 'in_progress' ? 'İşleniyor' : 'Tamamlandı'}
<Badge variant={isDone ? "secondary" : "outline"} className="capitalize shrink-0">
{task.status === "todo" ? "Bekliyor" : task.status === "in_progress" ? "İşleniyor" : "Tamamlandı"}
</Badge>
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground bg-muted/30 p-2 rounded-md">
<span className="font-medium truncate">{getProjectName(task.project_id)}</span>
<span className="font-medium truncate">{projectNames.get(task.projectId!)}</span>
</div>
</div>
<div className="flex items-center justify-between text-sm text-muted-foreground border-t border-border pt-4">
<div className="flex items-center gap-1.5">
<CalendarDays className="h-4 w-4" />
<span>{task.date ? format(new Date(task.date), 'd MMM yyyy', { locale: tr }) : 'Tarih yok'}</span>
<span>{date ? format(new Date(date), "d MMM yyyy", { locale: tr }) : "Tarih yok"}</span>
</div>
{task.status === 'completed' || task.status === 'done' ? (
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
) : (
<Clock className="h-4 w-4" />
)}
{isDone ? <CheckCircle2 className="h-4 w-4 text-emerald-500" /> : <Clock className="h-4 w-4" />}
</div>
</CardContent>
</Card>
))
)}
);
})}
</div>
</div>
);