diff --git a/app/(dashboard)/clients/[id]/client-detail-client.tsx b/app/(dashboard)/clients/[id]/client-detail-client.tsx index d018bd8..c99a5a0 100644 --- a/app/(dashboard)/clients/[id]/client-detail-client.tsx +++ b/app/(dashboard)/clients/[id]/client-detail-client.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; 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 { 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"; @@ -20,6 +20,7 @@ export type ClientDetailData = { status: string; notes: string | null; client_auth_id: string | null; + portal_locale: string; }; export type ClientActivity = { @@ -31,9 +32,18 @@ export type ClientActivity = { 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 [openDialog, setOpenDialog] = useState(false); + const [portalLocale, setPortalLocale] = useState(client.portal_locale); const getActivityIcon = (type: string) => { switch (type) { @@ -71,19 +81,21 @@ export function ClientDetailClient({ client, activities }: { client: ClientDetai e.preventDefault(); const formData = new FormData(e.currentTarget); const email = formData.get("email") as string; + const locale = formData.get("locale") as string; setIsCreatingUser(true); try { const res = await fetch("/api/create-client-user", { method: "POST", 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(); if (!res.ok || data.error) { throw new Error(data.error || "Kullanıcı oluşturulamadı."); } setInvitationUrl(data.invitation.invitationUrl); + setPortalLocale(data.invitation.locale ?? locale); toast.success("Güvenli portal daveti oluşturuldu."); } catch (error: unknown) { 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 (
{/* Header Info */} @@ -129,6 +158,21 @@ export function ClientDetailClient({ client, activities }: { client: ClientDetai
+
+ + +
{invitationUrl ? (
@@ -163,9 +207,23 @@ export function ClientDetailClient({ client, activities }: { client: ClientDetai )} {client.client_auth_id && ( - - Portal Aktif - + <> + + Portal Aktif + + + )}
@@ -290,7 +348,7 @@ export function ClientDetailClient({ client, activities }: { client: ClientDetai {getActivityBadge(activity.type)} {activity.content && (

{activity.content}

diff --git a/app/(dashboard)/clients/[id]/page.tsx b/app/(dashboard)/clients/[id]/page.tsx index 4c69f49..7d5c67d 100644 --- a/app/(dashboard)/clients/[id]/page.tsx +++ b/app/(dashboard)/clients/[id]/page.tsx @@ -1,11 +1,16 @@ import { notFound } from "next/navigation"; import { ClientDetailClient, type ClientDetailData, type ClientActivity } from "./client-detail-client"; +import { getSqliteConnection } from "@/server/db/client"; import { DomainError } from "@/server/domain/errors"; +import { I18nService } from "@/server/i18n/service"; import { requireFreelancerBackend } from "@/server/web/freelancer"; export default async function ClientDetailPage({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; 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[] }; try { @@ -21,6 +26,7 @@ export default async function ClientDetailPage({ params }: { params: Promise<{ i status: row.status, notes: row.notes, client_auth_id: row.authUserId, + portal_locale: row.portalLocale ?? defaultLocale, }; const activities: ClientActivity[] = service.listClientActivities(actor, id).map((activity) => ({ id: activity.id, @@ -37,5 +43,5 @@ export default async function ClientDetailPage({ params }: { params: Promise<{ i throw error; } - return ; + return ; } diff --git a/app/(dashboard)/clients/clients-client.tsx b/app/(dashboard)/clients/clients-client.tsx index 37e0034..cd36a74 100644 --- a/app/(dashboard)/clients/clients-client.tsx +++ b/app/(dashboard)/clients/clients-client.tsx @@ -1,5 +1,7 @@ "use client"; +import { getDocumentIntlLocale } from "@/lib/i18n/browser"; +import { useTranslations } from "@/components/i18n/i18n-provider"; import { createClientRecord, updateClientRecord, @@ -40,7 +42,7 @@ import { import Link from "next/link"; import { useState } from "react"; 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 { StatCard } from "@/components/system/stat-card"; @@ -88,6 +90,7 @@ export function ClientsClient({ totalRevenue, activeCount, }: ClientsClientProps) { + const t = useTranslations(); const [query, setQuery] = useState(""); const normalizedQuery = query.trim().toLowerCase(); @@ -154,7 +157,7 @@ export function ClientsClient({

- CRM & Müşteriler + {t("clients.title")}

@@ -326,7 +329,7 @@ function DraggableClientCard({
- {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() })}
)} @@ -384,7 +387,7 @@ function ClientRow({ client }: { client: ClientListItem }) { {client.next_follow_up_date ? (
- {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() })}
) : ( - @@ -635,5 +638,5 @@ function formatPhone(input: string) { } 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); } diff --git a/app/(dashboard)/projects/[id]/page.tsx b/app/(dashboard)/projects/[id]/page.tsx index eaa509d..1b50e6f 100644 --- a/app/(dashboard)/projects/[id]/page.tsx +++ b/app/(dashboard)/projects/[id]/page.tsx @@ -7,12 +7,18 @@ import { type ProjectPlanningSectionItem, type ProjectRevisionItem, } from "@/app/(dashboard)/projects/[id]/project-detail-client"; +import { getSqliteConnection } from "@/server/db/client"; 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"; export default async function ProjectDetailPage({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; + const locale = await resolveRequestLocale(); const { actor, service } = await requireFreelancerBackend(); + const content = new ContentTranslationService(getSqliteConnection().db); + const localization = content.getLocalizationContext(actor); let data: { project: ProjectDetail; @@ -23,14 +29,20 @@ export default async function ProjectDetailPage({ params }: { params: Promise<{ }; try { 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 project: ProjectDetail = { id: row.id, client_id: row.clientId, clientName: client?.name ?? null, - name: row.name, + name: resolvedProject.name, type: row.type, - description: row.description, + description: resolvedProject.description, status: row.status, start_date: row.startDate, due_date: row.dueDate, @@ -39,27 +51,50 @@ export default async function ProjectDetailPage({ params }: { params: Promise<{ progress: row.progress, progress_type: row.progressType, revision_quota: row.revisionQuota, - cover_image_alt: row.coverImageAlt, + cover_image_alt: resolvedProject.coverImageAlt, coverImageUrl: row.legacyCoverImagePath, + translations: toLocalizedValues(projectTranslationRows), }; - const sections: ProjectPlanningSectionItem[] = service.listPlanningSections(actor, id).map((section) => ({ - id: section.id, - project_id: section.projectId, - category: section.category, - title: section.title, - content: section.content, - sort_order: section.sortOrder, - })); - const tasks: ProjectDetailTaskItem[] = service.listTasks(actor, id) + const sectionRows = service.listPlanningSections(actor, id); + const sectionTranslations = content.listBatch("planning_section", sectionRows.map((section) => section.id)); + const sections: ProjectPlanningSectionItem[] = sectionRows.map((section) => { + const translationRows = sectionTranslations.get(section.id) ?? []; + const resolvedSection = content.resolveEntity("planning_section", section, { + locale: locale.locale, + defaultLocale: localization.defaultLocale, + translations: translationRows, + }); + 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") - .map((task) => ({ - id: task.id, - title: task.title, - status: task.status as ProjectDetailTaskItem["status"], - priority: task.priority, - due_at: task.dueAt?.toISOString() ?? null, - is_public_to_client: task.isPublicToClient, - })); + .map((task) => { + const translationRows = taskTranslations.get(task.id) ?? []; + const resolvedTask = content.resolveEntity("task", task, { + locale: locale.locale, + defaultLocale: localization.defaultLocale, + translations: translationRows, + }); + 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) .filter((transaction) => transaction.projectId === id) .map((transaction) => ({ @@ -92,6 +127,15 @@ export default async function ProjectDetailPage({ params }: { params: Promise<{ tasks={data.tasks} financeTransactions={data.financeTransactions} revisions={data.revisions} + localization={localization} /> ); } + +function toLocalizedValues(rows: ContentTranslationRow[]) { + return rows.reduce>>((result, row) => { + result[row.locale] = result[row.locale] ?? {}; + result[row.locale][row.field] = row.value; + return result; + }, {}); +} diff --git a/app/(dashboard)/projects/[id]/project-detail-client.tsx b/app/(dashboard)/projects/[id]/project-detail-client.tsx index 84d0d4a..1cf0eaa 100644 --- a/app/(dashboard)/projects/[id]/project-detail-client.tsx +++ b/app/(dashboard)/projects/[id]/project-detail-client.tsx @@ -1,5 +1,6 @@ "use client"; +import { getDocumentIntlLocale } from "@/lib/i18n/browser"; import { completeProjectRecord, createProjectPlanningSectionRecord, @@ -10,9 +11,11 @@ import { createTaskRecord, updateTaskStatusRecord, } from "@/app/(dashboard)/tasks/actions"; +import { LocalizedFields, type LocalizedFieldLocale, type LocalizedFieldValues } from "@/components/i18n/localized-fields"; import { PendingLink } from "@/components/ui/pending-link"; import { PendingSubmitButton } from "@/components/ui/pending-submit-button"; -import { 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 { Dialog, DialogContent, @@ -66,6 +69,7 @@ export type ProjectDetail = { revision_quota: number; cover_image_alt: string | null; coverImageUrl: string | null; + translations?: LocalizedFieldValues; }; export type ProjectPlanningSectionItem = { @@ -85,6 +89,7 @@ export type ProjectPlanningSectionItem = { title: string; content: string | null; sort_order: number; + translations?: LocalizedFieldValues; }; export type ProjectDetailTaskItem = { @@ -94,6 +99,7 @@ export type ProjectDetailTaskItem = { priority: "low" | "medium" | "high" | "urgent"; due_at: string | null; is_public_to_client: boolean; + translations?: LocalizedFieldValues; }; export type ProjectFinanceItem = { @@ -120,6 +126,10 @@ type ProjectDetailClientProps = { tasks: ProjectDetailTaskItem[]; financeTransactions: ProjectFinanceItem[]; revisions: ProjectRevisionItem[]; + localization: { + defaultLocale: string; + locales: LocalizedFieldLocale[]; + }; }; const typeLabels = { @@ -185,6 +195,7 @@ export function ProjectDetailClient({ tasks, financeTransactions, revisions, + localization, }: ProjectDetailClientProps) { const [activeTab, setActiveTab] = useState<"planning" | "design" | "tasks" | "finance" | "revisions">( "planning", @@ -227,7 +238,7 @@ export function ProjectDetailClient({
- + {project.status !== "completed" ? (
@@ -329,6 +340,7 @@ export function ProjectDetailClient({ description="Problem, amaç, hedef kitle, kapsam ve proje notlarını burada tut." sections={planningSections} defaultCategory="overview" + localization={localization} /> ) : null} @@ -339,11 +351,12 @@ export function ProjectDetailClient({ description="Renk paleti, tipografi, görsel dil ve asset notlarını proje kaynağına bağla." sections={designSections} defaultCategory="design_system" + localization={localization} /> ) : null} {activeTab === "tasks" ? ( - + ) : null} {activeTab === "finance" ? : null} {activeTab === "revisions" ? : null} @@ -391,7 +404,7 @@ function RevisionsPanel({
- {new Date(rev.created_at).toLocaleString('tr-TR')} + {new Date(rev.created_at).toLocaleString(getDocumentIntlLocale())}
@@ -505,11 +526,13 @@ function SectionDialog({ mode, defaultCategory, section, + localization, }: { projectId: string; mode: "create" | "edit"; defaultCategory?: ProjectPlanningSectionItem["category"]; section?: ProjectPlanningSectionItem; + localization: ProjectDetailClientProps["localization"]; }) { const [open, setOpen] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); @@ -569,26 +592,17 @@ function SectionDialog({
-
- - -
-
- -