feat: support localized domain content

This commit is contained in:
poyrazavsever
2026-07-19 03:07:20 +03:00
parent c7a4f3de51
commit c1c473469a
15 changed files with 659 additions and 208 deletions
@@ -2,7 +2,7 @@
import { useState } from "react"; import { useState } from "react";
import { format } from "date-fns"; import { format } from "date-fns";
import { tr } from "date-fns/locale"; import { getDocumentDateFnsLocale } from "@/lib/i18n/date-fns";
import { Card, CardContent, Badge, Button, Input, Textarea, Label } from "poyraz-ui/atoms"; import { Card, CardContent, Badge, Button, Input, Textarea, Label } from "poyraz-ui/atoms";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogFooter, Select, SelectTrigger, SelectValue, SelectContent, SelectItem, DialogDescription } from "poyraz-ui/molecules"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogFooter, Select, SelectTrigger, SelectValue, SelectContent, SelectItem, DialogDescription } from "poyraz-ui/molecules";
import { Phone, Mail, ExternalLink, Calendar, Plus, MessageSquare, UserPlus, Loader2, Copy } from "lucide-react"; import { Phone, Mail, ExternalLink, Calendar, Plus, MessageSquare, UserPlus, Loader2, Copy } from "lucide-react";
@@ -20,6 +20,7 @@ export type ClientDetailData = {
status: string; status: string;
notes: string | null; notes: string | null;
client_auth_id: string | null; client_auth_id: string | null;
portal_locale: string;
}; };
export type ClientActivity = { export type ClientActivity = {
@@ -31,9 +32,18 @@ export type ClientActivity = {
created_at: string; created_at: string;
}; };
export function ClientDetailClient({ client, activities }: { client: ClientDetailData; activities: ClientActivity[] }) { export function ClientDetailClient({
client,
activities,
locales,
}: {
client: ClientDetailData;
activities: ClientActivity[];
locales: Array<{ code: string; nativeName: string; name: string }>;
}) {
const [isAddingActivity, setIsAddingActivity] = useState(false); const [isAddingActivity, setIsAddingActivity] = useState(false);
const [openDialog, setOpenDialog] = useState(false); const [openDialog, setOpenDialog] = useState(false);
const [portalLocale, setPortalLocale] = useState(client.portal_locale);
const getActivityIcon = (type: string) => { const getActivityIcon = (type: string) => {
switch (type) { switch (type) {
@@ -71,19 +81,21 @@ export function ClientDetailClient({ client, activities }: { client: ClientDetai
e.preventDefault(); e.preventDefault();
const formData = new FormData(e.currentTarget); const formData = new FormData(e.currentTarget);
const email = formData.get("email") as string; const email = formData.get("email") as string;
const locale = formData.get("locale") as string;
setIsCreatingUser(true); setIsCreatingUser(true);
try { try {
const res = await fetch("/api/create-client-user", { const res = await fetch("/api/create-client-user", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, client_id: client.id }) body: JSON.stringify({ email, client_id: client.id, locale })
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok || data.error) { if (!res.ok || data.error) {
throw new Error(data.error || "Kullanıcı oluşturulamadı."); throw new Error(data.error || "Kullanıcı oluşturulamadı.");
} }
setInvitationUrl(data.invitation.invitationUrl); setInvitationUrl(data.invitation.invitationUrl);
setPortalLocale(data.invitation.locale ?? locale);
toast.success("Güvenli portal daveti oluşturuldu."); toast.success("Güvenli portal daveti oluşturuldu.");
} catch (error: unknown) { } catch (error: unknown) {
toast.error(error instanceof Error ? error.message : "Davet oluşturulamadı."); toast.error(error instanceof Error ? error.message : "Davet oluşturulamadı.");
@@ -92,6 +104,23 @@ export function ClientDetailClient({ client, activities }: { client: ClientDetai
} }
} }
async function handlePortalLocaleChange(nextLocale: string) {
setPortalLocale(nextLocale);
try {
const response = await fetch(`/api/portal-clients/${client.id}/locale`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ locale: nextLocale }),
});
const data = await response.json();
if (!response.ok || data.error) throw new Error(data.error || "Portal dili güncellenemedi.");
toast.success("Portal dili güncellendi.");
} catch (error) {
setPortalLocale(client.portal_locale);
toast.error(error instanceof Error ? error.message : "Portal dili güncellenemedi.");
}
}
return ( return (
<div className="mx-auto flex max-w-7xl flex-col gap-6 animate-in fade-in slide-in-from-bottom-4 duration-500"> <div className="mx-auto flex max-w-7xl flex-col gap-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
{/* Header Info */} {/* Header Info */}
@@ -129,6 +158,21 @@ export function ClientDetailClient({ client, activities }: { client: ClientDetai
<Label htmlFor="email">E-posta Adresi</Label> <Label htmlFor="email">E-posta Adresi</Label>
<Input id="email" name="email" type="email" required defaultValue={client.email || ""} /> <Input id="email" name="email" type="email" required defaultValue={client.email || ""} />
</div> </div>
<div className="space-y-2">
<Label>Portal dili</Label>
<Select name="locale" defaultValue={portalLocale}>
<SelectTrigger>
<SelectValue placeholder="Dil seç" />
</SelectTrigger>
<SelectContent>
{locales.map((locale) => (
<SelectItem key={locale.code} value={locale.code}>
{locale.nativeName || locale.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{invitationUrl ? ( {invitationUrl ? (
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="invitation-url">Davet bağlantısı</Label> <Label htmlFor="invitation-url">Davet bağlantısı</Label>
@@ -163,9 +207,23 @@ export function ClientDetailClient({ client, activities }: { client: ClientDetai
</Dialog> </Dialog>
)} )}
{client.client_auth_id && ( {client.client_auth_id && (
<Badge className="bg-emerald-500/10 text-emerald-600 border-emerald-500/20 px-3 py-1 text-sm flex items-center gap-1.5 ml-2"> <>
<UserPlus className="h-3.5 w-3.5" /> Portal Aktif <Badge className="bg-emerald-500/10 text-emerald-600 border-emerald-500/20 px-3 py-1 text-sm flex items-center gap-1.5 ml-2">
</Badge> <UserPlus className="h-3.5 w-3.5" /> Portal Aktif
</Badge>
<Select value={portalLocale} onValueChange={handlePortalLocaleChange}>
<SelectTrigger className="h-9 w-36">
<SelectValue placeholder="Portal dili" />
</SelectTrigger>
<SelectContent>
{locales.map((locale) => (
<SelectItem key={locale.code} value={locale.code}>
{locale.nativeName || locale.name}
</SelectItem>
))}
</SelectContent>
</Select>
</>
)} )}
</div> </div>
</div> </div>
@@ -290,7 +348,7 @@ export function ClientDetailClient({ client, activities }: { client: ClientDetai
{getActivityBadge(activity.type)} {getActivityBadge(activity.type)}
</div> </div>
<time className="text-xs text-muted-foreground block mb-2 font-medium"> <time className="text-xs text-muted-foreground block mb-2 font-medium">
{format(new Date(activity.activity_date), "d MMM yyyy, HH:mm", { locale: tr })} {format(new Date(activity.activity_date), "d MMM yyyy, HH:mm", { locale: getDocumentDateFnsLocale() })}
</time> </time>
{activity.content && ( {activity.content && (
<p className="text-sm text-muted-foreground whitespace-pre-wrap">{activity.content}</p> <p className="text-sm text-muted-foreground whitespace-pre-wrap">{activity.content}</p>
+7 -1
View File
@@ -1,11 +1,16 @@
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { ClientDetailClient, type ClientDetailData, type ClientActivity } from "./client-detail-client"; import { ClientDetailClient, type ClientDetailData, type ClientActivity } from "./client-detail-client";
import { getSqliteConnection } from "@/server/db/client";
import { DomainError } from "@/server/domain/errors"; import { DomainError } from "@/server/domain/errors";
import { I18nService } from "@/server/i18n/service";
import { requireFreelancerBackend } from "@/server/web/freelancer"; import { requireFreelancerBackend } from "@/server/web/freelancer";
export default async function ClientDetailPage({ params }: { params: Promise<{ id: string }> }) { export default async function ClientDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params; const { id } = await params;
const { actor, service } = await requireFreelancerBackend(); const { actor, service } = await requireFreelancerBackend();
const i18n = new I18nService(getSqliteConnection().db);
const locales = i18n.listLocales(actor).filter((locale) => locale.status === "active");
const defaultLocale = i18n.getSettings(actor).defaultLocale;
let data: { client: ClientDetailData; activities: ClientActivity[] }; let data: { client: ClientDetailData; activities: ClientActivity[] };
try { try {
@@ -21,6 +26,7 @@ export default async function ClientDetailPage({ params }: { params: Promise<{ i
status: row.status, status: row.status,
notes: row.notes, notes: row.notes,
client_auth_id: row.authUserId, client_auth_id: row.authUserId,
portal_locale: row.portalLocale ?? defaultLocale,
}; };
const activities: ClientActivity[] = service.listClientActivities(actor, id).map((activity) => ({ const activities: ClientActivity[] = service.listClientActivities(actor, id).map((activity) => ({
id: activity.id, id: activity.id,
@@ -37,5 +43,5 @@ export default async function ClientDetailPage({ params }: { params: Promise<{ i
throw error; throw error;
} }
return <ClientDetailClient client={data.client} activities={data.activities} />; return <ClientDetailClient client={data.client} activities={data.activities} locales={locales} />;
} }
+8 -5
View File
@@ -1,5 +1,7 @@
"use client"; "use client";
import { getDocumentIntlLocale } from "@/lib/i18n/browser";
import { useTranslations } from "@/components/i18n/i18n-provider";
import { import {
createClientRecord, createClientRecord,
updateClientRecord, updateClientRecord,
@@ -40,7 +42,7 @@ import {
import Link from "next/link"; import Link from "next/link";
import { useState } from "react"; import { useState } from "react";
import { format, isPast, isToday } from "date-fns"; import { format, isPast, isToday } from "date-fns";
import { tr } from "date-fns/locale"; import { getDocumentDateFnsLocale } from "@/lib/i18n/date-fns";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { StatCard } from "@/components/system/stat-card"; import { StatCard } from "@/components/system/stat-card";
@@ -88,6 +90,7 @@ export function ClientsClient({
totalRevenue, totalRevenue,
activeCount, activeCount,
}: ClientsClientProps) { }: ClientsClientProps) {
const t = useTranslations();
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const normalizedQuery = query.trim().toLowerCase(); const normalizedQuery = query.trim().toLowerCase();
@@ -154,7 +157,7 @@ export function ClientsClient({
<div className="flex flex-col gap-4 border-b border-border pb-5 lg:flex-row lg:items-end lg:justify-between"> <div className="flex flex-col gap-4 border-b border-border pb-5 lg:flex-row lg:items-end lg:justify-between">
<div> <div>
<h1 className="text-3xl font-semibold tracking-normal text-foreground"> <h1 className="text-3xl font-semibold tracking-normal text-foreground">
CRM & Müşteriler {t("clients.title")}
</h1> </h1>
</div> </div>
@@ -326,7 +329,7 @@ function DraggableClientCard({
<div className="mt-3 flex items-center gap-1.5 text-xs pointer-events-none"> <div className="mt-3 flex items-center gap-1.5 text-xs pointer-events-none">
<Clock className={`h-3 w-3 ${isPast(new Date(client.next_follow_up_date)) ? 'text-rose-500' : 'text-muted-foreground'}`} /> <Clock className={`h-3 w-3 ${isPast(new Date(client.next_follow_up_date)) ? 'text-rose-500' : 'text-muted-foreground'}`} />
<span className={isPast(new Date(client.next_follow_up_date)) ? 'text-rose-500 font-medium' : 'text-muted-foreground'}> <span className={isPast(new Date(client.next_follow_up_date)) ? 'text-rose-500 font-medium' : 'text-muted-foreground'}>
{format(new Date(client.next_follow_up_date), 'd MMM yyyy', { locale: tr })} {format(new Date(client.next_follow_up_date), 'd MMM yyyy', { locale: getDocumentDateFnsLocale() })}
</span> </span>
</div> </div>
)} )}
@@ -384,7 +387,7 @@ function ClientRow({ client }: { client: ClientListItem }) {
{client.next_follow_up_date ? ( {client.next_follow_up_date ? (
<div className={`flex items-center gap-1.5 ${isFollowUpOverdue ? 'text-rose-600 font-medium' : 'text-muted-foreground'}`}> <div className={`flex items-center gap-1.5 ${isFollowUpOverdue ? 'text-rose-600 font-medium' : 'text-muted-foreground'}`}>
<Clock className="h-3.5 w-3.5" /> <Clock className="h-3.5 w-3.5" />
{format(new Date(client.next_follow_up_date), 'd MMM yyyy', { locale: tr })} {format(new Date(client.next_follow_up_date), 'd MMM yyyy', { locale: getDocumentDateFnsLocale() })}
</div> </div>
) : ( ) : (
<span className="text-muted-foreground opacity-50">-</span> <span className="text-muted-foreground opacity-50">-</span>
@@ -635,5 +638,5 @@ function formatPhone(input: string) {
} }
function formatCurrency(value: number) { function formatCurrency(value: number) {
return new Intl.NumberFormat("tr-TR", { style: "currency", currency: "USD", maximumFractionDigits: 0 }).format(value); return new Intl.NumberFormat(getDocumentIntlLocale(), { style: "currency", currency: "USD", maximumFractionDigits: 0 }).format(value);
} }
+64 -20
View File
@@ -7,12 +7,18 @@ import {
type ProjectPlanningSectionItem, type ProjectPlanningSectionItem,
type ProjectRevisionItem, type ProjectRevisionItem,
} from "@/app/(dashboard)/projects/[id]/project-detail-client"; } from "@/app/(dashboard)/projects/[id]/project-detail-client";
import { getSqliteConnection } from "@/server/db/client";
import { DomainError } from "@/server/domain/errors"; import { DomainError } from "@/server/domain/errors";
import { ContentTranslationService, type ContentTranslationRow } from "@/server/i18n/content";
import { resolveRequestLocale } from "@/server/i18n/resolver";
import { requireFreelancerBackend } from "@/server/web/freelancer"; import { requireFreelancerBackend } from "@/server/web/freelancer";
export default async function ProjectDetailPage({ params }: { params: Promise<{ id: string }> }) { export default async function ProjectDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params; const { id } = await params;
const locale = await resolveRequestLocale();
const { actor, service } = await requireFreelancerBackend(); const { actor, service } = await requireFreelancerBackend();
const content = new ContentTranslationService(getSqliteConnection().db);
const localization = content.getLocalizationContext(actor);
let data: { let data: {
project: ProjectDetail; project: ProjectDetail;
@@ -23,14 +29,20 @@ export default async function ProjectDetailPage({ params }: { params: Promise<{
}; };
try { try {
const row = service.getProject(actor, id); const row = service.getProject(actor, id);
const projectTranslationRows = content.listEntityTranslations("project", row.id);
const resolvedProject = content.resolveEntity("project", row, {
locale: locale.locale,
defaultLocale: localization.defaultLocale,
translations: projectTranslationRows,
});
const client = row.clientId ? service.getClient(actor, row.clientId) : null; const client = row.clientId ? service.getClient(actor, row.clientId) : null;
const project: ProjectDetail = { const project: ProjectDetail = {
id: row.id, id: row.id,
client_id: row.clientId, client_id: row.clientId,
clientName: client?.name ?? null, clientName: client?.name ?? null,
name: row.name, name: resolvedProject.name,
type: row.type, type: row.type,
description: row.description, description: resolvedProject.description,
status: row.status, status: row.status,
start_date: row.startDate, start_date: row.startDate,
due_date: row.dueDate, due_date: row.dueDate,
@@ -39,27 +51,50 @@ export default async function ProjectDetailPage({ params }: { params: Promise<{
progress: row.progress, progress: row.progress,
progress_type: row.progressType, progress_type: row.progressType,
revision_quota: row.revisionQuota, revision_quota: row.revisionQuota,
cover_image_alt: row.coverImageAlt, cover_image_alt: resolvedProject.coverImageAlt,
coverImageUrl: row.legacyCoverImagePath, coverImageUrl: row.legacyCoverImagePath,
translations: toLocalizedValues(projectTranslationRows),
}; };
const sections: ProjectPlanningSectionItem[] = service.listPlanningSections(actor, id).map((section) => ({ const sectionRows = service.listPlanningSections(actor, id);
id: section.id, const sectionTranslations = content.listBatch("planning_section", sectionRows.map((section) => section.id));
project_id: section.projectId, const sections: ProjectPlanningSectionItem[] = sectionRows.map((section) => {
category: section.category, const translationRows = sectionTranslations.get(section.id) ?? [];
title: section.title, const resolvedSection = content.resolveEntity("planning_section", section, {
content: section.content, locale: locale.locale,
sort_order: section.sortOrder, defaultLocale: localization.defaultLocale,
})); translations: translationRows,
const tasks: ProjectDetailTaskItem[] = service.listTasks(actor, id) });
return {
id: section.id,
project_id: section.projectId,
category: section.category,
title: resolvedSection.title,
content: resolvedSection.content,
sort_order: section.sortOrder,
translations: toLocalizedValues(translationRows),
};
});
const taskRows = service.listTasks(actor, id).filter((task) => task.status !== "cancelled");
const taskTranslations = content.listBatch("task", taskRows.map((task) => task.id));
const tasks: ProjectDetailTaskItem[] = taskRows
.filter((task) => task.status !== "cancelled") .filter((task) => task.status !== "cancelled")
.map((task) => ({ .map((task) => {
id: task.id, const translationRows = taskTranslations.get(task.id) ?? [];
title: task.title, const resolvedTask = content.resolveEntity("task", task, {
status: task.status as ProjectDetailTaskItem["status"], locale: locale.locale,
priority: task.priority, defaultLocale: localization.defaultLocale,
due_at: task.dueAt?.toISOString() ?? null, translations: translationRows,
is_public_to_client: task.isPublicToClient, });
})); return {
id: task.id,
title: resolvedTask.title,
status: task.status as ProjectDetailTaskItem["status"],
priority: task.priority,
due_at: task.dueAt?.toISOString() ?? null,
is_public_to_client: task.isPublicToClient,
translations: toLocalizedValues(translationRows),
};
});
const financeTransactions: ProjectFinanceItem[] = service.listFinanceTransactions(actor) const financeTransactions: ProjectFinanceItem[] = service.listFinanceTransactions(actor)
.filter((transaction) => transaction.projectId === id) .filter((transaction) => transaction.projectId === id)
.map((transaction) => ({ .map((transaction) => ({
@@ -92,6 +127,15 @@ export default async function ProjectDetailPage({ params }: { params: Promise<{
tasks={data.tasks} tasks={data.tasks}
financeTransactions={data.financeTransactions} financeTransactions={data.financeTransactions}
revisions={data.revisions} revisions={data.revisions}
localization={localization}
/> />
); );
} }
function toLocalizedValues(rows: ContentTranslationRow[]) {
return rows.reduce<Record<string, Record<string, string>>>((result, row) => {
result[row.locale] = result[row.locale] ?? {};
result[row.locale][row.field] = row.value;
return result;
}, {});
}
@@ -1,5 +1,6 @@
"use client"; "use client";
import { getDocumentIntlLocale } from "@/lib/i18n/browser";
import { import {
completeProjectRecord, completeProjectRecord,
createProjectPlanningSectionRecord, createProjectPlanningSectionRecord,
@@ -10,9 +11,11 @@ import {
createTaskRecord, createTaskRecord,
updateTaskStatusRecord, updateTaskStatusRecord,
} from "@/app/(dashboard)/tasks/actions"; } from "@/app/(dashboard)/tasks/actions";
import { LocalizedFields, type LocalizedFieldLocale, type LocalizedFieldValues } from "@/components/i18n/localized-fields";
import { PendingLink } from "@/components/ui/pending-link"; import { PendingLink } from "@/components/ui/pending-link";
import { PendingSubmitButton } from "@/components/ui/pending-submit-button"; import { PendingSubmitButton } from "@/components/ui/pending-submit-button";
import { Badge, Button, Card, CardContent, Input, Label, Textarea } from "poyraz-ui/atoms"; import { contentTranslationRegistry } from "@/lib/i18n/content";
import { Badge, Button, Card, CardContent, Input, Label } from "poyraz-ui/atoms";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -66,6 +69,7 @@ export type ProjectDetail = {
revision_quota: number; revision_quota: number;
cover_image_alt: string | null; cover_image_alt: string | null;
coverImageUrl: string | null; coverImageUrl: string | null;
translations?: LocalizedFieldValues;
}; };
export type ProjectPlanningSectionItem = { export type ProjectPlanningSectionItem = {
@@ -85,6 +89,7 @@ export type ProjectPlanningSectionItem = {
title: string; title: string;
content: string | null; content: string | null;
sort_order: number; sort_order: number;
translations?: LocalizedFieldValues;
}; };
export type ProjectDetailTaskItem = { export type ProjectDetailTaskItem = {
@@ -94,6 +99,7 @@ export type ProjectDetailTaskItem = {
priority: "low" | "medium" | "high" | "urgent"; priority: "low" | "medium" | "high" | "urgent";
due_at: string | null; due_at: string | null;
is_public_to_client: boolean; is_public_to_client: boolean;
translations?: LocalizedFieldValues;
}; };
export type ProjectFinanceItem = { export type ProjectFinanceItem = {
@@ -120,6 +126,10 @@ type ProjectDetailClientProps = {
tasks: ProjectDetailTaskItem[]; tasks: ProjectDetailTaskItem[];
financeTransactions: ProjectFinanceItem[]; financeTransactions: ProjectFinanceItem[];
revisions: ProjectRevisionItem[]; revisions: ProjectRevisionItem[];
localization: {
defaultLocale: string;
locales: LocalizedFieldLocale[];
};
}; };
const typeLabels = { const typeLabels = {
@@ -185,6 +195,7 @@ export function ProjectDetailClient({
tasks, tasks,
financeTransactions, financeTransactions,
revisions, revisions,
localization,
}: ProjectDetailClientProps) { }: ProjectDetailClientProps) {
const [activeTab, setActiveTab] = useState<"planning" | "design" | "tasks" | "finance" | "revisions">( const [activeTab, setActiveTab] = useState<"planning" | "design" | "tasks" | "finance" | "revisions">(
"planning", "planning",
@@ -227,7 +238,7 @@ export function ProjectDetailClient({
<div className="flex gap-2"> <div className="flex gap-2">
<ProjectSettingsDialog project={project} /> <ProjectSettingsDialog project={project} />
<SectionDialog projectId={project.id} mode="create" defaultCategory="overview" /> <SectionDialog projectId={project.id} mode="create" defaultCategory="overview" localization={localization} />
{project.status !== "completed" ? ( {project.status !== "completed" ? (
<form action={completeProjectRecord}> <form action={completeProjectRecord}>
<input type="hidden" name="id" value={project.id} /> <input type="hidden" name="id" value={project.id} />
@@ -329,6 +340,7 @@ export function ProjectDetailClient({
description="Problem, amaç, hedef kitle, kapsam ve proje notlarını burada tut." description="Problem, amaç, hedef kitle, kapsam ve proje notlarını burada tut."
sections={planningSections} sections={planningSections}
defaultCategory="overview" defaultCategory="overview"
localization={localization}
/> />
) : null} ) : null}
@@ -339,11 +351,12 @@ export function ProjectDetailClient({
description="Renk paleti, tipografi, görsel dil ve asset notlarını proje kaynağına bağla." description="Renk paleti, tipografi, görsel dil ve asset notlarını proje kaynağına bağla."
sections={designSections} sections={designSections}
defaultCategory="design_system" defaultCategory="design_system"
localization={localization}
/> />
) : null} ) : null}
{activeTab === "tasks" ? ( {activeTab === "tasks" ? (
<TaskPanel projectId={project.id} clientId={project.client_id} tasks={tasks} /> <TaskPanel projectId={project.id} clientId={project.client_id} tasks={tasks} localization={localization} />
) : null} ) : null}
{activeTab === "finance" ? <FinancePanel transactions={financeTransactions} /> : null} {activeTab === "finance" ? <FinancePanel transactions={financeTransactions} /> : null}
{activeTab === "revisions" ? <RevisionsPanel projectId={project.id} revisions={revisions} /> : null} {activeTab === "revisions" ? <RevisionsPanel projectId={project.id} revisions={revisions} /> : null}
@@ -391,7 +404,7 @@ function RevisionsPanel({
<div key={rev.id} className="p-4 border rounded-md"> <div key={rev.id} className="p-4 border rounded-md">
<div className="flex justify-between items-start mb-3"> <div className="flex justify-between items-start mb-3">
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
{new Date(rev.created_at).toLocaleString('tr-TR')} {new Date(rev.created_at).toLocaleString(getDocumentIntlLocale())}
</div> </div>
<Select <Select
defaultValue={rev.status} defaultValue={rev.status}
@@ -430,12 +443,14 @@ function SectionGrid({
description, description,
sections, sections,
defaultCategory, defaultCategory,
localization,
}: { }: {
projectId: string; projectId: string;
title: string; title: string;
description: string; description: string;
sections: ProjectPlanningSectionItem[]; sections: ProjectPlanningSectionItem[];
defaultCategory: ProjectPlanningSectionItem["category"]; defaultCategory: ProjectPlanningSectionItem["category"];
localization: ProjectDetailClientProps["localization"];
}) { }) {
return ( return (
<Card> <Card>
@@ -445,13 +460,13 @@ function SectionGrid({
<h2 className="text-lg font-semibold text-foreground">{title}</h2> <h2 className="text-lg font-semibold text-foreground">{title}</h2>
<p className="mt-1 text-sm text-muted-foreground">{description}</p> <p className="mt-1 text-sm text-muted-foreground">{description}</p>
</div> </div>
<SectionDialog projectId={projectId} mode="create" defaultCategory={defaultCategory} /> <SectionDialog projectId={projectId} mode="create" defaultCategory={defaultCategory} localization={localization} />
</div> </div>
{sections.length > 0 ? ( {sections.length > 0 ? (
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
{sections.map((section) => ( {sections.map((section) => (
<PlanningSectionCard key={section.id} section={section} /> <PlanningSectionCard key={section.id} section={section} localization={localization} />
))} ))}
</div> </div>
) : ( ) : (
@@ -469,7 +484,13 @@ function SectionGrid({
); );
} }
function PlanningSectionCard({ section }: { section: ProjectPlanningSectionItem }) { function PlanningSectionCard({
section,
localization,
}: {
section: ProjectPlanningSectionItem;
localization: ProjectDetailClientProps["localization"];
}) {
return ( return (
<Card className="transition-colors hover:border-primary/30"> <Card className="transition-colors hover:border-primary/30">
<CardContent className="flex h-full flex-col gap-4 p-4"> <CardContent className="flex h-full flex-col gap-4 p-4">
@@ -479,7 +500,7 @@ function PlanningSectionCard({ section }: { section: ProjectPlanningSectionItem
<h3 className="mt-3 text-base font-semibold text-foreground">{section.title}</h3> <h3 className="mt-3 text-base font-semibold text-foreground">{section.title}</h3>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<SectionDialog projectId={section.project_id} mode="edit" section={section} /> <SectionDialog projectId={section.project_id} mode="edit" section={section} localization={localization} />
<form action={deleteProjectPlanningSectionRecord}> <form action={deleteProjectPlanningSectionRecord}>
<input type="hidden" name="id" value={section.id} /> <input type="hidden" name="id" value={section.id} />
<input type="hidden" name="project_id" value={section.project_id} /> <input type="hidden" name="project_id" value={section.project_id} />
@@ -505,11 +526,13 @@ function SectionDialog({
mode, mode,
defaultCategory, defaultCategory,
section, section,
localization,
}: { }: {
projectId: string; projectId: string;
mode: "create" | "edit"; mode: "create" | "edit";
defaultCategory?: ProjectPlanningSectionItem["category"]; defaultCategory?: ProjectPlanningSectionItem["category"];
section?: ProjectPlanningSectionItem; section?: ProjectPlanningSectionItem;
localization: ProjectDetailClientProps["localization"];
}) { }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
@@ -569,26 +592,17 @@ function SectionDialog({
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="grid gap-2"> <LocalizedFields
<Label htmlFor={`section-title-${section?.id || "new"}`}>Başlık</Label> idPrefix={`section-${section?.id || "new"}`}
<Input defaultLocale={localization.defaultLocale}
id={`section-title-${section?.id || "new"}`} locales={localization.locales}
name="title" fields={contentTranslationRegistry.planning_section}
defaultValue={section?.title || ""} values={section?.translations}
required fallbackValues={{
placeholder="Örn. Başarı kriterleri" title: section?.title,
/> content: section?.content,
</div> }}
<div className="grid gap-2"> />
<Label htmlFor={`section-content-${section?.id || "new"}`}>İçerik</Label>
<Textarea
id={`section-content-${section?.id || "new"}`}
name="content"
defaultValue={section?.content || ""}
rows={8}
placeholder="Kısa notlar, kriterler, renkler, tipografi kararları..."
/>
</div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor={`section-order-${section?.id || "new"}`}>Sıra</Label> <Label htmlFor={`section-order-${section?.id || "new"}`}>Sıra</Label>
<Input <Input
@@ -615,10 +629,12 @@ function TaskPanel({
projectId, projectId,
clientId, clientId,
tasks, tasks,
localization,
}: { }: {
projectId: string; projectId: string;
clientId: string | null; clientId: string | null;
tasks: ProjectDetailTaskItem[]; tasks: ProjectDetailTaskItem[];
localization: ProjectDetailClientProps["localization"];
}) { }) {
const [view, setView] = useState<"list" | "kanban">("list"); const [view, setView] = useState<"list" | "kanban">("list");
const [statusOverrides, setStatusOverrides] = useState< const [statusOverrides, setStatusOverrides] = useState<
@@ -703,7 +719,7 @@ function TaskPanel({
Kanban Kanban
</Button> </Button>
</div> </div>
<ProjectTaskDialog projectId={projectId} clientId={clientId} /> <ProjectTaskDialog projectId={projectId} clientId={clientId} localization={localization} />
</div> </div>
</div> </div>
@@ -989,9 +1005,11 @@ function ProjectSettingsDialog({ project }: { project: ProjectDetail }) {
function ProjectTaskDialog({ function ProjectTaskDialog({
projectId, projectId,
clientId, clientId,
localization,
}: { }: {
projectId: string; projectId: string;
clientId: string | null; clientId: string | null;
localization: ProjectDetailClientProps["localization"];
}) { }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
@@ -1027,24 +1045,12 @@ function ProjectTaskDialog({
</DialogHeader> </DialogHeader>
<div className="grid gap-4"> <div className="grid gap-4">
<div className="grid gap-2"> <LocalizedFields
<Label htmlFor="project-task-title">Başlık</Label> idPrefix="project-task"
<Input defaultLocale={localization.defaultLocale}
id="project-task-title" locales={localization.locales}
name="title" fields={contentTranslationRegistry.task}
required />
placeholder="Örn. Mobil görünüm kontrolü"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="project-task-description">Açıklama</Label>
<Textarea
id="project-task-description"
name="description"
rows={3}
placeholder="Kapsam, teslim notu veya kabul kriterleri..."
/>
</div>
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
<div className="grid gap-2"> <div className="grid gap-2">
<Label>Durum</Label> <Label>Durum</Label>
@@ -1262,7 +1268,7 @@ function TabButton({
} }
function formatDate(value: string) { function formatDate(value: string) {
return new Intl.DateTimeFormat("tr-TR", { return new Intl.DateTimeFormat(getDocumentIntlLocale(), {
day: "2-digit", day: "2-digit",
month: "short", month: "short",
year: "numeric", year: "numeric",
@@ -1270,7 +1276,7 @@ function formatDate(value: string) {
} }
function formatDateTime(value: string) { function formatDateTime(value: string) {
return new Intl.DateTimeFormat("tr-TR", { return new Intl.DateTimeFormat(getDocumentIntlLocale(), {
day: "2-digit", day: "2-digit",
month: "short", month: "short",
hour: "2-digit", hour: "2-digit",
@@ -1285,7 +1291,7 @@ function getTaskStatusLabel(status: ProjectDetailTaskItem["status"]) {
} }
function formatCurrency(value: number, currency: string) { function formatCurrency(value: number, currency: string) {
return new Intl.NumberFormat("tr-TR", { return new Intl.NumberFormat(getDocumentIntlLocale(), {
style: "currency", style: "currency",
currency, currency,
maximumFractionDigits: 0, maximumFractionDigits: 0,
+32 -12
View File
@@ -3,6 +3,11 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { getFileService } from "@/server/files/runtime"; import { getFileService } from "@/server/files/runtime";
import { getSqliteConnection } from "@/server/db/client";
import {
ContentTranslationService,
parseContentTranslationsFromFormData,
} from "@/server/i18n/content";
import { cleanText, decimalToMinor, requiredText } from "@/server/web/form-data"; import { cleanText, decimalToMinor, requiredText } from "@/server/web/form-data";
import { requireFreelancerBackend } from "@/server/web/freelancer"; import { requireFreelancerBackend } from "@/server/web/freelancer";
@@ -20,20 +25,21 @@ function numberValue(value: FormDataEntryValue | null, fallback = 0) {
return Number.isFinite(parsed) ? parsed : fallback; return Number.isFinite(parsed) ? parsed : fallback;
} }
function projectPayload(formData: FormData) { function projectPayload(formData: FormData, translations?: Record<string, Record<string, string | null>>, defaultLocale = "tr") {
const type = enumValue(formData.get("type"), PROJECT_TYPES, "client_project"); const type = enumValue(formData.get("type"), PROJECT_TYPES, "client_project");
const localized = translations?.[defaultLocale] ?? {};
return { return {
name: requiredText(formData.get("name"), "Proje adı zorunludur."), name: localized.name ?? requiredText(formData.get("name"), "Proje adı zorunludur."),
type, type,
clientId: type === "client_project" ? cleanText(formData.get("client_id")) : null, clientId: type === "client_project" ? cleanText(formData.get("client_id")) : null,
description: cleanText(formData.get("description")), description: localized.description ?? cleanText(formData.get("description")),
status: enumValue(formData.get("status"), PROJECT_STATUSES, "planning"), status: enumValue(formData.get("status"), PROJECT_STATUSES, "planning"),
startDate: cleanText(formData.get("start_date")), startDate: cleanText(formData.get("start_date")),
dueDate: cleanText(formData.get("due_date")), dueDate: cleanText(formData.get("due_date")),
budgetAmountMinor: decimalToMinor(formData.get("budget_amount")), budgetAmountMinor: decimalToMinor(formData.get("budget_amount")),
currency: cleanText(formData.get("currency")) ?? "USD", currency: cleanText(formData.get("currency")) ?? "USD",
progress: Math.min(100, Math.max(0, Math.round(numberValue(formData.get("progress"))))), progress: Math.min(100, Math.max(0, Math.round(numberValue(formData.get("progress"))))),
coverImageAlt: cleanText(formData.get("cover_image_alt")), coverImageAlt: localized.coverImageAlt ?? cleanText(formData.get("cover_image_alt")),
}; };
} }
@@ -57,8 +63,11 @@ async function uploadCover(
export async function createProjectRecord(formData: FormData) { export async function createProjectRecord(formData: FormData) {
const { actor, service } = await requireFreelancerBackend(); const { actor, service } = await requireFreelancerBackend();
const i18n = new ContentTranslationService(getSqliteConnection().db);
const context = i18n.getLocalizationContext(actor);
const translations = parseContentTranslationsFromFormData(formData, "project", context);
const id = randomUUID(); const id = randomUUID();
service.createProject(actor, { id, ...projectPayload(formData) }); service.createProject(actor, { id, ...projectPayload(formData, translations, context.defaultLocale), translations });
try { try {
const cover = await uploadCover(actor, id, formData); const cover = await uploadCover(actor, id, formData);
if (cover) service.updateProject(actor, id, { legacyCoverImagePath: cover }); if (cover) service.updateProject(actor, id, { legacyCoverImagePath: cover });
@@ -71,8 +80,11 @@ export async function createProjectRecord(formData: FormData) {
export async function updateProjectRecord(formData: FormData) { export async function updateProjectRecord(formData: FormData) {
const { actor, service } = await requireFreelancerBackend(); const { actor, service } = await requireFreelancerBackend();
const i18n = new ContentTranslationService(getSqliteConnection().db);
const context = i18n.getLocalizationContext(actor);
const translations = parseContentTranslationsFromFormData(formData, "project", context);
const id = requiredText(formData.get("id"), "Proje kaydı bulunamadı."); const id = requiredText(formData.get("id"), "Proje kaydı bulunamadı.");
service.updateProject(actor, id, projectPayload(formData)); service.updateProject(actor, id, { ...projectPayload(formData, translations, context.defaultLocale), translations });
const cover = await uploadCover(actor, id, formData); const cover = await uploadCover(actor, id, formData);
if (cover) service.updateProject(actor, id, { legacyCoverImagePath: cover }); if (cover) service.updateProject(actor, id, { legacyCoverImagePath: cover });
revalidatePath("/projects"); revalidatePath("/projects");
@@ -87,28 +99,35 @@ export async function completeProjectRecord(formData: FormData) {
revalidatePath(`/projects/${id}`); revalidatePath(`/projects/${id}`);
} }
function sectionPayload(formData: FormData) { function sectionPayload(formData: FormData, translations?: Record<string, Record<string, string | null>>, defaultLocale = "tr") {
const localized = translations?.[defaultLocale] ?? {};
return { return {
projectId: requiredText(formData.get("project_id"), "Proje zorunludur."), projectId: requiredText(formData.get("project_id"), "Proje zorunludur."),
category: enumValue(formData.get("category"), SECTION_CATEGORIES, "overview"), category: enumValue(formData.get("category"), SECTION_CATEGORIES, "overview"),
title: requiredText(formData.get("title"), "Planlama başlığı zorunludur."), title: localized.title ?? requiredText(formData.get("title"), "Planlama başlığı zorunludur."),
content: cleanText(formData.get("content")), content: localized.content ?? cleanText(formData.get("content")),
sortOrder: Math.max(0, Math.round(numberValue(formData.get("sort_order")))), sortOrder: Math.max(0, Math.round(numberValue(formData.get("sort_order")))),
}; };
} }
export async function createProjectPlanningSectionRecord(formData: FormData) { export async function createProjectPlanningSectionRecord(formData: FormData) {
const { actor, service } = await requireFreelancerBackend(); const { actor, service } = await requireFreelancerBackend();
const payload = sectionPayload(formData); const i18n = new ContentTranslationService(getSqliteConnection().db);
service.addPlanningSection(actor, payload); const context = i18n.getLocalizationContext(actor);
const translations = parseContentTranslationsFromFormData(formData, "planning_section", context);
const payload = sectionPayload(formData, translations, context.defaultLocale);
service.addPlanningSection(actor, { ...payload, translations });
revalidatePath("/projects"); revalidatePath("/projects");
revalidatePath(`/projects/${payload.projectId}`); revalidatePath(`/projects/${payload.projectId}`);
} }
export async function updateProjectPlanningSectionRecord(formData: FormData) { export async function updateProjectPlanningSectionRecord(formData: FormData) {
const { actor, service } = await requireFreelancerBackend(); const { actor, service } = await requireFreelancerBackend();
const i18n = new ContentTranslationService(getSqliteConnection().db);
const context = i18n.getLocalizationContext(actor);
const translations = parseContentTranslationsFromFormData(formData, "planning_section", context);
const id = requiredText(formData.get("id"), "Planlama alanı bulunamadı."); const id = requiredText(formData.get("id"), "Planlama alanı bulunamadı.");
const payload = sectionPayload(formData); const payload = sectionPayload(formData, translations, context.defaultLocale);
if (!service.listPlanningSections(actor, payload.projectId).some((section) => section.id === id)) { if (!service.listPlanningSections(actor, payload.projectId).some((section) => section.id === id)) {
throw new Error("Planlama alanı bu projeye ait değil."); throw new Error("Planlama alanı bu projeye ait değil.");
} }
@@ -117,6 +136,7 @@ export async function updateProjectPlanningSectionRecord(formData: FormData) {
title: payload.title, title: payload.title,
content: payload.content, content: payload.content,
sortOrder: payload.sortOrder, sortOrder: payload.sortOrder,
translations,
}); });
revalidatePath("/projects"); revalidatePath("/projects");
revalidatePath(`/projects/${payload.projectId}`); revalidatePath(`/projects/${payload.projectId}`);
+27 -5
View File
@@ -1,8 +1,14 @@
import { ProjectsClient, type ProjectClientOption, type ProjectListItem } from "@/app/(dashboard)/projects/projects-client"; import { ProjectsClient, type ProjectClientOption, type ProjectListItem } from "@/app/(dashboard)/projects/projects-client";
import { getSqliteConnection } from "@/server/db/client";
import { ContentTranslationService, type ContentTranslationRow } from "@/server/i18n/content";
import { resolveRequestLocale } from "@/server/i18n/resolver";
import { requireFreelancerBackend } from "@/server/web/freelancer"; import { requireFreelancerBackend } from "@/server/web/freelancer";
export default async function ProjectsPage() { export default async function ProjectsPage() {
const locale = await resolveRequestLocale();
const { actor, service } = await requireFreelancerBackend(); const { actor, service } = await requireFreelancerBackend();
const content = new ContentTranslationService(getSqliteConnection().db);
const localization = content.getLocalizationContext(actor);
const projectRows = service.listProjects(actor); const projectRows = service.listProjects(actor);
const clientRows = service.listClients(actor); const clientRows = service.listClients(actor);
const taskRows = service.listTasks(actor); const taskRows = service.listTasks(actor);
@@ -17,15 +23,22 @@ export default async function ProjectsPage() {
taskStats.set(task.projectId, stats); taskStats.set(task.projectId, stats);
} }
const projectTranslations = content.listBatch("project", projectRows.map((project) => project.id));
const projects: ProjectListItem[] = projectRows.map((project) => { const projects: ProjectListItem[] = projectRows.map((project) => {
const stats = taskStats.get(project.id) ?? { total: 0, done: 0 }; const stats = taskStats.get(project.id) ?? { total: 0, done: 0 };
const translationRows = projectTranslations.get(project.id) ?? [];
const resolvedProject = content.resolveEntity("project", project, {
locale: locale.locale,
defaultLocale: localization.defaultLocale,
translations: translationRows,
});
return { return {
id: project.id, id: project.id,
client_id: project.clientId, client_id: project.clientId,
clientName: project.clientId ? clientNames.get(project.clientId) ?? null : null, clientName: project.clientId ? clientNames.get(project.clientId) ?? null : null,
name: project.name, name: resolvedProject.name,
type: project.type, type: project.type,
description: project.description, description: resolvedProject.description,
status: project.status, status: project.status,
start_date: project.startDate, start_date: project.startDate,
due_date: project.dueDate, due_date: project.dueDate,
@@ -33,16 +46,25 @@ export default async function ProjectsPage() {
currency: project.currency, currency: project.currency,
progress: project.progress, progress: project.progress,
cover_image_path: project.legacyCoverImagePath, cover_image_path: project.legacyCoverImagePath,
cover_image_alt: project.coverImageAlt, cover_image_alt: resolvedProject.coverImageAlt,
coverImageUrl: project.legacyCoverImagePath, coverImageUrl: project.legacyCoverImagePath,
taskCount: stats.total, taskCount: stats.total,
doneTaskCount: stats.done, doneTaskCount: stats.done,
translations: toLocalizedValues(translationRows),
}; };
}); });
const clients: ProjectClientOption[] = clientRows const clients: ProjectClientOption[] = clientRows
.filter((client) => client.status !== "archived") .filter((client) => client.status !== "archived")
.sort((a, b) => a.name.localeCompare(b.name, "tr")) .sort((a, b) => a.name.localeCompare(b.name, locale.locale))
.map(({ id, name }) => ({ id, name })); .map(({ id, name }) => ({ id, name }));
return <ProjectsClient projects={projects} clients={clients} />; return <ProjectsClient projects={projects} clients={clients} localization={localization} />;
}
function toLocalizedValues(rows: ContentTranslationRow[]) {
return rows.reduce<Record<string, Record<string, string>>>((result, row) => {
result[row.locale] = result[row.locale] ?? {};
result[row.locale][row.field] = row.value;
return result;
}, {});
} }
+47 -44
View File
@@ -1,13 +1,17 @@
"use client"; "use client";
import { getDocumentIntlLocale } from "@/lib/i18n/browser";
import { useTranslations } from "@/components/i18n/i18n-provider";
import { import {
completeProjectRecord, completeProjectRecord,
createProjectRecord, createProjectRecord,
updateProjectRecord, updateProjectRecord,
} from "@/app/(dashboard)/projects/actions"; } from "@/app/(dashboard)/projects/actions";
import { LocalizedFields, type LocalizedFieldLocale, type LocalizedFieldValues } from "@/components/i18n/localized-fields";
import { PendingLink } from "@/components/ui/pending-link"; import { PendingLink } from "@/components/ui/pending-link";
import { PendingSubmitButton } from "@/components/ui/pending-submit-button"; import { PendingSubmitButton } from "@/components/ui/pending-submit-button";
import { Badge, Button, Card, CardContent, Input, Label, Textarea } from "poyraz-ui/atoms"; import { contentTranslationRegistry } from "@/lib/i18n/content";
import { Badge, Button, Card, CardContent, Input, Label } from "poyraz-ui/atoms";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -66,6 +70,7 @@ export type ProjectListItem = {
coverImageUrl: string | null; coverImageUrl: string | null;
taskCount: number; taskCount: number;
doneTaskCount: number; doneTaskCount: number;
translations?: LocalizedFieldValues;
}; };
const typeLabels = { const typeLabels = {
@@ -92,9 +97,14 @@ const statusClasses = {
type ProjectsClientProps = { type ProjectsClientProps = {
projects: ProjectListItem[]; projects: ProjectListItem[];
clients: ProjectClientOption[]; clients: ProjectClientOption[];
localization: {
defaultLocale: string;
locales: LocalizedFieldLocale[];
};
}; };
export function ProjectsClient({ projects, clients }: ProjectsClientProps) { export function ProjectsClient({ projects, clients, localization }: ProjectsClientProps) {
const t = useTranslations();
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [view, setView] = useState<"grid" | "list">("grid"); const [view, setView] = useState<"grid" | "list">("grid");
const normalizedQuery = query.trim().toLowerCase(); const normalizedQuery = query.trim().toLowerCase();
@@ -118,21 +128,21 @@ export function ProjectsClient({ projects, clients }: ProjectsClientProps) {
<div className="flex flex-col gap-4 border-b border-border pb-5 lg:flex-row lg:items-end lg:justify-between"> <div className="flex flex-col gap-4 border-b border-border pb-5 lg:flex-row lg:items-end lg:justify-between">
<div> <div>
<h1 className="text-3xl font-semibold tracking-normal text-foreground"> <h1 className="text-3xl font-semibold tracking-normal text-foreground">
Projeler {t("projects.title")}
</h1> </h1>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<AIProjectRiskDialog /> <AIProjectRiskDialog />
<ProjectDialog mode="create" clients={clients} /> <ProjectDialog mode="create" clients={clients} localization={localization} />
</div> </div>
</div> </div>
<div className="grid gap-3 md:grid-cols-4"> <div className="grid gap-3 md:grid-cols-4">
<StatCard label="Aktif proje" value={activeCount.toString()} icon={FolderKanban} tone="green" /> <StatCard label={t("projects.stats.active")} value={activeCount.toString()} icon={FolderKanban} tone="green" />
<StatCard label="Side project" value={sideProjectCount.toString()} icon={Target} tone="blue" /> <StatCard label="Side project" value={sideProjectCount.toString()} icon={Target} tone="blue" />
<StatCard label="Ortalama ilerleme" value={`${averageProgress}%`} icon={CheckCircle2} tone="amber" /> <StatCard label={t("projects.stats.progress")} value={`${averageProgress}%`} icon={CheckCircle2} tone="amber" />
<StatCard label="Toplam bütçe" value={formatCurrency(totalBudget)} icon={Wallet} tone="red" /> <StatCard label={t("projects.stats.budget")} value={formatCurrency(totalBudget)} icon={Wallet} tone="red" />
</div> </div>
<Card> <Card>
@@ -178,7 +188,7 @@ export function ProjectsClient({ projects, clients }: ProjectsClientProps) {
view === "grid" ? ( view === "grid" ? (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3"> <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{filteredProjects.map((project) => ( {filteredProjects.map((project) => (
<ProjectCard key={project.id} project={project} clients={clients} /> <ProjectCard key={project.id} project={project} clients={clients} localization={localization} />
))} ))}
</div> </div>
) : ( ) : (
@@ -193,7 +203,7 @@ export function ProjectsClient({ projects, clients }: ProjectsClientProps) {
</div> </div>
<div className="divide-y divide-border"> <div className="divide-y divide-border">
{filteredProjects.map((project) => ( {filteredProjects.map((project) => (
<ProjectRow key={project.id} project={project} clients={clients} /> <ProjectRow key={project.id} project={project} clients={clients} localization={localization} />
))} ))}
</div> </div>
</div> </div>
@@ -211,9 +221,11 @@ export function ProjectsClient({ projects, clients }: ProjectsClientProps) {
function ProjectCard({ function ProjectCard({
project, project,
clients, clients,
localization,
}: { }: {
project: ProjectListItem; project: ProjectListItem;
clients: ProjectClientOption[]; clients: ProjectClientOption[];
localization: ProjectsClientProps["localization"];
}) { }) {
const router = useRouter(); const router = useRouter();
const [isNavigating, startNavigation] = useTransition(); const [isNavigating, startNavigation] = useTransition();
@@ -274,7 +286,7 @@ function ProjectCard({
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
{project.doneTaskCount}/{project.taskCount} görev tamamlandı {project.doneTaskCount}/{project.taskCount} görev tamamlandı
</div> </div>
<ProjectActions project={project} clients={clients} showDetail={false} /> <ProjectActions project={project} clients={clients} localization={localization} showDetail={false} />
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -307,9 +319,11 @@ function ProjectCover({ project }: { project: ProjectListItem }) {
function ProjectRow({ function ProjectRow({
project, project,
clients, clients,
localization,
}: { }: {
project: ProjectListItem; project: ProjectListItem;
clients: ProjectClientOption[]; clients: ProjectClientOption[];
localization: ProjectsClientProps["localization"];
}) { }) {
return ( return (
<div className="grid gap-4 px-4 py-4 grid-cols-[1.5fr_1fr_1fr_0.8fr_1fr] items-center"> <div className="grid gap-4 px-4 py-4 grid-cols-[1.5fr_1fr_1fr_0.8fr_1fr] items-center">
@@ -327,7 +341,7 @@ function ProjectRow({
<ProgressBar progress={project.progress} compact /> <ProgressBar progress={project.progress} compact />
</div> </div>
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<ProjectActions project={project} clients={clients} showDetail /> <ProjectActions project={project} clients={clients} localization={localization} showDetail />
</div> </div>
</div> </div>
); );
@@ -350,10 +364,12 @@ function ProjectMeta({ project }: { project: ProjectListItem }) {
function ProjectActions({ function ProjectActions({
project, project,
clients, clients,
localization,
showDetail, showDetail,
}: { }: {
project: ProjectListItem; project: ProjectListItem;
clients: ProjectClientOption[]; clients: ProjectClientOption[];
localization: ProjectsClientProps["localization"];
showDetail: boolean; showDetail: boolean;
}) { }) {
return ( return (
@@ -376,7 +392,7 @@ function ProjectActions({
</PendingLink> </PendingLink>
</Button> </Button>
) : null} ) : null}
<ProjectDialog mode="edit" project={project} clients={clients} iconOnly /> <ProjectDialog mode="edit" project={project} clients={clients} localization={localization} iconOnly />
{project.status !== "completed" ? ( {project.status !== "completed" ? (
<form action={completeProjectRecord}> <form action={completeProjectRecord}>
<input type="hidden" name="id" value={project.id} /> <input type="hidden" name="id" value={project.id} />
@@ -398,11 +414,13 @@ function ProjectDialog({
mode, mode,
project, project,
clients, clients,
localization,
iconOnly = false, iconOnly = false,
}: { }: {
mode: "create" | "edit"; mode: "create" | "edit";
project?: ProjectListItem; project?: ProjectListItem;
clients: ProjectClientOption[]; clients: ProjectClientOption[];
localization: ProjectsClientProps["localization"];
iconOnly?: boolean; iconOnly?: boolean;
}) { }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@@ -456,6 +474,7 @@ function ProjectDialog({
<ProjectFormFields <ProjectFormFields
project={project} project={project}
clients={clients} clients={clients}
localization={localization}
projectType={projectType} projectType={projectType}
onProjectTypeChange={setProjectType} onProjectTypeChange={setProjectType}
/> />
@@ -549,15 +568,6 @@ function CoverImageInput({ project }: { project?: ProjectListItem }) {
className="sr-only" className="sr-only"
onChange={handleFileChange} onChange={handleFileChange}
/> />
<div className="grid gap-2">
<Label htmlFor={`cover-alt-${project?.id || "new"}`}>Görsel alt metni</Label>
<Input
id={`cover-alt-${project?.id || "new"}`}
name="cover_image_alt"
defaultValue={project?.cover_image_alt || ""}
placeholder="Görseli kısaca açıkla"
/>
</div>
</div> </div>
); );
} }
@@ -565,11 +575,13 @@ function CoverImageInput({ project }: { project?: ProjectListItem }) {
function ProjectFormFields({ function ProjectFormFields({
project, project,
clients, clients,
localization,
projectType, projectType,
onProjectTypeChange, onProjectTypeChange,
}: { }: {
project?: ProjectListItem; project?: ProjectListItem;
clients: ProjectClientOption[]; clients: ProjectClientOption[];
localization: ProjectsClientProps["localization"];
projectType: ProjectListItem["type"]; projectType: ProjectListItem["type"];
onProjectTypeChange: (value: ProjectListItem["type"]) => void; onProjectTypeChange: (value: ProjectListItem["type"]) => void;
}) { }) {
@@ -577,16 +589,18 @@ function ProjectFormFields({
<div className="grid gap-4"> <div className="grid gap-4">
<CoverImageInput project={project} /> <CoverImageInput project={project} />
<div className="grid gap-2"> <LocalizedFields
<Label htmlFor={`name-${project?.id || "new"}`}>Proje adı</Label> idPrefix={`project-${project?.id || "new"}`}
<Input defaultLocale={localization.defaultLocale}
id={`name-${project?.id || "new"}`} locales={localization.locales}
name="name" fields={contentTranslationRegistry.project}
defaultValue={project?.name || ""} values={project?.translations}
required fallbackValues={{
placeholder="Örn. Marka web sitesi" name: project?.name,
/> description: project?.description,
</div> coverImageAlt: project?.cover_image_alt,
}}
/>
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
<div className="grid gap-2"> <div className="grid gap-2">
@@ -626,17 +640,6 @@ function ProjectFormFields({
</div> </div>
</div> </div>
<div className="grid gap-2">
<Label htmlFor={`description-${project?.id || "new"}`}>Açıklama</Label>
<Textarea
id={`description-${project?.id || "new"}`}
name="description"
defaultValue={project?.description || ""}
placeholder="Kapsam, hedef veya teslimat notları..."
rows={3}
/>
</div>
<div className="grid gap-4 md:grid-cols-3"> <div className="grid gap-4 md:grid-cols-3">
<div className="grid gap-2"> <div className="grid gap-2">
<Label>Durum</Label> <Label>Durum</Label>
@@ -742,7 +745,7 @@ function EmptyState({ hasQuery }: { hasQuery: boolean }) {
} }
function formatDate(value: string) { function formatDate(value: string) {
return new Intl.DateTimeFormat("tr-TR", { return new Intl.DateTimeFormat(getDocumentIntlLocale(), {
day: "2-digit", day: "2-digit",
month: "short", month: "short",
year: "numeric", year: "numeric",
@@ -750,7 +753,7 @@ function formatDate(value: string) {
} }
function formatCurrency(value: number) { function formatCurrency(value: number) {
return new Intl.NumberFormat("tr-TR", { return new Intl.NumberFormat(getDocumentIntlLocale(), {
style: "currency", style: "currency",
currency: "USD", currency: "USD",
maximumFractionDigits: 0, maximumFractionDigits: 0,
+19 -7
View File
@@ -1,6 +1,11 @@
"use server"; "use server";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { getSqliteConnection } from "@/server/db/client";
import {
ContentTranslationService,
parseContentTranslationsFromFormData,
} from "@/server/i18n/content";
import { cleanText, optionalDate, requiredText } from "@/server/web/form-data"; import { cleanText, optionalDate, requiredText } from "@/server/web/form-data";
import { requireFreelancerBackend } from "@/server/web/freelancer"; import { requireFreelancerBackend } from "@/server/web/freelancer";
@@ -16,11 +21,12 @@ function minutes(value: FormDataEntryValue | null): number | null {
return Number.isFinite(parsed) && parsed >= 0 ? Math.round(parsed) : null; return Number.isFinite(parsed) && parsed >= 0 ? Math.round(parsed) : null;
} }
function payload(formData: FormData) { function payload(formData: FormData, translations?: Record<string, Record<string, string | null>>, defaultLocale = "tr") {
const dueAt = optionalDate(formData.get("due_at")); const dueAt = optionalDate(formData.get("due_at"));
const localized = translations?.[defaultLocale] ?? {};
return { return {
title: requiredText(formData.get("title"), "Görev başlığı zorunludur."), title: localized.title ?? requiredText(formData.get("title"), "Görev başlığı zorunludur."),
description: cleanText(formData.get("description")), description: localized.description ?? cleanText(formData.get("description")),
status: enumValue(formData.get("status"), TASK_STATUSES, "todo"), status: enumValue(formData.get("status"), TASK_STATUSES, "todo"),
priority: enumValue(formData.get("priority"), TASK_PRIORITIES, "medium"), priority: enumValue(formData.get("priority"), TASK_PRIORITIES, "medium"),
clientId: cleanText(formData.get("client_id")), clientId: cleanText(formData.get("client_id")),
@@ -50,17 +56,23 @@ function revalidate(projectId?: string | null) {
export async function createTaskRecord(formData: FormData) { export async function createTaskRecord(formData: FormData) {
const { actor, service } = await requireFreelancerBackend(); const { actor, service } = await requireFreelancerBackend();
const value = completeRelations(payload(formData), service, actor); const i18n = new ContentTranslationService(getSqliteConnection().db);
service.createTask(actor, value); const context = i18n.getLocalizationContext(actor);
const translations = parseContentTranslationsFromFormData(formData, "task", context);
const value = completeRelations(payload(formData, translations, context.defaultLocale), service, actor);
service.createTask(actor, { ...value, translations });
revalidate(value.projectId); revalidate(value.projectId);
} }
export async function updateTaskRecord(formData: FormData) { export async function updateTaskRecord(formData: FormData) {
const { actor, service } = await requireFreelancerBackend(); const { actor, service } = await requireFreelancerBackend();
const i18n = new ContentTranslationService(getSqliteConnection().db);
const context = i18n.getLocalizationContext(actor);
const translations = parseContentTranslationsFromFormData(formData, "task", context);
const id = requiredText(formData.get("id"), "Görev kaydı bulunamadı."); const id = requiredText(formData.get("id"), "Görev kaydı bulunamadı.");
const value = completeRelations(payload(formData), service, actor); const value = completeRelations(payload(formData, translations, context.defaultLocale), service, actor);
const current = service.listTasks(actor).find((task) => task.id === id); const current = service.listTasks(actor).find((task) => task.id === id);
service.updateTask(actor, id, value); service.updateTask(actor, id, { ...value, translations });
revalidate(value.projectId); revalidate(value.projectId);
if (current?.projectId !== value.projectId) revalidate(current?.projectId); if (current?.projectId !== value.projectId) revalidate(current?.projectId);
} }
+48 -18
View File
@@ -1,37 +1,67 @@
import { TasksClient, type TaskListItem, type TaskRelationOption } from "@/app/(dashboard)/tasks/tasks-client"; import { TasksClient, type TaskListItem, type TaskRelationOption } from "@/app/(dashboard)/tasks/tasks-client";
import { getSqliteConnection } from "@/server/db/client";
import { ContentTranslationService, type ContentTranslationRow } from "@/server/i18n/content";
import { resolveRequestLocale } from "@/server/i18n/resolver";
import { requireFreelancerBackend } from "@/server/web/freelancer"; import { requireFreelancerBackend } from "@/server/web/freelancer";
export default async function TasksPage() { export default async function TasksPage() {
const locale = await resolveRequestLocale();
const { actor, service } = await requireFreelancerBackend(); const { actor, service } = await requireFreelancerBackend();
const content = new ContentTranslationService(getSqliteConnection().db);
const localization = content.getLocalizationContext(actor);
const taskRows = service.listTasks(actor); const taskRows = service.listTasks(actor);
const clientRows = service.listClients(actor); const clientRows = service.listClients(actor);
const projectRows = service.listProjects(actor); const projectRows = service.listProjects(actor);
const clientNames = new Map(clientRows.map((item) => [item.id, item.name])); const clientNames = new Map(clientRows.map((item) => [item.id, item.name]));
const projectNames = new Map(projectRows.map((item) => [item.id, item.name])); const projectTranslations = content.listBatch("project", projectRows.map((project) => project.id));
const resolvedProjects = projectRows.map((project) => content.resolveEntity("project", project, {
locale: locale.locale,
defaultLocale: localization.defaultLocale,
translations: projectTranslations.get(project.id) ?? [],
}));
const projectNames = new Map(resolvedProjects.map((item) => [item.id, item.name]));
const taskTranslations = content.listBatch("task", taskRows.map((task) => task.id));
const tasks: TaskListItem[] = taskRows const tasks: TaskListItem[] = taskRows
.filter((task) => task.status !== "cancelled") .filter((task) => task.status !== "cancelled")
.map((task) => ({ .map((task) => {
id: task.id, const translationRows = taskTranslations.get(task.id) ?? [];
title: task.title, const resolvedTask = content.resolveEntity("task", task, {
description: task.description, locale: locale.locale,
status: task.status as TaskListItem["status"], defaultLocale: localization.defaultLocale,
priority: task.priority, translations: translationRows,
due_at: task.dueAt?.toISOString() ?? null, });
estimated_minutes: task.estimatedMinutes, return {
actual_minutes: task.actualMinutes, id: task.id,
client_id: task.clientId, title: resolvedTask.title,
clientName: task.clientId ? clientNames.get(task.clientId) ?? null : null, description: resolvedTask.description,
project_id: task.projectId, status: task.status as TaskListItem["status"],
projectName: task.projectId ? projectNames.get(task.projectId) ?? null : null, priority: task.priority,
created_at: task.createdAt.toISOString(), due_at: task.dueAt?.toISOString() ?? null,
})); estimated_minutes: task.estimatedMinutes,
actual_minutes: task.actualMinutes,
client_id: task.clientId,
clientName: task.clientId ? clientNames.get(task.clientId) ?? null : null,
project_id: task.projectId,
projectName: task.projectId ? projectNames.get(task.projectId) ?? null : null,
created_at: task.createdAt.toISOString(),
translations: toLocalizedValues(translationRows),
};
});
const clients: TaskRelationOption[] = clientRows const clients: TaskRelationOption[] = clientRows
.filter((client) => client.status !== "archived") .filter((client) => client.status !== "archived")
.map(({ id, name }) => ({ id, name })); .map(({ id, name }) => ({ id, name }));
const projects: TaskRelationOption[] = projectRows const projects: TaskRelationOption[] = resolvedProjects
.filter((project) => project.status !== "cancelled") .filter((project) => project.status !== "cancelled")
.map(({ id, name, clientId }) => ({ id, name, client_id: clientId })); .map(({ id, name, clientId }) => ({ id, name, client_id: clientId }));
return <TasksClient tasks={tasks} clients={clients} projects={projects} />; return <TasksClient tasks={tasks} clients={clients} projects={projects} localization={localization} />;
}
function toLocalizedValues(rows: ContentTranslationRow[]) {
return rows.reduce<Record<string, Record<string, string>>>((result, row) => {
result[row.locale] = result[row.locale] ?? {};
result[row.locale][row.field] = row.value;
return result;
}, {});
} }
+45 -28
View File
@@ -1,12 +1,16 @@
"use client"; "use client";
import { getDocumentIntlLocale } from "@/lib/i18n/browser";
import { useTranslations } from "@/components/i18n/i18n-provider";
import { import {
createTaskRecord, createTaskRecord,
deleteTaskRecord, deleteTaskRecord,
updateTaskStatusRecord, updateTaskStatusRecord,
updateTaskRecord, updateTaskRecord,
} from "@/app/(dashboard)/tasks/actions"; } from "@/app/(dashboard)/tasks/actions";
import { Badge, Button, Card, CardContent, Input, Label, Textarea } from "poyraz-ui/atoms"; import { LocalizedFields, type LocalizedFieldLocale, type LocalizedFieldValues } from "@/components/i18n/localized-fields";
import { contentTranslationRegistry } from "@/lib/i18n/content";
import { Badge, Button, Card, CardContent, Input, Label } from "poyraz-ui/atoms";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -53,6 +57,7 @@ export type TaskListItem = {
project_id: string | null; project_id: string | null;
projectName: string | null; projectName: string | null;
created_at: string; created_at: string;
translations?: LocalizedFieldValues;
}; };
const statusLabels = { const statusLabels = {
@@ -79,9 +84,14 @@ type TasksClientProps = {
tasks: TaskListItem[]; tasks: TaskListItem[];
clients: TaskRelationOption[]; clients: TaskRelationOption[];
projects: TaskRelationOption[]; projects: TaskRelationOption[];
localization: {
defaultLocale: string;
locales: LocalizedFieldLocale[];
};
}; };
export function TasksClient({ tasks, clients, projects }: TasksClientProps) { export function TasksClient({ tasks, clients, projects, localization }: TasksClientProps) {
const t = useTranslations();
const [statusOverrides, setStatusOverrides] = useState< const [statusOverrides, setStatusOverrides] = useState<
Partial<Record<string, TaskListItem["status"]>> Partial<Record<string, TaskListItem["status"]>>
>({}); >({});
@@ -193,11 +203,11 @@ export function TasksClient({ tasks, clients, projects }: TasksClientProps) {
<div className="flex flex-col gap-4 border-b border-border pb-5 lg:flex-row lg:items-end lg:justify-between"> <div className="flex flex-col gap-4 border-b border-border pb-5 lg:flex-row lg:items-end lg:justify-between">
<div> <div>
<h1 className="text-3xl font-semibold tracking-normal text-foreground"> <h1 className="text-3xl font-semibold tracking-normal text-foreground">
Görevler {t("tasks.title")}
</h1> </h1>
</div> </div>
<TaskDialog mode="create" clients={clients} projects={projects} /> <TaskDialog mode="create" clients={clients} projects={projects} localization={localization} />
</div> </div>
<div className="grid gap-3 md:grid-cols-4"> <div className="grid gap-3 md:grid-cols-4">
@@ -266,6 +276,7 @@ export function TasksClient({ tasks, clients, projects }: TasksClientProps) {
tasks={filteredTasks} tasks={filteredTasks}
clients={clients} clients={clients}
projects={projects} projects={projects}
localization={localization}
pendingTaskIds={pendingTaskIds} pendingTaskIds={pendingTaskIds}
onTaskDelete={handleTaskDelete} onTaskDelete={handleTaskDelete}
onTaskStatusChange={handleTaskStatusChange} onTaskStatusChange={handleTaskStatusChange}
@@ -275,6 +286,7 @@ export function TasksClient({ tasks, clients, projects }: TasksClientProps) {
tasks={filteredTasks} tasks={filteredTasks}
clients={clients} clients={clients}
projects={projects} projects={projects}
localization={localization}
pendingTaskIds={pendingTaskIds} pendingTaskIds={pendingTaskIds}
onTaskDelete={handleTaskDelete} onTaskDelete={handleTaskDelete}
onTaskStatusChange={handleTaskStatusChange} onTaskStatusChange={handleTaskStatusChange}
@@ -293,6 +305,7 @@ function TaskList({
tasks, tasks,
clients, clients,
projects, projects,
localization,
pendingTaskIds, pendingTaskIds,
onTaskDelete, onTaskDelete,
onTaskStatusChange, onTaskStatusChange,
@@ -300,6 +313,7 @@ function TaskList({
tasks: TaskListItem[]; tasks: TaskListItem[];
clients: TaskRelationOption[]; clients: TaskRelationOption[];
projects: TaskRelationOption[]; projects: TaskRelationOption[];
localization: TasksClientProps["localization"];
pendingTaskIds: Set<string>; pendingTaskIds: Set<string>;
onTaskDelete: (taskId: string) => void; onTaskDelete: (taskId: string) => void;
onTaskStatusChange: (taskId: string, status: TaskListItem["status"]) => void; onTaskStatusChange: (taskId: string, status: TaskListItem["status"]) => void;
@@ -321,6 +335,7 @@ function TaskList({
task={task} task={task}
clients={clients} clients={clients}
projects={projects} projects={projects}
localization={localization}
isPending={pendingTaskIds.has(task.id)} isPending={pendingTaskIds.has(task.id)}
onTaskDelete={onTaskDelete} onTaskDelete={onTaskDelete}
onTaskStatusChange={onTaskStatusChange} onTaskStatusChange={onTaskStatusChange}
@@ -336,6 +351,7 @@ function TaskRow({
task, task,
clients, clients,
projects, projects,
localization,
isPending, isPending,
onTaskDelete, onTaskDelete,
onTaskStatusChange, onTaskStatusChange,
@@ -343,6 +359,7 @@ function TaskRow({
task: TaskListItem; task: TaskListItem;
clients: TaskRelationOption[]; clients: TaskRelationOption[];
projects: TaskRelationOption[]; projects: TaskRelationOption[];
localization: TasksClientProps["localization"];
isPending: boolean; isPending: boolean;
onTaskDelete: (taskId: string) => void; onTaskDelete: (taskId: string) => void;
onTaskStatusChange: (taskId: string, status: TaskListItem["status"]) => void; onTaskStatusChange: (taskId: string, status: TaskListItem["status"]) => void;
@@ -373,6 +390,7 @@ function TaskRow({
task={task} task={task}
clients={clients} clients={clients}
projects={projects} projects={projects}
localization={localization}
isPending={isPending} isPending={isPending}
onTaskDelete={onTaskDelete} onTaskDelete={onTaskDelete}
onTaskStatusChange={onTaskStatusChange} onTaskStatusChange={onTaskStatusChange}
@@ -385,6 +403,7 @@ function TaskKanban({
tasks, tasks,
clients, clients,
projects, projects,
localization,
pendingTaskIds, pendingTaskIds,
onTaskDelete, onTaskDelete,
onTaskStatusChange, onTaskStatusChange,
@@ -392,6 +411,7 @@ function TaskKanban({
tasks: TaskListItem[]; tasks: TaskListItem[];
clients: TaskRelationOption[]; clients: TaskRelationOption[];
projects: TaskRelationOption[]; projects: TaskRelationOption[];
localization: TasksClientProps["localization"];
pendingTaskIds: Set<string>; pendingTaskIds: Set<string>;
onTaskDelete: (taskId: string) => void; onTaskDelete: (taskId: string) => void;
onTaskStatusChange: (taskId: string, status: TaskListItem["status"]) => void; onTaskStatusChange: (taskId: string, status: TaskListItem["status"]) => void;
@@ -460,6 +480,7 @@ function TaskKanban({
task={task} task={task}
clients={clients} clients={clients}
projects={projects} projects={projects}
localization={localization}
compact compact
isPending={pendingTaskIds.has(task.id)} isPending={pendingTaskIds.has(task.id)}
onTaskDelete={onTaskDelete} onTaskDelete={onTaskDelete}
@@ -481,6 +502,7 @@ function TaskActions({
task, task,
clients, clients,
projects, projects,
localization,
compact = false, compact = false,
isPending, isPending,
onTaskDelete, onTaskDelete,
@@ -489,6 +511,7 @@ function TaskActions({
task: TaskListItem; task: TaskListItem;
clients: TaskRelationOption[]; clients: TaskRelationOption[];
projects: TaskRelationOption[]; projects: TaskRelationOption[];
localization: TasksClientProps["localization"];
compact?: boolean; compact?: boolean;
isPending: boolean; isPending: boolean;
onTaskDelete: (taskId: string) => void; onTaskDelete: (taskId: string) => void;
@@ -496,7 +519,7 @@ function TaskActions({
}) { }) {
return ( return (
<div className={compact ? "flex justify-end gap-1" : "flex justify-start gap-2 lg:justify-end"}> <div className={compact ? "flex justify-end gap-1" : "flex justify-start gap-2 lg:justify-end"}>
<TaskDialog mode="edit" task={task} clients={clients} projects={projects} /> <TaskDialog mode="edit" task={task} clients={clients} projects={projects} localization={localization} />
{task.status !== "done" ? ( {task.status !== "done" ? (
<Button effect="shine" <Button effect="shine"
type="button" type="button"
@@ -537,11 +560,13 @@ function TaskDialog({
task, task,
clients, clients,
projects, projects,
localization,
}: { }: {
mode: "create" | "edit"; mode: "create" | "edit";
task?: TaskListItem; task?: TaskListItem;
clients: TaskRelationOption[]; clients: TaskRelationOption[];
projects: TaskRelationOption[]; projects: TaskRelationOption[];
localization: TasksClientProps["localization"];
}) { }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
@@ -587,7 +612,7 @@ function TaskDialog({
</DialogHeader> </DialogHeader>
<div className="tiny-scrollbar min-h-0 flex-1 overflow-y-auto px-5 pb-5"> <div className="tiny-scrollbar min-h-0 flex-1 overflow-y-auto px-5 pb-5">
<TaskFormFields task={task} clients={clients} projects={projects} /> <TaskFormFields task={task} clients={clients} projects={projects} localization={localization} />
</div> </div>
<DialogFooter className="shrink-0 border-t border-border bg-background p-5"> <DialogFooter className="shrink-0 border-t border-border bg-background p-5">
@@ -610,10 +635,12 @@ function TaskFormFields({
task, task,
clients, clients,
projects, projects,
localization,
}: { }: {
task?: TaskListItem; task?: TaskListItem;
clients: TaskRelationOption[]; clients: TaskRelationOption[];
projects: TaskRelationOption[]; projects: TaskRelationOption[];
localization: TasksClientProps["localization"];
}) { }) {
const [clientId, setClientId] = useState(task?.client_id || "__none"); const [clientId, setClientId] = useState(task?.client_id || "__none");
const [projectId, setProjectId] = useState(task?.project_id || "__none"); const [projectId, setProjectId] = useState(task?.project_id || "__none");
@@ -650,27 +677,17 @@ function TaskFormFields({
return ( return (
<div className="grid gap-4"> <div className="grid gap-4">
<div className="grid gap-2"> <LocalizedFields
<Label htmlFor={`title-${task?.id || "new"}`}>Başlık</Label> idPrefix={`task-${task?.id || "new"}`}
<Input defaultLocale={localization.defaultLocale}
id={`title-${task?.id || "new"}`} locales={localization.locales}
name="title" fields={contentTranslationRegistry.task}
defaultValue={task?.title || ""} values={task?.translations}
required fallbackValues={{
placeholder="Örn. Ana sayfa wireframe revizyonu" title: task?.title,
/> description: task?.description,
</div> }}
/>
<div className="grid gap-2">
<Label htmlFor={`description-${task?.id || "new"}`}>Açıklama</Label>
<Textarea
id={`description-${task?.id || "new"}`}
name="description"
defaultValue={task?.description || ""}
rows={3}
placeholder="Kapsam, not veya teslim kriterleri..."
/>
</div>
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
<SelectField name="status" label="Durum" defaultValue={task?.status || "todo"}> <SelectField name="status" label="Durum" defaultValue={task?.status || "todo"}>
@@ -825,7 +842,7 @@ function isOverdue(task: TaskListItem) {
} }
function formatDateTime(value: string) { function formatDateTime(value: string) {
return new Intl.DateTimeFormat("tr-TR", { return new Intl.DateTimeFormat(getDocumentIntlLocale(), {
day: "2-digit", day: "2-digit",
month: "short", month: "short",
hour: "2-digit", hour: "2-digit",
+28
View File
@@ -0,0 +1,28 @@
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
const dataDir = path.join(process.cwd(), ".data", `i18n-phase5-smoke-${Date.now()}`);
const databasePath = path.join(dataDir, "neta.db");
const env = { ...process.env, DATA_DIR: dataDir, DATABASE_PATH: databasePath };
fs.mkdirSync(dataDir, { recursive: true });
execFileSync(process.execPath, ["scripts/migrate.mjs"], {
cwd: process.cwd(),
env,
stdio: "inherit",
});
execFileSync("./node_modules/.bin/tsc", ["-p", "tsconfig.i18n-phase5-smoke.json"], {
cwd: process.cwd(),
stdio: "inherit",
});
const serverOnlyStubDir = path.join(process.cwd(), ".next", "i18n-phase5-smoke-dist", "node_modules", "server-only");
fs.mkdirSync(serverOnlyStubDir, { recursive: true });
fs.writeFileSync(path.join(serverOnlyStubDir, "index.js"), "\n");
execFileSync(
process.execPath,
[path.join(".next", "i18n-phase5-smoke-dist", "scripts", "i18n-phase5-smoke.js")],
{ cwd: process.cwd(), env, stdio: "inherit" },
);
+118
View File
@@ -0,0 +1,118 @@
import assert from "node:assert/strict";
import { user } from "../server/db/schema";
import { getSqliteConnection } from "../server/db/client";
import type { DomainActor } from "../server/domain/actor";
import { DomainService } from "../server/services/domain";
import { ContentTranslationService } from "../server/i18n/content";
import { I18nService } from "../server/i18n/service";
const { db } = getSqliteConnection();
const owner: DomainActor = {
authUserId: "phase5-owner",
role: "freelancer",
clientId: null,
disabled: false,
};
db.insert(user)
.values({
id: owner.authUserId,
name: "Phase 5 Owner",
email: "phase5-owner@example.com",
emailVerified: true,
createdAt: new Date(),
updatedAt: new Date(),
})
.onConflictDoNothing()
.run();
const i18n = new I18nService(db);
i18n.listLocales(owner);
if (!i18n.listLocales(owner).some((locale) => locale.code === "fr")) {
i18n.createLocale(owner, {
code: "fr",
name: "French",
nativeName: "Français",
fallbackLocale: "en",
status: "active",
});
} else {
i18n.updateLocale(owner, "fr", { status: "active" });
}
const domain = new DomainService(db, (() => {
let next = 0;
return () => `phase5-${++next}`;
})());
const content = new ContentTranslationService(db);
const project = domain.createProject(owner, {
id: "phase5-project",
type: "side_project",
name: "Legacy fallback",
translations: {
tr: {
name: "Çok dilli proje",
description: "Türkçe açıklama",
coverImageAlt: "Türkçe kapak",
},
en: {
name: "Multilingual project",
description: "English description",
coverImageAlt: "English cover",
},
fr: {
name: "Projet multilingue",
description: "Description française",
coverImageAlt: "Couverture française",
},
},
});
assert.equal(project.name, "Çok dilli proje", "Default locale must be projected to legacy project.name.");
assert.equal(project.description, "Türkçe açıklama");
const projectTranslations = content.listEntityTranslations("project", project.id);
assert.equal(projectTranslations.filter((row) => row.field === "name").length, 3);
assert.equal(
content.resolveEntity("project", project, {
locale: "fr",
defaultLocale: "tr",
translations: projectTranslations,
}).name,
"Projet multilingue",
"Project must resolve according to selected locale.",
);
const section = domain.addPlanningSection(owner, {
id: "phase5-section",
projectId: project.id,
category: "overview",
title: "Legacy section",
translations: {
tr: { title: "Planlama", content: "Türkçe içerik" },
en: { title: "Planning", content: "English content" },
fr: { title: "Planification", content: "Contenu français" },
},
});
assert.equal(section.title, "Planlama");
const task = domain.createTask(owner, {
id: "phase5-task",
projectId: project.id,
title: "Legacy task",
translations: {
tr: { title: "Görev başlığı", description: "Türkçe görev" },
en: { title: "Task title", description: "English task" },
fr: { title: "Titre de tâche", description: "Tâche française" },
},
});
assert.equal(task.title, "Görev başlığı");
const batch = content.listBatch("task", [task.id]);
assert.equal(batch.get(task.id)?.some((row) => row.locale === "fr" && row.value === "Titre de tâche"), true);
domain.deleteTask(owner, task.id);
assert.equal(content.listEntityTranslations("task", task.id).length, 0, "Task delete must remove content translations.");
console.log("I18n phase 5 content translation smoke passed.");
+72 -11
View File
@@ -39,15 +39,19 @@ import {
calendarEventUpdateSchema, calendarEventUpdateSchema,
} from "../domain/validation"; } from "../domain/validation";
import { createDomainRepositories, type DomainRepositories } from "../repositories/domain"; import { createDomainRepositories, type DomainRepositories } from "../repositories/domain";
import { ContentTranslationService, projectBaseFromTranslations } from "../i18n/content";
import type { ContentTranslationInput } from "../../lib/i18n/content";
export class DomainService { export class DomainService {
readonly repositories: DomainRepositories; readonly repositories: DomainRepositories;
private readonly contentTranslations: ContentTranslationService;
constructor( constructor(
private readonly db: DomainDatabase, private readonly db: DomainDatabase,
private readonly id: IdGenerator = generateId, private readonly id: IdGenerator = generateId,
) { ) {
this.repositories = createDomainRepositories(db); this.repositories = createDomainRepositories(db);
this.contentTranslations = new ContentTranslationService(db);
} }
listClients(actor: DomainActor) { listClients(actor: DomainActor) {
@@ -112,17 +116,31 @@ export class DomainService {
createProject(actor: DomainActor, input: unknown) { createProject(actor: DomainActor, input: unknown) {
const scope = requireOwnerScope(actor); const scope = requireOwnerScope(actor);
const value = parseDomainInput(projectCreateSchema, input); const translations = this.getContentTranslations(input);
const defaultLocale = this.contentTranslations.getLocalizationContext(actor).defaultLocale;
const value = parseDomainInput(
projectCreateSchema,
translations ? projectBaseFromTranslations("project", input as Record<string, unknown>, translations, defaultLocale) : input,
);
this.assertProjectClient(scope, value.type, value.clientId); this.assertProjectClient(scope, value.type, value.clientId);
return this.repositories.projects.create(scope, { ...value, id: value.id ?? this.id() }); const id = value.id ?? this.id();
const created = this.repositories.projects.create(scope, { ...value, id });
this.contentTranslations.upsertEntityTranslations("project", created.id, translations);
return created;
} }
updateProject(actor: DomainActor, projectId: string, input: unknown) { updateProject(actor: DomainActor, projectId: string, input: unknown) {
const scope = requireOwnerScope(actor); const scope = requireOwnerScope(actor);
const current = this.repositories.projects.get(scope, projectId) ?? this.throwNotFound("Proje"); const current = this.repositories.projects.get(scope, projectId) ?? this.throwNotFound("Proje");
const value = parseDomainInput(projectUpdateSchema, input); const translations = this.getContentTranslations(input);
const defaultLocale = this.contentTranslations.getLocalizationContext(actor).defaultLocale;
const value = parseDomainInput(
projectUpdateSchema,
translations ? projectBaseFromTranslations("project", input as Record<string, unknown>, translations, defaultLocale) : input,
);
this.assertProjectClient(scope, value.type ?? current.type, value.clientId === undefined ? current.clientId : value.clientId); this.assertProjectClient(scope, value.type ?? current.type, value.clientId === undefined ? current.clientId : value.clientId);
const updated = this.repositories.projects.update(scope, projectId, value) ?? this.throwNotFound("Proje"); const updated = this.repositories.projects.update(scope, projectId, value) ?? this.throwNotFound("Proje");
this.contentTranslations.upsertEntityTranslations("project", updated.id, translations);
if ( if (
updated.progressType === "auto" updated.progressType === "auto"
&& (value.progressType === "auto" || value.progress !== undefined) && (value.progressType === "auto" || value.progress !== undefined)
@@ -134,7 +152,14 @@ export class DomainService {
} }
deleteProject(actor: DomainActor, id: string) { deleteProject(actor: DomainActor, id: string) {
return this.repositories.projects.remove(requireOwnerScope(actor), id) ?? this.throwNotFound("Proje"); const scope = requireOwnerScope(actor);
const sectionIds = this.repositories.planning.list(scope, id).map((section) => section.id);
const deleted = this.repositories.projects.remove(scope, id) ?? this.throwNotFound("Proje");
this.contentTranslations.deleteEntityTranslations("project", id);
for (const sectionId of sectionIds) {
this.contentTranslations.deleteEntityTranslations("planning_section", sectionId);
}
return deleted;
} }
listTasks(actor: DomainActor, projectId?: string) { listTasks(actor: DomainActor, projectId?: string) {
@@ -151,9 +176,15 @@ export class DomainService {
createTask(actor: DomainActor, input: unknown) { createTask(actor: DomainActor, input: unknown) {
const scope = requireOwnerScope(actor); const scope = requireOwnerScope(actor);
const value = parseDomainInput(taskCreateSchema, input); const translations = this.getContentTranslations(input);
const defaultLocale = this.contentTranslations.getLocalizationContext(actor).defaultLocale;
const value = parseDomainInput(
taskCreateSchema,
translations ? projectBaseFromTranslations("task", input as Record<string, unknown>, translations, defaultLocale) : input,
);
this.assertTaskRelations(scope, value); this.assertTaskRelations(scope, value);
const task = this.repositories.tasks.create(scope, { ...value, id: value.id ?? this.id() }); const task = this.repositories.tasks.create(scope, { ...value, id: value.id ?? this.id() });
this.contentTranslations.upsertEntityTranslations("task", task.id, translations);
if (task.projectId) this.recalculateProjectProgress(scope, task.projectId); if (task.projectId) this.recalculateProjectProgress(scope, task.projectId);
return task; return task;
} }
@@ -161,10 +192,16 @@ export class DomainService {
updateTask(actor: DomainActor, taskId: string, input: unknown) { updateTask(actor: DomainActor, taskId: string, input: unknown) {
const scope = requireOwnerScope(actor); const scope = requireOwnerScope(actor);
const current = this.repositories.tasks.get(scope, taskId) ?? this.throwNotFound("Görev"); const current = this.repositories.tasks.get(scope, taskId) ?? this.throwNotFound("Görev");
const value = parseDomainInput(taskUpdateSchema, input); const translations = this.getContentTranslations(input);
const defaultLocale = this.contentTranslations.getLocalizationContext(actor).defaultLocale;
const value = parseDomainInput(
taskUpdateSchema,
translations ? projectBaseFromTranslations("task", input as Record<string, unknown>, translations, defaultLocale) : input,
);
const merged = { ...current, ...value }; const merged = { ...current, ...value };
this.assertTaskRelations(scope, merged); this.assertTaskRelations(scope, merged);
const task = this.repositories.tasks.update(scope, taskId, value) ?? this.throwNotFound("Görev"); const task = this.repositories.tasks.update(scope, taskId, value) ?? this.throwNotFound("Görev");
this.contentTranslations.upsertEntityTranslations("task", task.id, translations);
if (current.projectId) this.recalculateProjectProgress(scope, current.projectId); if (current.projectId) this.recalculateProjectProgress(scope, current.projectId);
if (task.projectId && task.projectId !== current.projectId) this.recalculateProjectProgress(scope, task.projectId); if (task.projectId && task.projectId !== current.projectId) this.recalculateProjectProgress(scope, task.projectId);
return task; return task;
@@ -173,6 +210,7 @@ export class DomainService {
deleteTask(actor: DomainActor, taskId: string) { deleteTask(actor: DomainActor, taskId: string) {
const scope = requireOwnerScope(actor); const scope = requireOwnerScope(actor);
const task = this.repositories.tasks.remove(scope, taskId) ?? this.throwNotFound("Görev"); const task = this.repositories.tasks.remove(scope, taskId) ?? this.throwNotFound("Görev");
this.contentTranslations.deleteEntityTranslations("task", taskId);
if (task.projectId) this.recalculateProjectProgress(scope, task.projectId); if (task.projectId) this.recalculateProjectProgress(scope, task.projectId);
return task; return task;
} }
@@ -255,20 +293,36 @@ export class DomainService {
addPlanningSection(actor: DomainActor, input: unknown) { addPlanningSection(actor: DomainActor, input: unknown) {
const scope = requireOwnerScope(actor); const scope = requireOwnerScope(actor);
const value = parseDomainInput(planningSectionCreateSchema, input); const translations = this.getContentTranslations(input);
const defaultLocale = this.contentTranslations.getLocalizationContext(actor).defaultLocale;
const value = parseDomainInput(
planningSectionCreateSchema,
translations ? projectBaseFromTranslations("planning_section", input as Record<string, unknown>, translations, defaultLocale) : input,
);
this.requireOwnedProject(scope, value.projectId); this.requireOwnedProject(scope, value.projectId);
return this.repositories.planning.create(scope, { ...value, id: value.id ?? this.id() }); const section = this.repositories.planning.create(scope, { ...value, id: value.id ?? this.id() });
this.contentTranslations.upsertEntityTranslations("planning_section", section.id, translations);
return section;
} }
updatePlanningSection(actor: DomainActor, sectionId: string, input: unknown) { updatePlanningSection(actor: DomainActor, sectionId: string, input: unknown) {
const scope = requireOwnerScope(actor); const scope = requireOwnerScope(actor);
if (!this.repositories.planning.get(scope, sectionId)) throw notFound("Planlama bölümü"); if (!this.repositories.planning.get(scope, sectionId)) throw notFound("Planlama bölümü");
const value = parseDomainInput(planningSectionUpdateSchema, input); const translations = this.getContentTranslations(input);
return this.repositories.planning.update(scope, sectionId, value) ?? this.throwNotFound("Planlama bölümü"); const defaultLocale = this.contentTranslations.getLocalizationContext(actor).defaultLocale;
const value = parseDomainInput(
planningSectionUpdateSchema,
translations ? projectBaseFromTranslations("planning_section", input as Record<string, unknown>, translations, defaultLocale) : input,
);
const section = this.repositories.planning.update(scope, sectionId, value) ?? this.throwNotFound("Planlama bölümü");
this.contentTranslations.upsertEntityTranslations("planning_section", section.id, translations);
return section;
} }
deletePlanningSection(actor: DomainActor, sectionId: string) { deletePlanningSection(actor: DomainActor, sectionId: string) {
return this.repositories.planning.remove(requireOwnerScope(actor), sectionId) ?? this.throwNotFound("Planlama bölümü"); const section = this.repositories.planning.remove(requireOwnerScope(actor), sectionId) ?? this.throwNotFound("Planlama bölümü");
this.contentTranslations.deleteEntityTranslations("planning_section", sectionId);
return section;
} }
listPlanningSections(actor: DomainActor, projectId: string) { listPlanningSections(actor: DomainActor, projectId: string) {
@@ -680,4 +734,11 @@ export class DomainService {
private throwNotFound(resource: string): never { private throwNotFound(resource: string): never {
throw notFound(resource); throw notFound(resource);
} }
private getContentTranslations(input: unknown): ContentTranslationInput | undefined {
if (!input || typeof input !== "object") return undefined;
const translations = (input as { translations?: unknown }).translations;
if (!translations || typeof translations !== "object") return undefined;
return translations as ContentTranslationInput;
}
} }
+23
View File
@@ -0,0 +1,23 @@
{
"extends": "./tsconfig.i18n-phase3-smoke.json",
"compilerOptions": {
"outDir": ".next/i18n-phase5-smoke-dist"
},
"include": [
"scripts/i18n-phase5-smoke.ts",
"lib/i18n/**/*.ts",
"locales/**/*.ts",
"server/auth/types.ts",
"server/db/**/*.ts",
"server/domain/**/*.ts",
"server/i18n/catalog.ts",
"server/i18n/content.ts",
"server/i18n/locale.ts",
"server/i18n/service.ts",
"server/i18n/translator.ts",
"server/repositories/domain.ts",
"server/repositories/i18n.ts",
"server/services/domain.ts"
],
"exclude": ["node_modules"]
}