Files
neta/app/(dashboard)/projects/projects-client.tsx
T

863 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { getDocumentIntlLocale } from "@/lib/i18n/browser";
import { useTranslations } from "@/components/i18n/i18n-provider";
import {
completeProjectRecord,
createProjectRecord,
updateProjectRecord,
} from "@/app/(dashboard)/projects/actions";
import { LocalizedFields, type LocalizedFieldLocale, type LocalizedFieldValues } from "@/components/i18n/localized-fields";
import { PendingLink } from "@/components/ui/pending-link";
import { PendingSubmitButton } from "@/components/ui/pending-submit-button";
import { contentTranslationRegistry } from "@/lib/i18n/content";
import { Badge, Button, Card, CardContent, Input, Label } from "poyraz-ui/atoms";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
toast,
} from "poyraz-ui/molecules";
import {
CalendarDays,
CheckCircle2,
Eye,
FolderKanban,
ImageIcon,
LayoutGrid,
List,
Pencil,
Plus,
Target,
Wallet,
Brain,
Loader2,
} from "lucide-react";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { useEffect, useState, useTransition, type ChangeEvent } from "react";
import { StatCard } from "@/components/system/stat-card";
export type ProjectClientOption = {
id: string;
name: string;
};
export type ProjectListItem = {
id: string;
client_id: string | null;
clientName: string | null;
name: string;
type: "client_project" | "side_project";
description: string | null;
status: "planning" | "active" | "paused" | "completed" | "cancelled";
start_date: string | null;
due_date: string | null;
budget_amount: number | null;
currency: string;
progress: number;
cover_image_path: string | null;
cover_image_alt: string | null;
coverImageUrl: string | null;
taskCount: number;
doneTaskCount: number;
translations?: LocalizedFieldValues;
};
type Translate = ReturnType<typeof useTranslations>;
const typeLabels = (t: Translate) => ({
client_project: t("projects.types.client"),
side_project: t("projects.types.side"),
});
const statusLabels = (t: Translate) => ({
planning: t("projects.status.planning"),
active: t("projects.status.active"),
paused: t("projects.status.paused"),
completed: t("projects.status.completed"),
cancelled: t("projects.status.cancelled"),
});
const statusClasses = {
planning: "border-blue-200 bg-blue-50 text-blue-700",
active: "border-emerald-200 bg-emerald-50 text-emerald-700",
paused: "border-amber-200 bg-amber-50 text-amber-700",
completed: "border-zinc-200 bg-zinc-50 text-zinc-700",
cancelled: "border-rose-200 bg-rose-50 text-rose-700",
};
type ProjectsClientProps = {
projects: ProjectListItem[];
clients: ProjectClientOption[];
localization: {
defaultLocale: string;
locales: LocalizedFieldLocale[];
};
};
export function ProjectsClient({ projects, clients, localization }: ProjectsClientProps) {
const t = useTranslations();
const [query, setQuery] = useState("");
const [view, setView] = useState<"grid" | "list">("grid");
const normalizedQuery = query.trim().toLowerCase();
const types = typeLabels(t);
const filteredProjects = normalizedQuery
? projects.filter((project) =>
[project.name, project.description, project.clientName, types[project.type]]
.filter(Boolean)
.some((value) => value!.toLowerCase().includes(normalizedQuery)),
)
: projects;
const activeCount = projects.filter((project) => project.status === "active").length;
const sideProjectCount = projects.filter((project) => project.type === "side_project").length;
const averageProgress = projects.length
? Math.round(projects.reduce((sum, project) => sum + project.progress, 0) / projects.length)
: 0;
const totalBudget = projects.reduce((sum, project) => sum + (project.budget_amount || 0), 0);
return (
<div className="mx-auto flex max-w-7xl flex-col gap-6">
<div className="flex flex-col gap-4 border-b border-border pb-5 lg:flex-row lg:items-end lg:justify-between">
<div>
<h1 className="text-3xl font-semibold tracking-normal text-foreground">
{t("projects.title")}
</h1>
</div>
<div className="flex gap-2">
<AIProjectRiskDialog />
<ProjectDialog mode="create" clients={clients} localization={localization} />
</div>
</div>
<div className="grid gap-3 md:grid-cols-4">
<StatCard label={t("projects.stats.active")} value={activeCount.toString()} icon={FolderKanban} tone="green" />
<StatCard label={t("projects.stats.side")} value={sideProjectCount.toString()} icon={Target} tone="blue" />
<StatCard label={t("projects.stats.progress")} value={`${averageProgress}%`} icon={CheckCircle2} tone="amber" />
<StatCard label={t("projects.stats.budget")} value={formatCurrency(totalBudget)} icon={Wallet} tone="red" />
</div>
<Card>
<CardContent className="space-y-4 p-4">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div>
<h2 className="text-base font-semibold text-foreground">{t("projects.list.title")}</h2>
<p className="text-sm text-muted-foreground">
{t("projects.list.count", { count: filteredProjects.length })}
</p>
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={t("projects.list.search")}
className="sm:w-80"
/>
<div className="flex rounded-sm border border-border p-1">
<Button size="sm" effect="shine"
type="button"
variant={view === "grid" ? "default" : "secondary"}
className="gap-2 px-3"
onClick={() => setView("grid")}
>
<LayoutGrid className="h-4 w-4" />
{t("projects.list.grid")}
</Button>
<Button size="sm" effect="shine"
type="button"
variant={view === "list" ? "default" : "secondary"}
className="gap-2 px-3"
onClick={() => setView("list")}
>
<List className="h-4 w-4" />
{t("projects.list.list")}
</Button>
</div>
</div>
</div>
{filteredProjects.length > 0 ? (
view === "grid" ? (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{filteredProjects.map((project) => (
<ProjectCard key={project.id} project={project} clients={clients} localization={localization} />
))}
</div>
) : (
<div className="overflow-x-auto rounded-sm border border-border">
<div className="min-w-[800px]">
<div className="grid grid-cols-[1.5fr_1fr_1fr_0.8fr_1fr] gap-4 border-b border-border bg-muted/40 px-4 py-3 text-xs font-medium uppercase text-muted-foreground">
<span>{t("projects.list.columns.project")}</span>
<span>{t("projects.list.columns.type")}</span>
<span>{t("projects.list.columns.status")}</span>
<span className="text-center">{t("projects.list.columns.budgetDeadline")}</span>
<span className="sr-only">İşlemler</span>
</div>
<div className="divide-y divide-border">
{filteredProjects.map((project) => (
<ProjectRow key={project.id} project={project} clients={clients} localization={localization} />
))}
</div>
</div>
</div>
)
) : (
<EmptyState hasQuery={Boolean(normalizedQuery)} />
)}
</CardContent>
</Card>
</div>
);
}
function ProjectCard({
project,
clients,
localization,
}: {
project: ProjectListItem;
clients: ProjectClientOption[];
localization: ProjectsClientProps["localization"];
}) {
const t = useTranslations();
const router = useRouter();
const [isNavigating, startNavigation] = useTransition();
const detailHref = `/projects/${project.id}`;
function goToProjectDetail() {
startNavigation(() => {
router.push(detailHref);
});
}
function prefetchProjectDetail() {
router.prefetch(detailHref);
}
return (
<Card
role="link"
tabIndex={0}
aria-label={`${project.name} detayına git`}
aria-busy={isNavigating}
className={
isNavigating
? "relative cursor-progress opacity-80 transition-colors ring-2 ring-primary/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
: "relative cursor-pointer transition-colors hover:border-primary/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
}
onClick={goToProjectDetail}
onMouseEnter={prefetchProjectDetail}
onFocus={prefetchProjectDetail}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
goToProjectDetail();
}
}}
>
<CardContent className="flex h-full flex-col gap-5 p-5">
{isNavigating ? (
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-sm bg-background/70 backdrop-blur-[1px]">
<Loader2 className="h-5 w-5 animate-spin text-primary" />
</div>
) : null}
<ProjectCover project={project} />
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h3 className="truncate text-lg font-semibold text-foreground">{project.name}</h3>
<p className="mt-1 line-clamp-2 text-sm text-muted-foreground">
{project.description || t("projects.card.noDescription")}
</p>
</div>
<Badge variant="outline" className={statusClasses[project.status]}>
{statusLabels(t)[project.status]}
</Badge>
</div>
<ProjectMeta project={project} />
<ProgressBar progress={project.progress} />
<div className="mt-auto flex items-center justify-between gap-2 border-t border-border pt-4">
<div className="text-xs text-muted-foreground">
{t("projects.card.taskProgress", { done: project.doneTaskCount, total: project.taskCount })}
</div>
<ProjectActions project={project} clients={clients} localization={localization} showDetail={false} />
</div>
</CardContent>
</Card>
);
}
function ProjectCover({ project }: { project: ProjectListItem }) {
const t = useTranslations();
if (project.coverImageUrl) {
return (
<div className="relative aspect-video overflow-hidden rounded-sm border border-border bg-muted">
<Image
src={project.coverImageUrl}
alt={project.cover_image_alt || project.name}
fill
sizes="(min-width: 1280px) 30vw, (min-width: 768px) 45vw, 100vw"
unoptimized
className="object-cover"
/>
</div>
);
}
return (
<div className="flex aspect-video items-center justify-center rounded-sm border border-dashed border-border bg-muted/30 text-sm text-muted-foreground">
{t("projects.card.noCover")}
</div>
);
}
function ProjectRow({
project,
clients,
localization,
}: {
project: ProjectListItem;
clients: ProjectClientOption[];
localization: ProjectsClientProps["localization"];
}) {
const t = useTranslations();
return (
<div className="grid gap-4 px-4 py-4 grid-cols-[1.5fr_1fr_1fr_0.8fr_1fr] items-center">
<div className="min-w-0">
<div className="font-medium text-foreground">{project.name}</div>
<div className="truncate text-sm text-muted-foreground">
{project.clientName || t("projects.card.noClient")}
</div>
</div>
<div className="text-sm text-muted-foreground">{typeLabels(t)[project.type]}</div>
<div>
<Badge variant="outline" className={statusClasses[project.status]}>{statusLabels(t)[project.status]}</Badge>
</div>
<div>
<ProgressBar progress={project.progress} compact />
</div>
<div className="flex justify-end gap-2">
<ProjectActions project={project} clients={clients} localization={localization} showDetail />
</div>
</div>
);
}
function ProjectMeta({ project }: { project: ProjectListItem }) {
const t = useTranslations();
return (
<div className="grid gap-2 text-sm text-muted-foreground">
<div>{typeLabels(t)[project.type]}</div>
<div>{project.clientName || t("projects.card.noClient")}</div>
<div className="flex items-center gap-2">
<CalendarDays className="h-4 w-4" />
{project.due_date ? formatDate(project.due_date) : t("projects.card.noDeadline")}
</div>
<div>{project.budget_amount ? formatCurrency(project.budget_amount) : t("projects.card.noBudget")}</div>
</div>
);
}
function ProjectActions({
project,
clients,
localization,
showDetail,
}: {
project: ProjectListItem;
clients: ProjectClientOption[];
localization: ProjectsClientProps["localization"];
showDetail: boolean;
}) {
const t = useTranslations();
return (
<div
className="flex gap-2"
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}
>
{showDetail ? (
<Button
size="icon"
effect="shine"
asChild
variant="secondary"
title={t("projects.actions.detail")}
aria-label={t("projects.actions.detail")}
>
<PendingLink href={`/projects/${project.id}`} className="flex h-full w-full items-center justify-center" showSpinner>
<Eye className="h-4 w-4" />
</PendingLink>
</Button>
) : null}
<ProjectDialog mode="edit" project={project} clients={clients} localization={localization} iconOnly />
{project.status !== "completed" ? (
<form action={completeProjectRecord}>
<input type="hidden" name="id" value={project.id} />
<PendingSubmitButton
size="icon"
variant="secondary"
title={t("projects.actions.complete")}
aria-label={t("projects.actions.complete")}
idleIcon={<CheckCircle2 className="h-4 w-4" />}
>
</PendingSubmitButton>
</form>
) : null}
</div>
);
}
function ProjectDialog({
mode,
project,
clients,
localization,
iconOnly = false,
}: {
mode: "create" | "edit";
project?: ProjectListItem;
clients: ProjectClientOption[];
localization: ProjectsClientProps["localization"];
iconOnly?: boolean;
}) {
const t = useTranslations();
const [open, setOpen] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [projectType, setProjectType] = useState(project?.type || "client_project");
const action = mode === "create" ? createProjectRecord : updateProjectRecord;
async function handleSubmit(formData: FormData) {
setIsSubmitting(true);
try {
await action(formData);
setOpen(false);
toast.success(mode === "create" ? t("projects.messages.created") : t("projects.messages.updated"));
} catch (error) {
toast.error(
error instanceof Error
? error.message
: t("projects.errors.saveFailed"),
);
} finally {
setIsSubmitting(false);
}
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button effect="shine"
variant={mode === "create" ? "default" : "secondary"}
size={iconOnly ? "icon" : "default"}
className={iconOnly ? undefined : "min-w-24 gap-2 px-3"}
title={mode === "create" ? t("projects.actions.add") : t("projects.actions.edit")}
aria-label={mode === "create" ? t("projects.actions.add") : t("projects.actions.edit")}
>
{mode === "create" ? <Plus className="h-4 w-4" /> : <Pencil className="h-4 w-4" />}
{iconOnly ? null : mode === "create" ? t("projects.actions.add") : t("projects.actions.edit")}
</Button>
</DialogTrigger>
<DialogContent className="flex max-h-[calc(100dvh-2rem)] w-[calc(100vw-2rem)] flex-col overflow-hidden p-0 sm:max-h-[min(680px,calc(100dvh-4rem))] sm:max-w-2xl data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95">
<form action={handleSubmit} className="flex min-h-0 flex-1 flex-col overflow-hidden">
{project ? <input type="hidden" name="id" value={project.id} /> : null}
<DialogHeader className="shrink-0 px-5 pb-4 pt-5 pr-12">
<DialogTitle>{mode === "create" ? t("projects.form.createTitle") : t("projects.form.editTitle")}</DialogTitle>
<DialogDescription>
{t("projects.form.description")}
</DialogDescription>
</DialogHeader>
<div className="tiny-scrollbar min-h-0 flex-1 overflow-y-auto px-5 pb-5">
<ProjectFormFields
project={project}
clients={clients}
localization={localization}
projectType={projectType}
onProjectTypeChange={setProjectType}
/>
</div>
<DialogFooter className="shrink-0 border-t border-border bg-background p-5">
<Button variant="default" effect="shine" type="submit" disabled={isSubmitting} className="w-full gap-2 sm:w-auto">
{mode === "create" ? <Plus className="h-4 w-4" /> : <Pencil className="h-4 w-4" />}
{isSubmitting
? t("projects.form.submitting")
: mode === "create"
? t("projects.form.submitCreate")
: t("projects.form.submitEdit")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
function CoverImageInput({ project }: { project?: ProjectListItem }) {
const t = useTranslations();
const inputId = `cover-${project?.id || "new"}`;
const [previewUrl, setPreviewUrl] = useState(project?.coverImageUrl || "");
useEffect(() => {
return () => {
if (previewUrl.startsWith("blob:")) {
URL.revokeObjectURL(previewUrl);
}
};
}, [previewUrl]);
function handleFileChange(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
if (!file) {
setPreviewUrl(project?.coverImageUrl || "");
return;
}
const nextPreviewUrl = URL.createObjectURL(file);
setPreviewUrl((currentPreviewUrl) => {
if (currentPreviewUrl.startsWith("blob:")) {
URL.revokeObjectURL(currentPreviewUrl);
}
return nextPreviewUrl;
});
}
return (
<div className="grid gap-3">
<Label htmlFor={inputId}>{t("projects.form.coverImage")}</Label>
<label
htmlFor={inputId}
className="group relative flex aspect-16/7 cursor-pointer items-center justify-center overflow-hidden rounded-sm border border-dashed border-border bg-muted/20 transition-colors hover:border-primary/50 hover:bg-primary/5"
>
{previewUrl ? (
<Image
src={previewUrl}
alt={project?.cover_image_alt || project?.name || "Proje kapak görseli önizlemesi"}
fill
sizes="(min-width: 640px) 640px, 100vw"
unoptimized
className="object-cover"
/>
) : (
<div className="flex flex-col items-center gap-3 text-muted-foreground transition-colors group-hover:text-primary">
<div className="flex h-12 w-12 items-center justify-center rounded-sm bg-background shadow-sm">
<ImageIcon className="h-6 w-6" />
</div>
<div className="text-center">
<div className="text-sm font-medium">{t("projects.form.coverImageSelect")}</div>
<div className="text-xs">{t("projects.form.coverImageFormat")}</div>
</div>
</div>
)}
{previewUrl ? (
<div className="absolute inset-x-0 bottom-0 bg-background/90 px-3 py-2 text-xs text-muted-foreground backdrop-blur">
{t("projects.form.coverImageChange")}
</div>
) : null}
</label>
<Input
id={inputId}
name="cover_image"
type="file"
accept="image/png,image/jpeg,image/webp,image/gif"
className="sr-only"
onChange={handleFileChange}
/>
</div>
);
}
function ProjectFormFields({
project,
clients,
localization,
projectType,
onProjectTypeChange,
}: {
project?: ProjectListItem;
clients: ProjectClientOption[];
localization: ProjectsClientProps["localization"];
projectType: ProjectListItem["type"];
onProjectTypeChange: (value: ProjectListItem["type"]) => void;
}) {
const t = useTranslations();
return (
<div className="grid gap-4">
<CoverImageInput project={project} />
<LocalizedFields
idPrefix={`project-${project?.id || "new"}`}
defaultLocale={localization.defaultLocale}
locales={localization.locales}
fields={contentTranslationRegistry.project.map((f) => ({
...f,
label: t(`projects.fields.${f.name}`) || f.label,
placeholder: f.placeholder ? t(`projects.placeholders.${f.name}`) || f.placeholder : undefined,
}))}
values={project?.translations}
fallbackValues={{
name: project?.name,
description: project?.description,
coverImageAlt: project?.cover_image_alt,
}}
/>
<div className="grid gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label>{t("projects.form.type")}</Label>
<Select
name="type"
value={projectType}
onValueChange={(value) => onProjectTypeChange(value as ProjectListItem["type"])}
>
<SelectTrigger>
<SelectValue placeholder={t("projects.form.typePlaceholder")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="client_project">{t("projects.types.client")}</SelectItem>
<SelectItem value="side_project">{t("projects.types.side")}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label>{t("projects.form.client")}</Label>
<Select
name="client_id"
defaultValue={project?.client_id || ""}
disabled={projectType === "side_project"}
>
<SelectTrigger>
<SelectValue placeholder={t("projects.form.clientPlaceholder")} />
</SelectTrigger>
<SelectContent>
{clients.map((client) => (
<SelectItem key={client.id} value={client.id}>
{client.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid gap-4 md:grid-cols-3">
<div className="grid gap-2">
<Label>{t("projects.form.status")}</Label>
<Select name="status" defaultValue={project?.status || "planning"}>
<SelectTrigger>
<SelectValue placeholder={t("projects.form.statusPlaceholder")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="planning">{t("projects.status.planning")}</SelectItem>
<SelectItem value="active">{t("projects.status.active")}</SelectItem>
<SelectItem value="paused">{t("projects.status.paused")}</SelectItem>
<SelectItem value="completed">{t("projects.status.completed")}</SelectItem>
<SelectItem value="cancelled">{t("projects.status.cancelled")}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor={`start-${project?.id || "new"}`}>{t("projects.form.startDate")}</Label>
<Input id={`start-${project?.id || "new"}`} name="start_date" type="date" defaultValue={project?.start_date || ""} />
</div>
<div className="grid gap-2">
<Label htmlFor={`due-${project?.id || "new"}`}>{t("projects.form.dueDate")}</Label>
<Input id={`due-${project?.id || "new"}`} name="due_date" type="date" defaultValue={project?.due_date || ""} />
</div>
</div>
<div className="grid gap-4 md:grid-cols-3">
<div className="grid gap-2">
<Label htmlFor={`budget-${project?.id || "new"}`}>{t("projects.form.budget")}</Label>
<Input
id={`budget-${project?.id || "new"}`}
name="budget_amount"
type="number"
min="0"
step="0.01"
defaultValue={project?.budget_amount ?? ""}
placeholder="0"
/>
</div>
<div className="grid gap-2">
<Label htmlFor={`currency-${project?.id || "new"}`}>{t("projects.form.currency")}</Label>
<Input id={`currency-${project?.id || "new"}`} name="currency" defaultValue={project?.currency || "USD"} maxLength={3} />
</div>
<div className="grid gap-2">
<Label htmlFor={`progress-${project?.id || "new"}`}>{t("projects.form.progress")}</Label>
<div className="flex items-center gap-3">
<Input
id={`progress-${project?.id || "new"}`}
name="progress"
type="range"
min="0"
max="100"
defaultValue={project?.progress ?? 0}
className="flex-1 cursor-pointer accent-primary"
onChange={(e) => {
const el = document.getElementById(`progress-val-${project?.id || "new"}`);
if (el) el.textContent = `%${e.target.value}`;
}}
/>
<span id={`progress-val-${project?.id || "new"}`} className="w-10 text-sm font-medium text-right">
%{project?.progress ?? 0}
</span>
</div>
</div>
</div>
</div>
);
}
function ProgressBar({ progress, compact = false }: { progress: number; compact?: boolean }) {
const t = useTranslations();
return (
<div className="space-y-2">
{!compact ? (
<div className="flex justify-between text-xs text-muted-foreground">
<span>{t("projects.card.progress")}</span>
<span>{progress}%</span>
</div>
) : null}
<div className={compact ? "h-2 rounded-full bg-muted" : "h-2.5 rounded-full bg-muted"}>
<div
className="h-full rounded-full bg-primary transition-all"
style={{ width: `${Math.min(Math.max(progress, 0), 100)}%` }}
/>
</div>
</div>
);
}
function EmptyState({ hasQuery }: { hasQuery: boolean }) {
const t = useTranslations();
return (
<div className="flex flex-col items-center justify-center gap-2 rounded-sm border border-dashed border-border py-12 text-center">
<FolderKanban className="h-8 w-8 text-muted-foreground/50" />
<div className="text-sm font-medium text-foreground">{t("projects.empty.title")}</div>
<div className="max-w-xs text-xs text-muted-foreground">
{t("projects.empty.description")}
</div>
</div>
);
}
function formatDate(value: string) {
return new Intl.DateTimeFormat(getDocumentIntlLocale(), {
day: "2-digit",
month: "short",
year: "numeric",
}).format(new Date(`${value}T00:00:00`));
}
function formatCurrency(value: number) {
return new Intl.NumberFormat(getDocumentIntlLocale(), {
style: "currency",
currency: "USD",
maximumFractionDigits: 0,
}).format(value);
}
function AIProjectRiskDialog({ projectId }: { projectId?: string }) {
const t = useTranslations();
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [result, setResult] = useState<string | null>(null);
const handleAnalyze = async () => {
setLoading(true);
setResult(null);
try {
const res = await fetch("/api/project-risk", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ projectId }),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || "Bilinmeyen bir hata oluştu.");
}
setResult(data.text);
} catch (err) {
setResult(
"Hata: " +
(err instanceof Error ? err.message : "Bilinmeyen bir hata oluştu."),
);
} finally {
setLoading(false);
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button effect="shine" variant="secondary" className="gap-2">
<Brain className="h-4 w-4" />{t("projects.actions.ai")}</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Brain className="h-5 w-5 text-indigo-600" />
Proje Risk Analizi
</DialogTitle>
<DialogDescription>
Yapay zeka, projelerinizin ilerleme durumunu ve bitiş tarihlerini kontrol ederek riskleri tahmin eder.
</DialogDescription>
</DialogHeader>
<div className="py-4">
{!result && !loading && (
<div className="text-center py-10">
<Button variant="default" effect="shine" onClick={handleAnalyze} className="gap-2">
<Brain className="h-4 w-4" />
Raporu Oluştur
</Button>
</div>
)}
{loading && (
<div className="flex flex-col items-center justify-center py-10 space-y-4 text-indigo-600">
<Loader2 className="h-8 w-8 animate-spin" />
<p className="text-sm font-medium">Projeler analiz ediliyor...</p>
</div>
)}
{result && (
<div className="bg-muted/50 border border-border rounded-lg p-5 text-sm prose prose-sm dark:prose-invert max-w-none whitespace-pre-wrap">
{result}
</div>
)}
</div>
{result && (
<DialogFooter>
<Button effect="shine" variant="secondary" onClick={() => setOpen(false)}>Kapat</Button>
<Button effect="shine" variant="default" onClick={handleAnalyze} className="gap-2">
<Brain className="h-4 w-4" />
Yeniden Oluştur
</Button>
</DialogFooter>
)}
</DialogContent>
</Dialog>
);
}