feat(backend): migrate freelancer and portal runtimes
This commit is contained in:
@@ -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
@@ -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">
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 →
|
||||
</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 →
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
+19
-66
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user