feat(i18n): integrate multilingual support into clients views and forms

This commit is contained in:
poyrazavsever
2026-07-20 11:08:43 +03:00
parent 5210a180d4
commit 0cb6ee8139
6 changed files with 206 additions and 104 deletions
+9 -2
View File
@@ -3,6 +3,7 @@
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
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";
import { parseContentTranslationsFromFormData } from "@/server/i18n/content";
const ACTIVITY_TYPES = ["note", "call", "meeting", "email"] as const; 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] ? rawType as (typeof ACTIVITY_TYPES)[number]
: "note"; : "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, { service.addClientActivity(actor, {
clientId, clientId,
type, type,
title: requiredText(formData.get("title"), "Aktivite başlığı zorunludur."), title: defaultTitle || requiredText(formData.get("title"), "Aktivite başlığı zorunludur."),
content: cleanText(formData.get("content")), content: defaultContent || cleanText(formData.get("content")),
activityDate: optionalDate(formData.get("activity_date")) ?? new Date(), activityDate: optionalDate(formData.get("activity_date")) ?? new Date(),
translations,
}); });
revalidatePath(`/clients/${clientId}`); revalidatePath(`/clients/${clientId}`);
@@ -4,10 +4,11 @@ import { useState } from "react";
import { format } from "date-fns"; import { format } from "date-fns";
import { getDocumentDateFnsLocale } from "@/lib/i18n/date-fns"; 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, Tabs, TabsList, TabsTrigger, TabsContent } 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";
import { toast } from "poyraz-ui/molecules"; import { toast } from "poyraz-ui/molecules";
import { addClientActivity } from "./actions"; import { addClientActivity } from "./actions";
import { useTranslations } from "@/components/i18n/i18n-provider";
export type ClientDetailData = { export type ClientDetailData = {
id: string; id: string;
@@ -30,6 +31,7 @@ export type ClientActivity = {
content: string | null; content: string | null;
activity_date: string; activity_date: string;
created_at: string; created_at: string;
translations?: Record<string, Record<string, string>>;
}; };
export function ClientDetailClient({ export function ClientDetailClient({
@@ -44,6 +46,7 @@ export function ClientDetailClient({
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 [portalLocale, setPortalLocale] = useState(client.portal_locale);
const t = useTranslations();
const getActivityIcon = (type: string) => { const getActivityIcon = (type: string) => {
switch (type) { switch (type) {
@@ -56,10 +59,10 @@ export function ClientDetailClient({
const getActivityBadge = (type: string) => { const getActivityBadge = (type: string) => {
switch (type) { switch (type) {
case "call": return <Badge className="bg-blue-500/10 text-blue-500 border-blue-500/20">Arama</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">Toplantı</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">E-posta</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">Not</Badge>; default: return <Badge variant="secondary">{t("clients.detail.activityTypes.note")}</Badge>;
} }
}; };
@@ -142,7 +145,7 @@ export function ClientDetailClient({
<Dialog open={createUserOpen} onOpenChange={setCreateUserOpen}> <Dialog open={createUserOpen} onOpenChange={setCreateUserOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button effect="shine" variant="secondary" size="sm" className="gap-2 ml-2 border-dashed"> <Button effect="shine" variant="secondary" size="sm" className="gap-2 ml-2 border-dashed">
<UserPlus className="h-4 w-4" /> Portal Hesabı <UserPlus className="h-4 w-4" /> {t("clients.detail.createPortalAccount")}
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent> <DialogContent>
@@ -155,11 +158,11 @@ export function ClientDetailClient({
</DialogHeader> </DialogHeader>
<div className="space-y-4 py-4"> <div className="space-y-4 py-4">
<div className="space-y-2"> <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 || ""} /> <Input id="email" name="email" type="email" required defaultValue={client.email || ""} />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Portal dili</Label> <Label>{t("clients.detail.portalLocale")}</Label>
<Select name="locale" defaultValue={portalLocale}> <Select name="locale" defaultValue={portalLocale}>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Dil seç" /> <SelectValue placeholder="Dil seç" />
@@ -209,7 +212,7 @@ export function ClientDetailClient({
{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"> <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> </Badge>
<Select value={portalLocale} onValueChange={handlePortalLocaleChange}> <Select value={portalLocale} onValueChange={handlePortalLocaleChange}>
<SelectTrigger className="h-9 w-36"> <SelectTrigger className="h-9 w-36">
@@ -233,7 +236,7 @@ export function ClientDetailClient({
<div className="space-y-6"> <div className="space-y-6">
<Card> <Card>
<CardContent className="p-5 space-y-4"> <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"> <div className="space-y-3 text-sm">
{client.email ? ( {client.email ? (
<div className="flex items-center gap-3 text-muted-foreground"> <div className="flex items-center gap-3 text-muted-foreground">
@@ -264,7 +267,7 @@ export function ClientDetailClient({
<Card> <Card>
<CardContent className="p-5 space-y-4"> <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 ? ( {client.notes ? (
<p className="text-sm text-muted-foreground whitespace-pre-wrap">{client.notes}</p> <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}> <Dialog open={openDialog} onOpenChange={setOpenDialog}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button variant="default" effect="shine" size="sm" className="gap-2"> <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> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent> <DialogContent>
<form action={handleAddActivity} className="space-y-4"> <form action={handleAddActivity} className="space-y-4">
<DialogHeader> <DialogHeader>
<DialogTitle>Yeni Aktivite Ekle</DialogTitle> <DialogTitle>{t("clients.detail.addActivity")}</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="grid gap-4 py-4"> <div className="grid gap-4 py-4">
<div className="grid gap-2"> <div className="grid gap-2">
<Label>Tip</Label> <Label>{t("clients.detail.activityType")}</Label>
<Select name="type" defaultValue="note"> <Select name="type" defaultValue="note">
<SelectTrigger><SelectValue /></SelectTrigger> <SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="note">Not</SelectItem> <SelectItem value="note">{t("clients.detail.activityTypes.note")}</SelectItem>
<SelectItem value="call">Arama</SelectItem> <SelectItem value="call">{t("clients.detail.activityTypes.call")}</SelectItem>
<SelectItem value="meeting">Toplantı</SelectItem> <SelectItem value="meeting">{t("clients.detail.activityTypes.meeting")}</SelectItem>
<SelectItem value="email">E-posta</SelectItem> <SelectItem value="email">{t("clients.detail.activityTypes.email")}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@@ -310,17 +313,32 @@ export function ClientDetailClient({
<Input name="title" required placeholder="Aktivite özeti" /> <Input name="title" required placeholder="Aktivite özeti" />
</div> </div>
<div className="grid gap-2"> <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)} /> <Input name="activity_date" type="datetime-local" required defaultValue={new Date().toISOString().slice(0, 16)} />
</div> </div>
<div className="grid gap-2"> <Tabs defaultValue={locales[0].code}>
<Label>İçerik (Opsiyonel)</Label> <TabsList className="mb-4">
<Textarea name="content" rows={4} placeholder="Görüşme detayları..." /> {locales.map((locale) => (
</div> <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> </div>
<DialogFooter> <DialogFooter>
<Button variant="default" effect="shine" type="submit" disabled={isAddingActivity}> <Button variant="default" effect="shine" type="submit" disabled={isAddingActivity}>
{isAddingActivity ? "Ekleniyor..." : "Ekle"} {isAddingActivity ? "..." : t("clients.detail.saveActivity")}
</Button> </Button>
</DialogFooter> </DialogFooter>
</form> </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"> <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 ? ( {activities.length === 0 ? (
<div className="text-center py-10"> <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> </div>
) : ( ) : (
activities.map((activity) => ( 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"> <Card className="w-[calc(100%-4rem)] md:w-[calc(50%-2.5rem)] hover:border-primary/50 transition-colors">
<CardContent className="p-4"> <CardContent className="p-4">
<div className="flex justify-between items-start mb-2"> <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)} {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: getDocumentDateFnsLocale() })} {format(new Date(activity.activity_date), "d MMM yyyy, HH:mm", { locale: getDocumentDateFnsLocale() })}
</time> </time>
{activity.content && ( {(activity.translations?.[locales[0].code]?.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.translations?.[locales[0].code]?.content ?? activity.content}</p>
)} )}
</CardContent> </CardContent>
</Card> </Card>
+32 -3
View File
@@ -4,17 +4,36 @@ 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 { I18nService } from "@/server/i18n/service";
import { requireFreelancerBackend } from "@/server/web/freelancer"; 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 }> }) { export default async function ClientDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params; const { id } = await params;
const { actor, service } = await requireFreelancerBackend(); const { context, actor, service } = await requireFreelancerBackend();
const i18n = new I18nService(getSqliteConnection().db); const i18n = new I18nService(getSqliteConnection().db);
const locales = i18n.listLocales(actor).filter((locale) => locale.status === "active"); const locales = i18n.listLocales(actor).filter((locale) => locale.status === "active");
const defaultLocale = i18n.getSettings(actor).defaultLocale; const defaultLocale = i18n.getSettings(actor).defaultLocale;
const resolvedLocale = await resolveFreelancerLocale(context);
const payload = getClientI18nPayload(resolvedLocale.locale, ["clients"]);
let data: { client: ClientDetailData; activities: ClientActivity[] }; let data: { client: ClientDetailData; activities: ClientActivity[] };
try { try {
const row = service.getClient(actor, id); const row = service.getClient(actor, id);
const clientTranslations = service.contentTranslations.list("client", id);
const client: ClientDetailData = { const client: ClientDetailData = {
id: row.id, id: row.id,
name: row.name, name: row.name,
@@ -27,14 +46,20 @@ export default async function ClientDetailPage({ params }: { params: Promise<{ i
notes: row.notes, notes: row.notes,
client_auth_id: row.authUserId, client_auth_id: row.authUserId,
portal_locale: row.portalLocale ?? defaultLocale, 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, id: activity.id,
type: activity.type, type: activity.type,
title: activity.title, title: activity.title,
content: activity.content, content: activity.content,
activity_date: activity.activityDate.toISOString(), activity_date: activity.activityDate.toISOString(),
created_at: activity.createdAt.toISOString(), created_at: activity.createdAt.toISOString(),
translations: buildTranslations(activityTranslationsMap.get(activity.id)),
})); }));
data = { client, activities }; data = { client, activities };
@@ -43,5 +68,9 @@ export default async function ClientDetailPage({ params }: { params: Promise<{ i
throw error; 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>
);
} }
+9 -3
View File
@@ -3,6 +3,7 @@
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { requireFreelancerBackend } from "@/server/web/freelancer"; import { requireFreelancerBackend } from "@/server/web/freelancer";
import { cleanText, requiredText } from "@/server/web/form-data"; import { cleanText, requiredText } from "@/server/web/form-data";
import { parseContentTranslationsFromFormData } from "@/server/i18n/content";
const CLIENT_STATUSES = ["active", "paused", "archived"] as const; const CLIENT_STATUSES = ["active", "paused", "archived"] as const;
const PIPELINE_STAGES = ["lead", "contacted", "proposal_sent", "won", "lost"] as const; const PIPELINE_STAGES = ["lead", "contacted", "proposal_sent", "won", "lost"] as const;
@@ -20,7 +21,11 @@ function cleanWebsite(value: FormDataEntryValue | null) {
return website && !/^https?:\/\//i.test(website) ? `https://${website}` : website; return website && !/^https?:\/\//i.test(website) ? `https://${website}` : website;
} }
function readPayload(formData: FormData) { function readPayload(
formData: FormData,
service: Awaited<ReturnType<typeof requireFreelancerBackend>>["service"],
actor: Awaited<ReturnType<typeof requireFreelancerBackend>>["actor"]
) {
return { return {
name: requiredText(formData.get("name"), "Müşteri adı zorunludur."), name: requiredText(formData.get("name"), "Müşteri adı zorunludur."),
companyName: cleanText(formData.get("company_name")), companyName: cleanText(formData.get("company_name")),
@@ -31,19 +36,20 @@ function readPayload(formData: FormData) {
notes: cleanText(formData.get("notes")), notes: cleanText(formData.get("notes")),
pipelineStage: enumValue(formData.get("pipeline_stage"), PIPELINE_STAGES, "lead"), pipelineStage: enumValue(formData.get("pipeline_stage"), PIPELINE_STAGES, "lead"),
nextFollowUpDate: cleanText(formData.get("next_follow_up_date")), nextFollowUpDate: cleanText(formData.get("next_follow_up_date")),
translations: parseContentTranslationsFromFormData(formData, "client", service.contentTranslations.getLocalizationContext(actor)),
}; };
} }
export async function createClientRecord(formData: FormData) { export async function createClientRecord(formData: FormData) {
const { actor, service } = await requireFreelancerBackend(); const { actor, service } = await requireFreelancerBackend();
service.createClient(actor, readPayload(formData)); service.createClient(actor, readPayload(formData, service, actor));
revalidatePath("/clients"); revalidatePath("/clients");
} }
export async function updateClientRecord(formData: FormData) { export async function updateClientRecord(formData: FormData) {
const { actor, service } = await requireFreelancerBackend(); const { actor, service } = await requireFreelancerBackend();
const id = requiredText(formData.get("id"), "Müşteri kaydı bulunamadı."); const id = requiredText(formData.get("id"), "Müşteri kaydı bulunamadı.");
service.updateClient(actor, id, readPayload(formData)); service.updateClient(actor, id, readPayload(formData, service, actor));
revalidatePath("/clients"); revalidatePath("/clients");
revalidatePath(`/clients/${id}`); revalidatePath(`/clients/${id}`);
} }
+80 -62
View File
@@ -63,6 +63,7 @@ export type ClientListItem = {
next_follow_up_date: string | null; next_follow_up_date: string | null;
last_contact_date: string | null; last_contact_date: string | null;
client_value_score: number; client_value_score: number;
translations?: Record<string, Record<string, string>>;
}; };
type ClientPipelineStage = ClientListItem["pipeline_stage"]; type ClientPipelineStage = ClientListItem["pipeline_stage"];
@@ -83,12 +84,14 @@ type ClientsClientProps = {
clients: ClientListItem[]; clients: ClientListItem[];
totalRevenue: number; totalRevenue: number;
activeCount: number; activeCount: number;
activeLocales: { code: string; name: string }[];
}; };
export function ClientsClient({ export function ClientsClient({
clients, clients,
totalRevenue, totalRevenue,
activeCount, activeCount,
activeLocales,
}: ClientsClientProps) { }: ClientsClientProps) {
const t = useTranslations(); const t = useTranslations();
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
@@ -123,7 +126,7 @@ export function ClientsClient({
try { try {
await updateClientPipelineStage(clientId, newStage); await updateClientPipelineStage(clientId, newStage);
toast.success("Müşteri aşaması güncellendi."); toast.success(t("clients.messages.stageUpdated"));
} catch (error) { } catch (error) {
setPipelineOverrides((current) => ({ setPipelineOverrides((current) => ({
...current, ...current,
@@ -132,7 +135,7 @@ export function ClientsClient({
toast.error( toast.error(
error instanceof Error error instanceof Error
? error.message ? error.message
: "Müşteri aşaması güncellenemedi.", : t("clients.errors.stageUpdateFailed"),
); );
} }
} }
@@ -161,32 +164,32 @@ export function ClientsClient({
</h1> </h1>
</div> </div>
<ClientDialog mode="create" /> <ClientDialog mode="create" activeLocales={activeLocales} />
</div> </div>
<div className="grid gap-3 md:grid-cols-4"> <div className="grid gap-3 md:grid-cols-4">
<StatCard <StatCard
label="Potansiyel (Lead)" label={t("clients.stats.lead")}
value={clients.filter(c => c.pipeline_stage === 'lead' || c.pipeline_stage === 'contacted').length.toString()} value={clients.filter(c => c.pipeline_stage === 'lead' || c.pipeline_stage === 'contacted').length.toString()}
icon={Users} icon={Users}
tone="blue" tone="blue"
/> />
<StatCard <StatCard
label="Aktif Müşteri" label={t("clients.stats.active")}
value={activeCount.toString()} value={activeCount.toString()}
icon={UserCheck} icon={UserCheck}
tone="green" tone="green"
/> />
<StatCard <StatCard
label="Bekleyen Follow-up" label={t("clients.stats.followUp")}
value={clients.filter(c => c.next_follow_up_date && (isPast(new Date(c.next_follow_up_date)) || isToday(new Date(c.next_follow_up_date)))).length.toString()} value={clients.filter(c => c.next_follow_up_date && (isPast(new Date(c.next_follow_up_date)) || isToday(new Date(c.next_follow_up_date)))).length.toString()}
icon={Clock} icon={Clock}
tone="rose" tone="rose"
/> />
<StatCard <StatCard
label="Kayıtlı Gelir" label={t("clients.stats.revenue")}
value={formatCurrency(totalRevenue)} value={formatCurrency(totalRevenue)}
description="Ödenmiş gelir işlemleri" description={t("clients.stats.revenueDesc")}
icon={Wallet} icon={Wallet}
tone="primary" tone="primary"
/> />
@@ -195,14 +198,14 @@ export function ClientsClient({
<Tabs defaultValue="pipeline" className="w-full"> <Tabs defaultValue="pipeline" className="w-full">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-4"> <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-4">
<TabsList> <TabsList>
<TabsTrigger value="pipeline">Pipeline (Kanban)</TabsTrigger> <TabsTrigger value="pipeline">{t("clients.tabs.pipeline")}</TabsTrigger>
<TabsTrigger value="list">Müşteri Listesi</TabsTrigger> <TabsTrigger value="list">{t("clients.tabs.list")}</TabsTrigger>
</TabsList> </TabsList>
<Input <Input
value={query} value={query}
onChange={(event) => setQuery(event.target.value)} onChange={(event) => setQuery(event.target.value)}
placeholder="Müşteri, firma, e-posta veya not ara" placeholder={t("clients.search")}
className="md:max-w-sm" className="md:max-w-sm"
/> />
</div> </div>
@@ -214,7 +217,7 @@ export function ClientsClient({
return ( return (
<DroppableColumn <DroppableColumn
key={stage.id} key={stage.id}
title={stage.label} title={t(`clients.pipeline.${stage.id}` as any)}
count={stageClients.length} count={stageClients.length}
color={stage.color.split(' ')[1]} color={stage.color.split(' ')[1]}
onDrop={() => handleDrop(stage.id)} onDrop={() => handleDrop(stage.id)}
@@ -230,7 +233,7 @@ export function ClientsClient({
))} ))}
{stageClients.length === 0 && ( {stageClients.length === 0 && (
<div className="h-24 flex items-center justify-center border-2 border-dashed border-border rounded-md text-xs text-muted-foreground"> <div className="h-24 flex items-center justify-center border-2 border-dashed border-border rounded-md text-xs text-muted-foreground">
Boş {t("clients.empty.pipeline")}
</div> </div>
)} )}
</DroppableColumn> </DroppableColumn>
@@ -244,22 +247,22 @@ export function ClientsClient({
<div className="overflow-x-auto rounded-sm border border-border"> <div className="overflow-x-auto rounded-sm border border-border">
<div className="min-w-[900px]"> <div className="min-w-[900px]">
<div className="grid grid-cols-[1.5fr_1fr_1fr_1fr_0.8fr_0.8fr] gap-4 border-b border-border bg-muted/40 px-4 py-3 text-xs font-medium uppercase text-muted-foreground"> <div className="grid grid-cols-[1.5fr_1fr_1fr_1fr_0.8fr_0.8fr] gap-4 border-b border-border bg-muted/40 px-4 py-3 text-xs font-medium uppercase text-muted-foreground">
<span>Müşteri</span> <span>{t("clients.list.client")}</span>
<span>İletişim</span> <span>{t("clients.list.contact")}</span>
<span>Aşama</span> <span>{t("clients.list.stage")}</span>
<span>Follow-up</span> <span>{t("clients.list.followUp")}</span>
<span>Projeler</span> <span>Finans</span>
<span className="text-right">İşlem</span> <span className="text-right">İşlem</span>
</div> </div>
<div className="divide-y divide-border"> <div className="divide-y divide-border">
{filteredClients.map((client) => ( {filteredClients.map(client => (
<ClientRow key={client.id} client={client} /> <ClientRow key={client.id} client={client} activeLocales={activeLocales} />
))} ))}
</div> </div>
</div> </div>
</div> </div>
) : ( ) : (
<EmptyState hasQuery={Boolean(normalizedQuery)} /> <EmptyState hasQuery={query.length > 0} />
)} )}
</TabsContent> </TabsContent>
</Tabs> </Tabs>
@@ -320,7 +323,7 @@ function DraggableClientCard({
{client.name} {client.name}
</PendingLink> </PendingLink>
<div onPointerDown={(e) => e.stopPropagation()}> <div onPointerDown={(e) => e.stopPropagation()}>
<ClientDialog mode="edit" client={client} trigger={<Button size="icon-sm" effect="shine" variant="secondary" ><Pencil className="h-3 w-3" /></Button>} /> <ClientDialog mode="edit" client={client} activeLocales={[{code: "dummy", name: "dummy"}]} trigger={<Button size="icon-sm" effect="shine" variant="secondary" ><Pencil className="h-3 w-3" /></Button>} />
</div> </div>
</div> </div>
{client.company_name && <p className="text-xs text-muted-foreground mb-2 pointer-events-none">{client.company_name}</p>} {client.company_name && <p className="text-xs text-muted-foreground mb-2 pointer-events-none">{client.company_name}</p>}
@@ -339,7 +342,8 @@ function DraggableClientCard({
); );
} }
function ClientRow({ client }: { client: ClientListItem }) { function ClientRow({ client, activeLocales }: { client: ClientListItem, activeLocales: { code: string; name: string }[] }) {
const t = useTranslations();
const isFollowUpOverdue = client.next_follow_up_date && (isPast(new Date(client.next_follow_up_date)) || isToday(new Date(client.next_follow_up_date))); const isFollowUpOverdue = client.next_follow_up_date && (isPast(new Date(client.next_follow_up_date)) || isToday(new Date(client.next_follow_up_date)));
const stage = pipelineStages.find(s => s.id === client.pipeline_stage) || pipelineStages[0]; const stage = pipelineStages.find(s => s.id === client.pipeline_stage) || pipelineStages[0];
@@ -353,7 +357,7 @@ function ClientRow({ client }: { client: ClientListItem }) {
<div className="min-w-0"> <div className="min-w-0">
<PendingLink href={`/clients/${client.id}`} className="truncate font-medium text-foreground hover:underline flex items-center gap-1.5" showSpinner>{client.name}</PendingLink> <PendingLink href={`/clients/${client.id}`} className="truncate font-medium text-foreground hover:underline flex items-center gap-1.5" showSpinner>{client.name}</PendingLink>
<div className="truncate text-sm text-muted-foreground"> <div className="truncate text-sm text-muted-foreground">
{client.company_name || "Firma bilgisi yok"} {client.company_name || t("clients.list.noCompany")}
</div> </div>
</div> </div>
</div> </div>
@@ -373,13 +377,13 @@ function ClientRow({ client }: { client: ClientListItem }) {
</Link> </Link>
) : null} ) : null}
{!client.email && !client.phone && !client.website ? ( {!client.email && !client.phone && !client.website ? (
<span>İletişim bilgisi yok</span> <span>{t("clients.list.noContact")}</span>
) : null} ) : null}
</div> </div>
<div> <div>
<Badge className={stage.color}> <Badge className={stage.color}>
{stage.label} {t(`clients.pipeline.${stage.id}` as any)}
</Badge> </Badge>
</div> </div>
@@ -395,7 +399,7 @@ function ClientRow({ client }: { client: ClientListItem }) {
</div> </div>
<div className="text-sm"> <div className="text-sm">
<div className="font-medium text-foreground">{client.projectCount} Proje</div> <div className="font-medium text-foreground">{client.projectCount} {t("clients.list.projects")}</div>
<div className="text-muted-foreground">{formatCurrency(client.revenueTotal)}</div> <div className="text-muted-foreground">{formatCurrency(client.revenueTotal)}</div>
</div> </div>
@@ -405,7 +409,7 @@ function ClientRow({ client }: { client: ClientListItem }) {
<ArrowRight className="h-4 w-4" /> <ArrowRight className="h-4 w-4" />
</Button> </Button>
</PendingLink> </PendingLink>
<ClientDialog mode="edit" client={client} trigger={<Button effect="shine" variant="secondary" className="min-w-20 gap-2 px-3"><Pencil className="h-4 w-4" /> Düzenle</Button>} /> <ClientDialog mode="edit" client={client} activeLocales={activeLocales} trigger={<Button effect="shine" variant="secondary" className="min-w-20 gap-2 px-3"><Pencil className="h-4 w-4" /> {t("clients.actions.edit")}</Button>} />
</div> </div>
</div> </div>
); );
@@ -414,12 +418,15 @@ function ClientRow({ client }: { client: ClientListItem }) {
function ClientDialog({ function ClientDialog({
mode, mode,
client, client,
trigger trigger,
activeLocales
}: { }: {
mode: "create" | "edit"; mode: "create" | "edit";
client?: ClientListItem; client?: ClientListItem;
trigger?: React.ReactNode; trigger?: React.ReactNode;
activeLocales: { code: string; name: string }[];
}) { }) {
const t = useTranslations();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const action = mode === "create" ? createClientRecord : updateClientRecord; const action = mode === "create" ? createClientRecord : updateClientRecord;
@@ -430,12 +437,12 @@ function ClientDialog({
try { try {
await action(formData); await action(formData);
setOpen(false); setOpen(false);
toast.success(mode === "create" ? "Müşteri eklendi." : "Müşteri güncellendi."); toast.success(mode === "create" ? t("clients.messages.created") : t("clients.messages.updated"));
} catch (error) { } catch (error) {
toast.error( toast.error(
error instanceof Error error instanceof Error
? error.message ? error.message
: "Müşteri kaydedilirken beklenmeyen bir hata oluştu.", : t("clients.errors.saveFailed"),
); );
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
@@ -451,7 +458,7 @@ function ClientDialog({
className="min-w-24 gap-2 px-3" className="min-w-24 gap-2 px-3"
> >
{mode === "create" ? <Plus className="h-4 w-4" /> : <Pencil className="h-4 w-4" />} {mode === "create" ? <Plus className="h-4 w-4" /> : <Pencil className="h-4 w-4" />}
{mode === "create" ? "Müşteri ekle" : "Düzenle"} {mode === "create" ? t("clients.actions.add") : t("clients.actions.edit")}
</Button> </Button>
)} )}
</DialogTrigger> </DialogTrigger>
@@ -460,25 +467,25 @@ function ClientDialog({
{client ? <input type="hidden" name="id" value={client.id} /> : null} {client ? <input type="hidden" name="id" value={client.id} /> : null}
<DialogHeader className="shrink-0 px-5 pb-4 pt-5 pr-12"> <DialogHeader className="shrink-0 px-5 pb-4 pt-5 pr-12">
<DialogTitle> <DialogTitle>
{mode === "create" ? "Yeni müşteri" : "Müşteriyi düzenle"} {mode === "create" ? t("clients.form.createTitle") : t("clients.form.editTitle")}
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
Müşterinin iletişim ve CRM detaylarını girin. {t("clients.description")}
</DialogDescription> </DialogDescription>
</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">
<ClientFormFields client={client} /> <ClientFormFields client={client} activeLocales={activeLocales} />
</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">
<Button variant="default" effect="shine" type="submit" disabled={isSubmitting} className="w-full gap-2 sm:w-auto"> <Button variant="default" effect="shine" type="submit" disabled={isSubmitting} className="w-full gap-2 sm:w-auto">
{mode === "create" ? <Plus className="h-4 w-4" /> : <Pencil className="h-4 w-4" />} {mode === "create" ? <Plus className="h-4 w-4" /> : <Pencil className="h-4 w-4" />}
{isSubmitting {isSubmitting
? "Kaydediliyor" ? "..."
: mode === "create" : mode === "create"
? "Müşteriyi ekle" ? t("clients.form.save")
: "Değişiklikleri kaydet"} : t("clients.form.save")}
</Button> </Button>
</DialogFooter> </DialogFooter>
</form> </form>
@@ -487,12 +494,13 @@ function ClientDialog({
); );
} }
function ClientFormFields({ client }: { client?: ClientListItem }) { function ClientFormFields({ client, activeLocales }: { client?: ClientListItem, activeLocales: { code: string; name: string }[] }) {
const t = useTranslations();
return ( return (
<div className="grid gap-4"> <div className="grid gap-4">
<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 htmlFor={`name-${client?.id || "new"}`}>Müşteri adı</Label> <Label htmlFor={`name-${client?.id || "new"}`}>{t("clients.form.name")}</Label>
<Input <Input
id={`name-${client?.id || "new"}`} id={`name-${client?.id || "new"}`}
name="name" name="name"
@@ -502,40 +510,39 @@ function ClientFormFields({ client }: { client?: ClientListItem }) {
/> />
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor={`company-${client?.id || "new"}`}>Firma / marka adı</Label> <Label htmlFor={`company-${client?.id || "new"}`}>{t("clients.form.company")}</Label>
<Input <Input
id={`company-${client?.id || "new"}`} id={`company-${client?.id || "new"}`}
name="company_name" name="company_name"
defaultValue={client?.company_name || ""} defaultValue={client?.company_name || ""}
placeholder="Opsiyonel"
/> />
</div> </div>
</div> </div>
<div className="grid gap-4 md:grid-cols-2 border-t border-border pt-4 mt-2"> <div className="grid gap-4 md:grid-cols-2 border-t border-border pt-4 mt-2">
<div className="grid gap-2"> <div className="grid gap-2">
<Label>Satış Aşaması (Pipeline)</Label> <Label>{t("clients.form.pipelineStage")}</Label>
<Select name="pipeline_stage" defaultValue={client?.pipeline_stage || "lead"}> <Select name="pipeline_stage" defaultValue={client?.pipeline_stage || "lead"}>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Aşama seç" /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{pipelineStages.map(stage => ( {pipelineStages.map(stage => (
<SelectItem key={stage.id} value={stage.id}>{stage.label}</SelectItem> <SelectItem key={stage.id} value={stage.id}>{t(`clients.pipeline.${stage.id}` as any)}</SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label>Durum</Label> <Label>{t("clients.form.status")}</Label>
<Select name="status" defaultValue={client?.status || "active"}> <Select name="status" defaultValue={client?.status || "active"}>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Durum seç" /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="active">Aktif</SelectItem> <SelectItem value="active">{t("clients.form.active")}</SelectItem>
<SelectItem value="paused">Duraklatıldı</SelectItem> <SelectItem value="paused">{t("clients.form.paused")}</SelectItem>
<SelectItem value="archived">Arşivlendi</SelectItem> <SelectItem value="archived">{t("clients.form.archived")}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@@ -543,7 +550,7 @@ function ClientFormFields({ client }: { client?: ClientListItem }) {
<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 htmlFor={`followup-${client?.id || "new"}`}>Sonraki Follow-up Tarihi</Label> <Label htmlFor={`followup-${client?.id || "new"}`}>{t("clients.form.followUp")}</Label>
<Input <Input
id={`followup-${client?.id || "new"}`} id={`followup-${client?.id || "new"}`}
name="next_follow_up_date" name="next_follow_up_date"
@@ -555,7 +562,7 @@ function ClientFormFields({ client }: { client?: ClientListItem }) {
<div className="grid gap-4 md:grid-cols-2 border-t border-border pt-4 mt-2"> <div className="grid gap-4 md:grid-cols-2 border-t border-border pt-4 mt-2">
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor={`email-${client?.id || "new"}`}>E-posta</Label> <Label htmlFor={`email-${client?.id || "new"}`}>{t("clients.form.email")}</Label>
<Input <Input
id={`email-${client?.id || "new"}`} id={`email-${client?.id || "new"}`}
name="email" name="email"
@@ -565,7 +572,7 @@ function ClientFormFields({ client }: { client?: ClientListItem }) {
/> />
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor={`phone-${client?.id || "new"}`}>Telefon</Label> <Label htmlFor={`phone-${client?.id || "new"}`}>{t("clients.form.phone")}</Label>
<PhoneInput <PhoneInput
id={`phone-${client?.id || "new"}`} id={`phone-${client?.id || "new"}`}
name="phone" name="phone"
@@ -574,15 +581,26 @@ function ClientFormFields({ client }: { client?: ClientListItem }) {
</div> </div>
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2 border-t border-border pt-4 mt-2">
<Label htmlFor={`notes-${client?.id || "new"}`}>Genel Notlar</Label> <Tabs defaultValue={activeLocales[0].code}>
<Textarea <div className="flex items-center justify-between mb-4">
id={`notes-${client?.id || "new"}`} <Label>{t("clients.form.notes")}</Label>
name="notes" <TabsList>
defaultValue={client?.notes || ""} {activeLocales.map((locale) => (
placeholder="İletişim notları, beklentiler, özel bilgiler..." <TabsTrigger key={locale.code} value={locale.code}>{locale.name}</TabsTrigger>
rows={3} ))}
/> </TabsList>
</div>
{activeLocales.map((locale) => (
<TabsContent key={locale.code} value={locale.code} className="mt-0 space-y-4">
<Textarea
name={`i18n.${locale.code}.notes`}
defaultValue={client?.translations?.[locale.code]?.notes ?? ""}
rows={4}
/>
</TabsContent>
))}
</Tabs>
</div> </div>
</div> </div>
); );
+30 -6
View File
@@ -1,8 +1,26 @@
import { ClientsClient, type ClientListItem } from "@/app/(dashboard)/clients/clients-client"; import { ClientsClient, type ClientListItem } from "@/app/(dashboard)/clients/clients-client";
import { requireFreelancerBackend } from "@/server/web/freelancer"; 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 ClientsPage() { export default async function ClientsPage() {
const { actor, service } = await requireFreelancerBackend(); const { context, actor, service } = await requireFreelancerBackend();
const resolvedLocale = await resolveFreelancerLocale(context);
const payload = getClientI18nPayload(resolvedLocale.locale, ["clients"]);
const activeLocales = service.listLocales(actor).filter(l => l.status !== "archived").map(l => ({ code: l.code, name: l.nativeName }));
const clientsData = service.listClients(actor); const clientsData = service.listClients(actor);
const projects = service.listProjects(actor); const projects = service.listProjects(actor);
const finance = service.listFinanceTransactions(actor); const finance = service.listFinanceTransactions(actor);
@@ -32,6 +50,8 @@ export default async function ClientsPage() {
} }
} }
const translationsMap = service.contentTranslations.listBatch("client", clientsData.map(c => c.id));
const clients: ClientListItem[] = clientsData.map((client) => { const clients: ClientListItem[] = clientsData.map((client) => {
return { return {
id: client.id, id: client.id,
@@ -49,14 +69,18 @@ export default async function ClientsPage() {
created_at: client.createdAt.toISOString(), created_at: client.createdAt.toISOString(),
projectCount: projectCountByClient.get(client.id) ?? 0, projectCount: projectCountByClient.get(client.id) ?? 0,
revenueTotal: revenueByClient.get(client.id) ?? 0, revenueTotal: revenueByClient.get(client.id) ?? 0,
translations: buildTranslations(translationsMap.get(client.id)),
}; };
}); });
return ( return (
<ClientsClient <I18nProvider payload={payload}>
clients={clients} <ClientsClient
totalRevenue={clients.reduce((sum, client) => sum + client.revenueTotal, 0)} clients={clients}
activeCount={clients.filter((client) => client.status === "active").length} totalRevenue={clients.reduce((sum, client) => sum + client.revenueTotal, 0)}
/> activeCount={clients.filter((client) => client.status === "active").length}
activeLocales={activeLocales}
/>
</I18nProvider>
); );
} }