feat(i18n): integrate multilingual support into clients views and forms
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { cleanText, optionalDate, requiredText } from "@/server/web/form-data";
|
||||
import { requireFreelancerBackend } from "@/server/web/freelancer";
|
||||
import { parseContentTranslationsFromFormData } from "@/server/i18n/content";
|
||||
|
||||
const ACTIVITY_TYPES = ["note", "call", "meeting", "email"] as const;
|
||||
|
||||
@@ -13,12 +14,18 @@ export async function addClientActivity(clientId: string, formData: FormData) {
|
||||
? rawType as (typeof ACTIVITY_TYPES)[number]
|
||||
: "note";
|
||||
|
||||
const context = service.contentTranslations.getLocalizationContext(actor);
|
||||
const translations = parseContentTranslationsFromFormData(formData, "client_activity", context);
|
||||
const defaultTitle = translations?.[context.defaultLocale]?.title ?? "";
|
||||
const defaultContent = translations?.[context.defaultLocale]?.content ?? "";
|
||||
|
||||
service.addClientActivity(actor, {
|
||||
clientId,
|
||||
type,
|
||||
title: requiredText(formData.get("title"), "Aktivite başlığı zorunludur."),
|
||||
content: cleanText(formData.get("content")),
|
||||
title: defaultTitle || requiredText(formData.get("title"), "Aktivite başlığı zorunludur."),
|
||||
content: defaultContent || cleanText(formData.get("content")),
|
||||
activityDate: optionalDate(formData.get("activity_date")) ?? new Date(),
|
||||
translations,
|
||||
});
|
||||
|
||||
revalidatePath(`/clients/${clientId}`);
|
||||
|
||||
@@ -4,10 +4,11 @@ import { useState } from "react";
|
||||
import { format } from "date-fns";
|
||||
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 { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogFooter, Select, SelectTrigger, SelectValue, SelectContent, SelectItem, DialogDescription, Tabs, TabsList, TabsTrigger, TabsContent } from "poyraz-ui/molecules";
|
||||
import { Phone, Mail, ExternalLink, Calendar, Plus, MessageSquare, UserPlus, Loader2, Copy } from "lucide-react";
|
||||
import { toast } from "poyraz-ui/molecules";
|
||||
import { addClientActivity } from "./actions";
|
||||
import { useTranslations } from "@/components/i18n/i18n-provider";
|
||||
|
||||
export type ClientDetailData = {
|
||||
id: string;
|
||||
@@ -30,6 +31,7 @@ export type ClientActivity = {
|
||||
content: string | null;
|
||||
activity_date: string;
|
||||
created_at: string;
|
||||
translations?: Record<string, Record<string, string>>;
|
||||
};
|
||||
|
||||
export function ClientDetailClient({
|
||||
@@ -44,6 +46,7 @@ export function ClientDetailClient({
|
||||
const [isAddingActivity, setIsAddingActivity] = useState(false);
|
||||
const [openDialog, setOpenDialog] = useState(false);
|
||||
const [portalLocale, setPortalLocale] = useState(client.portal_locale);
|
||||
const t = useTranslations();
|
||||
|
||||
const getActivityIcon = (type: string) => {
|
||||
switch (type) {
|
||||
@@ -56,10 +59,10 @@ export function ClientDetailClient({
|
||||
|
||||
const getActivityBadge = (type: string) => {
|
||||
switch (type) {
|
||||
case "call": return <Badge className="bg-blue-500/10 text-blue-500 border-blue-500/20">Arama</Badge>;
|
||||
case "meeting": return <Badge className="bg-emerald-500/10 text-emerald-500 border-emerald-500/20">Toplantı</Badge>;
|
||||
case "email": return <Badge className="bg-amber-500/10 text-amber-500 border-amber-500/20">E-posta</Badge>;
|
||||
default: return <Badge variant="secondary">Not</Badge>;
|
||||
case "call": return <Badge className="bg-blue-500/10 text-blue-500 border-blue-500/20">{t("clients.detail.activityTypes.call")}</Badge>;
|
||||
case "meeting": return <Badge className="bg-emerald-500/10 text-emerald-500 border-emerald-500/20">{t("clients.detail.activityTypes.meeting")}</Badge>;
|
||||
case "email": return <Badge className="bg-amber-500/10 text-amber-500 border-amber-500/20">{t("clients.detail.activityTypes.email")}</Badge>;
|
||||
default: return <Badge variant="secondary">{t("clients.detail.activityTypes.note")}</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -142,7 +145,7 @@ export function ClientDetailClient({
|
||||
<Dialog open={createUserOpen} onOpenChange={setCreateUserOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button effect="shine" variant="secondary" size="sm" className="gap-2 ml-2 border-dashed">
|
||||
<UserPlus className="h-4 w-4" /> Portal Hesabı Aç
|
||||
<UserPlus className="h-4 w-4" /> {t("clients.detail.createPortalAccount")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
@@ -155,11 +158,11 @@ export function ClientDetailClient({
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">E-posta Adresi</Label>
|
||||
<Label htmlFor="email">{t("clients.detail.email")}</Label>
|
||||
<Input id="email" name="email" type="email" required defaultValue={client.email || ""} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Portal dili</Label>
|
||||
<Label>{t("clients.detail.portalLocale")}</Label>
|
||||
<Select name="locale" defaultValue={portalLocale}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Dil seç" />
|
||||
@@ -209,7 +212,7 @@ export function ClientDetailClient({
|
||||
{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
|
||||
<UserPlus className="h-3.5 w-3.5" /> {t("clients.detail.portalActive")}
|
||||
</Badge>
|
||||
<Select value={portalLocale} onValueChange={handlePortalLocaleChange}>
|
||||
<SelectTrigger className="h-9 w-36">
|
||||
@@ -233,7 +236,7 @@ export function ClientDetailClient({
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardContent className="p-5 space-y-4">
|
||||
<h3 className="font-semibold text-foreground">İletişim Bilgileri</h3>
|
||||
<h3 className="font-semibold text-foreground">{t("clients.detail.contactInfo")}</h3>
|
||||
<div className="space-y-3 text-sm">
|
||||
{client.email ? (
|
||||
<div className="flex items-center gap-3 text-muted-foreground">
|
||||
@@ -264,7 +267,7 @@ export function ClientDetailClient({
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-5 space-y-4">
|
||||
<h3 className="font-semibold text-foreground">Genel Notlar</h3>
|
||||
<h3 className="font-semibold text-foreground">{t("clients.form.notes")}</h3>
|
||||
{client.notes ? (
|
||||
<p className="text-sm text-muted-foreground whitespace-pre-wrap">{client.notes}</p>
|
||||
) : (
|
||||
@@ -284,24 +287,24 @@ export function ClientDetailClient({
|
||||
<Dialog open={openDialog} onOpenChange={setOpenDialog}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="default" effect="shine" size="sm" className="gap-2">
|
||||
<Plus className="h-4 w-4" /> Aktivite Ekle
|
||||
<Plus className="h-4 w-4" /> {t("clients.detail.addActivity")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<form action={handleAddActivity} className="space-y-4">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Yeni Aktivite Ekle</DialogTitle>
|
||||
<DialogTitle>{t("clients.detail.addActivity")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>Tip</Label>
|
||||
<Label>{t("clients.detail.activityType")}</Label>
|
||||
<Select name="type" defaultValue="note">
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="note">Not</SelectItem>
|
||||
<SelectItem value="call">Arama</SelectItem>
|
||||
<SelectItem value="meeting">Toplantı</SelectItem>
|
||||
<SelectItem value="email">E-posta</SelectItem>
|
||||
<SelectItem value="note">{t("clients.detail.activityTypes.note")}</SelectItem>
|
||||
<SelectItem value="call">{t("clients.detail.activityTypes.call")}</SelectItem>
|
||||
<SelectItem value="meeting">{t("clients.detail.activityTypes.meeting")}</SelectItem>
|
||||
<SelectItem value="email">{t("clients.detail.activityTypes.email")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -310,17 +313,32 @@ export function ClientDetailClient({
|
||||
<Input name="title" required placeholder="Aktivite özeti" />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Tarih</Label>
|
||||
<Label>{t("clients.detail.activityDate")}</Label>
|
||||
<Input name="activity_date" type="datetime-local" required defaultValue={new Date().toISOString().slice(0, 16)} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>İçerik (Opsiyonel)</Label>
|
||||
<Textarea name="content" rows={4} placeholder="Görüşme detayları..." />
|
||||
</div>
|
||||
<Tabs defaultValue={locales[0].code}>
|
||||
<TabsList className="mb-4">
|
||||
{locales.map((locale) => (
|
||||
<TabsTrigger key={locale.code} value={locale.code}>{locale.name}</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
{locales.map((locale) => (
|
||||
<TabsContent key={locale.code} value={locale.code} className="space-y-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>{t("clients.detail.activityTitle")} ({locale.code})</Label>
|
||||
<Input name={`i18n.${locale.code}.title`} required={locale.code === locales[0].code} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>{t("clients.detail.activityContent")} ({locale.code})</Label>
|
||||
<Textarea name={`i18n.${locale.code}.content`} rows={4} />
|
||||
</div>
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="default" effect="shine" type="submit" disabled={isAddingActivity}>
|
||||
{isAddingActivity ? "Ekleniyor..." : "Ekle"}
|
||||
{isAddingActivity ? "..." : t("clients.detail.saveActivity")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
@@ -331,7 +349,7 @@ export function ClientDetailClient({
|
||||
<div className="space-y-6 relative before:absolute before:inset-0 before:ml-5 before:-translate-x-px md:before:mx-auto md:before:translate-x-0 before:h-full before:w-0.5 before:bg-gradient-to-b before:from-transparent before:via-border before:to-transparent">
|
||||
{activities.length === 0 ? (
|
||||
<div className="text-center py-10">
|
||||
<p className="text-muted-foreground">Henüz kaydedilmiş bir aktivite yok.</p>
|
||||
<p className="text-muted-foreground">{t("clients.detail.emptyActivities")}</p>
|
||||
</div>
|
||||
) : (
|
||||
activities.map((activity) => (
|
||||
@@ -344,14 +362,14 @@ export function ClientDetailClient({
|
||||
<Card className="w-[calc(100%-4rem)] md:w-[calc(50%-2.5rem)] hover:border-primary/50 transition-colors">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<h4 className="font-semibold text-foreground">{activity.title}</h4>
|
||||
<h4 className="font-semibold text-foreground">{activity.translations?.[locales[0].code]?.title ?? activity.title}</h4>
|
||||
{getActivityBadge(activity.type)}
|
||||
</div>
|
||||
<time className="text-xs text-muted-foreground block mb-2 font-medium">
|
||||
{format(new Date(activity.activity_date), "d MMM yyyy, HH:mm", { locale: getDocumentDateFnsLocale() })}
|
||||
</time>
|
||||
{activity.content && (
|
||||
<p className="text-sm text-muted-foreground whitespace-pre-wrap">{activity.content}</p>
|
||||
{(activity.translations?.[locales[0].code]?.content ?? activity.content) && (
|
||||
<p className="text-sm text-muted-foreground whitespace-pre-wrap">{activity.translations?.[locales[0].code]?.content ?? activity.content}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -4,17 +4,36 @@ import { getSqliteConnection } from "@/server/db/client";
|
||||
import { DomainError } from "@/server/domain/errors";
|
||||
import { I18nService } from "@/server/i18n/service";
|
||||
import { requireFreelancerBackend } from "@/server/web/freelancer";
|
||||
import { resolveFreelancerLocale } from "@/server/i18n/resolver";
|
||||
import { getClientI18nPayload } from "@/server/i18n/translator";
|
||||
import { I18nProvider } from "@/components/i18n/i18n-provider";
|
||||
import type { ContentTranslationRow } from "@/server/i18n/content";
|
||||
|
||||
function buildTranslations(rows: ContentTranslationRow[] | undefined) {
|
||||
if (!rows) return undefined;
|
||||
const result: Record<string, Record<string, string>> = {};
|
||||
for (const row of rows) {
|
||||
if (!result[row.locale]) result[row.locale] = {};
|
||||
result[row.locale][row.field] = row.value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export default async function ClientDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const { actor, service } = await requireFreelancerBackend();
|
||||
const { context, 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;
|
||||
|
||||
const resolvedLocale = await resolveFreelancerLocale(context);
|
||||
const payload = getClientI18nPayload(resolvedLocale.locale, ["clients"]);
|
||||
|
||||
let data: { client: ClientDetailData; activities: ClientActivity[] };
|
||||
try {
|
||||
const row = service.getClient(actor, id);
|
||||
const clientTranslations = service.contentTranslations.list("client", id);
|
||||
|
||||
const client: ClientDetailData = {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
@@ -27,14 +46,20 @@ export default async function ClientDetailPage({ params }: { params: Promise<{ i
|
||||
notes: row.notes,
|
||||
client_auth_id: row.authUserId,
|
||||
portal_locale: row.portalLocale ?? defaultLocale,
|
||||
translations: buildTranslations(clientTranslations),
|
||||
};
|
||||
const activities: ClientActivity[] = service.listClientActivities(actor, id).map((activity) => ({
|
||||
|
||||
const rawActivities = service.listClientActivities(actor, id);
|
||||
const activityTranslationsMap = service.contentTranslations.listBatch("client_activity", rawActivities.map(a => a.id));
|
||||
|
||||
const activities: ClientActivity[] = rawActivities.map((activity) => ({
|
||||
id: activity.id,
|
||||
type: activity.type,
|
||||
title: activity.title,
|
||||
content: activity.content,
|
||||
activity_date: activity.activityDate.toISOString(),
|
||||
created_at: activity.createdAt.toISOString(),
|
||||
translations: buildTranslations(activityTranslationsMap.get(activity.id)),
|
||||
}));
|
||||
|
||||
data = { client, activities };
|
||||
@@ -43,5 +68,9 @@ export default async function ClientDetailPage({ params }: { params: Promise<{ i
|
||||
throw error;
|
||||
}
|
||||
|
||||
return <ClientDetailClient client={data.client} activities={data.activities} locales={locales} />;
|
||||
return (
|
||||
<I18nProvider payload={payload}>
|
||||
<ClientDetailClient client={data.client} activities={data.activities} locales={locales} />
|
||||
</I18nProvider>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user