diff --git a/app/(dashboard)/business/invoices/invoices-client.tsx b/app/(dashboard)/business/invoices/invoices-client.tsx index 062a9cd..385837b 100644 --- a/app/(dashboard)/business/invoices/invoices-client.tsx +++ b/app/(dashboard)/business/invoices/invoices-client.tsx @@ -1,11 +1,9 @@ "use client"; import { getDocumentIntlLocale } from "@/lib/i18n/browser"; -import { useState } from "react"; -import { format } from "date-fns"; -import { getDocumentDateFnsLocale } from "@/lib/i18n/date-fns"; -import { Plus, MoreHorizontal, FileEdit, Trash2, Send, Download, CheckCircle2 } from "lucide-react"; -import { Button, Card, CardContent, Badge } from "poyraz-ui/atoms"; +import { useTranslations } from "@/components/i18n/i18n-provider"; +import { CheckCircle2, Download, FileEdit, MoreHorizontal, Plus, Send, Trash2 } from "lucide-react"; +import { Badge, Button, Card, CardContent } from "poyraz-ui/atoms"; import { DropdownMenu, DropdownMenuContent, @@ -27,60 +25,38 @@ export type InvoiceRow = { }; export function InvoicesClient({ invoices }: { invoices: InvoiceRow[] }) { - const [isAddModalOpen, setIsAddModalOpen] = useState(false); - - const formatCurrency = (amount: number, currency: string) => { - return new Intl.NumberFormat(getDocumentIntlLocale(), { style: "currency", currency }).format(amount); - }; - - const getStatusBadge = (status: string) => { - switch (status) { - case "draft": - return Taslak; - case "sent": - return Gönderildi; - case "paid": - return Ödendi; - case "overdue": - return Gecikmiş; - case "cancelled": - return İptal; - default: - return {status}; - } - }; + const t = useTranslations(); return ( -
+
-
-

Faturalar

-
-
-
+
- - - - - - - + + {t("business.invoices.table.number")} + {t("business.common.client")} + {t("business.common.amount")} + {t("business.common.status")} + {t("business.invoices.table.issueDate")} + {t("business.invoices.table.dueDate")} + {t("business.common.actions")} {invoices.length === 0 ? ( - ) : ( @@ -88,48 +64,17 @@ export function InvoicesClient({ invoices }: { invoices: InvoiceRow[] }) { - - - - + + + + + )) @@ -140,20 +85,59 @@ export function InvoicesClient({ invoices }: { invoices: InvoiceRow[] }) { - - {isAddModalOpen && ( -
- - -

Yeni Fatura Ekle

-

Bu özellik şu an geliştirme aşamasındadır.

-
- -
-
-
-
- )} ); } + +function InvoiceMenu() { + const t = useTranslations(); + + return ( + + + + + + + {t("business.common.edit")} + + + {t("business.invoices.actions.download")} + + + {t("business.invoices.actions.send")} + + + {t("business.invoices.actions.markPaid")} + + + {t("business.common.delete")} + + + + ); +} + +function InvoiceStatusBadge({ status }: { status: InvoiceRow["status"] }) { + const t = useTranslations(); + if (status === "draft") return {t("business.invoices.status.draft")}; + if (status === "sent") return {t("business.invoices.status.sent")}; + if (status === "paid") return {t("business.invoices.status.paid")}; + if (status === "overdue") return {t("business.invoices.status.overdue")}; + return {t("business.invoices.status.cancelled")}; +} + +function TableHead({ className = "", children }: { className?: string; children: React.ReactNode }) { + return ; +} + +function formatCurrency(amount: number, currency: string) { + return new Intl.NumberFormat(getDocumentIntlLocale(), { style: "currency", currency }).format(amount); +} + +function formatDate(value: string) { + return new Intl.DateTimeFormat(getDocumentIntlLocale(), { day: "2-digit", month: "short", year: "numeric" }).format(new Date(`${value}T00:00:00`)); +} diff --git a/app/(dashboard)/business/invoices/page.tsx b/app/(dashboard)/business/invoices/page.tsx index f9b2463..b5f735d 100644 --- a/app/(dashboard)/business/invoices/page.tsx +++ b/app/(dashboard)/business/invoices/page.tsx @@ -1,8 +1,12 @@ 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 { InvoicesClient, type InvoiceRow } from "./invoices-client"; export default async function InvoicesPage() { - const { actor, service } = await requireFreelancerBackend(); + const { context, actor, service } = await requireFreelancerBackend(); + const locale = await resolveFreelancerLocale(context); const clientNames = new Map(service.listClients(actor).map((client) => [client.id, client.name])); const projectNames = new Map(service.listProjects(actor).map((project) => [project.id, project.name])); const invoices: InvoiceRow[] = service.listInvoices(actor).map((invoice) => ({ @@ -18,5 +22,11 @@ export default async function InvoicesPage() { projectName: invoice.projectId ? projectNames.get(invoice.projectId) ?? null : null, })); - return ; + const i18nPayload = getClientI18nPayload(locale.locale, ["business", "common"]); + + return ( + + + + ); } diff --git a/app/(dashboard)/business/proposals/actions.ts b/app/(dashboard)/business/proposals/actions.ts new file mode 100644 index 0000000..9325f78 --- /dev/null +++ b/app/(dashboard)/business/proposals/actions.ts @@ -0,0 +1,83 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { getSqliteConnection } from "@/server/db/client"; +import { ContentTranslationService, parseContentTranslationsFromFormData } from "@/server/i18n/content"; +import { cleanText, requiredText } from "@/server/web/form-data"; +import { requireFreelancerBackend } from "@/server/web/freelancer"; + +const STATUSES = ["draft", "sent", "accepted", "rejected"] as const; + +function enumValue(value: FormDataEntryValue | null, values: T, fallback: T[number]): T[number] { + return typeof value === "string" && values.includes(value) ? value as T[number] : fallback; +} + +function payload(formData: FormData, translations: Record>, defaultLocale: string) { + const amountMinor = amountToMinor(formData.get("amount")); + if (amountMinor == null) throw new Error("business.proposals.errors.amountRequired"); + const localized = translations[defaultLocale] ?? {}; + + return { + clientId: cleanText(formData.get("client_id")), + projectId: cleanText(formData.get("project_id")), + title: localized.title ?? "", + description: localized.description ?? null, + amountMinor, + currency: cleanText(formData.get("currency")) ?? "TRY", + status: enumValue(formData.get("status"), STATUSES, "draft"), + validUntil: optionalBusinessDate(formData.get("valid_until")), + }; +} + +function amountToMinor(value: FormDataEntryValue | null) { + const normalized = typeof value === "string" ? value.trim().replace(",", ".") : ""; + if (!normalized) return null; + const amount = Number(normalized); + if (!Number.isFinite(amount) || amount < 0) throw new Error("business.proposals.errors.amountRequired"); + return Math.round((amount + Number.EPSILON) * 100); +} + +function optionalBusinessDate(value: FormDataEntryValue | null) { + const text = cleanText(value); + if (!text) return null; + const date = new Date(`${text}T00:00:00`); + if (Number.isNaN(date.getTime())) throw new Error("business.proposals.errors.invalidDate"); + return date; +} + +export async function createProposalRecord(formData: FormData) { + const backend = await requireFreelancerBackend(); + const i18n = new ContentTranslationService(getSqliteConnection().db); + const context = i18n.getLocalizationContext(backend.actor); + const translations = parseContentTranslationsFromFormData(formData, "proposal", context); + backend.service.createProposal(backend.actor, { + ...payload(formData, translations, context.defaultLocale), + translations, + }); + revalidatePath("/business/proposals"); +} + +export async function updateProposalRecord(formData: FormData) { + const backend = await requireFreelancerBackend(); + const i18n = new ContentTranslationService(getSqliteConnection().db); + const context = i18n.getLocalizationContext(backend.actor); + const translations = parseContentTranslationsFromFormData(formData, "proposal", context); + backend.service.updateProposal( + backend.actor, + requiredText(formData.get("id"), "business.proposals.errors.notFound"), + { + ...payload(formData, translations, context.defaultLocale), + translations, + }, + ); + revalidatePath("/business/proposals"); +} + +export async function deleteProposalRecord(formData: FormData) { + const { actor, service } = await requireFreelancerBackend(); + service.deleteProposal( + actor, + requiredText(formData.get("id"), "business.proposals.errors.deleteNotFound"), + ); + revalidatePath("/business/proposals"); +} diff --git a/app/(dashboard)/business/proposals/page.tsx b/app/(dashboard)/business/proposals/page.tsx index eed877f..7d24905 100644 --- a/app/(dashboard)/business/proposals/page.tsx +++ b/app/(dashboard)/business/proposals/page.tsx @@ -1,21 +1,59 @@ import { requireFreelancerBackend } from "@/server/web/freelancer"; -import { ProposalsClient, type ProposalRow } from "./proposals-client"; +import { getSqliteConnection } from "@/server/db/client"; +import { ContentTranslationService, type ContentTranslationRow } from "@/server/i18n/content"; +import { resolveFreelancerLocale } from "@/server/i18n/resolver"; +import { getClientI18nPayload } from "@/server/i18n/translator"; +import { I18nProvider } from "@/components/i18n/i18n-provider"; +import { ProposalsClient, type BusinessRelationOption, type ProposalRow } from "./proposals-client"; export default async function ProposalsPage() { - const { actor, service } = await requireFreelancerBackend(); + const { context, actor, service } = await requireFreelancerBackend(); + const locale = await resolveFreelancerLocale(context); + const content = new ContentTranslationService(getSqliteConnection().db); + const localization = content.getLocalizationContext(actor); const clientNames = new Map(service.listClients(actor).map((client) => [client.id, client.name])); const projectNames = new Map(service.listProjects(actor).map((project) => [project.id, project.name])); - const proposals: ProposalRow[] = service.listProposals(actor).map((proposal) => ({ - id: proposal.id, - title: proposal.title, - amount: proposal.amountMinor / 100, - currency: proposal.currency, - status: proposal.status, - valid_until: proposal.validUntil?.toISOString() ?? null, - created_at: proposal.createdAt.toISOString(), - clientName: proposal.clientId ? clientNames.get(proposal.clientId) ?? null : null, - projectName: proposal.projectId ? projectNames.get(proposal.projectId) ?? null : null, - })); + const rawProposals = service.listProposals(actor); + const translations = content.listBatch("proposal", rawProposals.map((proposal) => proposal.id)); + const proposals: ProposalRow[] = rawProposals.map((proposal) => { + const translationRows = translations.get(proposal.id) ?? []; + const resolved = content.resolveEntity("proposal", proposal, { + locale: locale.locale, + defaultLocale: localization.defaultLocale, + translations: translationRows, + }); - return ; + return { + id: proposal.id, + title: resolved.title, + description: resolved.description, + amount: proposal.amountMinor / 100, + currency: proposal.currency, + status: proposal.status, + valid_until: proposal.validUntil?.toISOString() ?? null, + client_id: proposal.clientId, + project_id: proposal.projectId, + created_at: proposal.createdAt.toISOString(), + clientName: proposal.clientId ? clientNames.get(proposal.clientId) ?? null : null, + projectName: proposal.projectId ? projectNames.get(proposal.projectId) ?? null : null, + translations: toLocalizedValues(translationRows), + }; + }); + const clients: BusinessRelationOption[] = service.listClients(actor).map((client) => ({ id: client.id, name: client.name })); + const projects: BusinessRelationOption[] = service.listProjects(actor).map((project) => ({ id: project.id, name: project.name, client_id: project.clientId })); + const i18nPayload = getClientI18nPayload(locale.locale, ["business", "common"]); + + return ( + + + + ); +} + +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)/business/proposals/proposals-client.tsx b/app/(dashboard)/business/proposals/proposals-client.tsx index ea75279..d2896ca 100644 --- a/app/(dashboard)/business/proposals/proposals-client.tsx +++ b/app/(dashboard)/business/proposals/proposals-client.tsx @@ -1,85 +1,98 @@ "use client"; +import { LocalizedFields, type LocalizedFieldLocale, type LocalizedFieldValues } from "@/components/i18n/localized-fields"; +import { useTranslations } from "@/components/i18n/i18n-provider"; +import { contentTranslationRegistry } from "@/lib/i18n/content"; import { getDocumentIntlLocale } from "@/lib/i18n/browser"; +import { createProposalRecord, deleteProposalRecord, updateProposalRecord } from "./actions"; +import { CheckCircle2, FileEdit, Mail, MoreHorizontal, Plus, Trash2, XCircle } from "lucide-react"; import { useState } from "react"; -import { format } from "date-fns"; -import { getDocumentDateFnsLocale } from "@/lib/i18n/date-fns"; -import { Plus, MoreHorizontal, FileEdit, Trash2, Mail, CheckCircle2, XCircle } from "lucide-react"; -import { Button, Card, CardContent, Badge } from "poyraz-ui/atoms"; +import { Badge, Button, Card, CardContent, Input, Label } from "poyraz-ui/atoms"; import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + toast, } from "poyraz-ui/molecules"; +export type BusinessRelationOption = { + id: string; + name: string; + client_id?: string | null; +}; + export type ProposalRow = { id: string; title: string; + description: string | null; amount: number; currency: string; status: "draft" | "sent" | "accepted" | "rejected"; valid_until: string | null; + client_id: string | null; + project_id: string | null; clientName: string | null; projectName: string | null; created_at: string; + translations?: LocalizedFieldValues; }; -export function ProposalsClient({ proposals }: { proposals: ProposalRow[] }) { - const [isAddModalOpen, setIsAddModalOpen] = useState(false); - - const formatCurrency = (amount: number, currency: string) => { - return new Intl.NumberFormat(getDocumentIntlLocale(), { style: "currency", currency }).format(amount); +type ProposalsClientProps = { + proposals: ProposalRow[]; + clients: BusinessRelationOption[]; + projects: BusinessRelationOption[]; + localization: { + defaultLocale: string; + locales: LocalizedFieldLocale[]; }; +}; - const getStatusBadge = (status: string) => { - switch (status) { - case "draft": - return Taslak; - case "sent": - return Gönderildi; - case "accepted": - return Kabul Edildi; - case "rejected": - return Reddedildi; - default: - return {status}; - } - }; +const proposalStatuses = ["draft", "sent", "accepted", "rejected"] as const; +const currencyOptions = ["TRY", "USD", "EUR", "GBP"] as const; + +export function ProposalsClient({ proposals, clients, projects, localization }: ProposalsClientProps) { + const t = useTranslations(); return ( -
- {/* Header */} +
-
-

Teklifler

-
- +

{t("business.proposals.title")}

+
- {/* List */} -
+
Fatura NoMüşteriTutarDurumDüzenlenme Tarihiİşlemler
- Henüz hiç fatura bulunmuyor. + + {t("business.invoices.empty")}
{invoice.invoice_number} - {invoice.projectName && ( -
{invoice.projectName}
- )} -
- {invoice.clientName || "-"} - - {formatCurrency(invoice.amount, invoice.currency)} - - {getStatusBadge(invoice.status)} - - {invoice.issue_date ? format(new Date(invoice.issue_date), "dd MMM yyyy", { locale: getDocumentDateFnsLocale() }) : "-"} + {invoice.projectName ? ( +
{invoice.projectName}
+ ) : null}
{invoice.clientName || "-"}{formatCurrency(invoice.amount, invoice.currency)}{invoice.issue_date ? formatDate(invoice.issue_date) : "-"}{invoice.due_date ? formatDate(invoice.due_date) : "-"} - - - - - - - Düzenle - - - PDF İndir - - - Gönder - - - Ödendi İşaretle - - - Sil - - - +
{children}
- - - - - - - + + {t("business.proposals.table.title")} + {t("business.common.client")} + {t("business.common.amount")} + {t("business.common.status")} + {t("business.proposals.table.validUntil")} + {t("business.common.actions")} {proposals.length === 0 ? ( - ) : ( @@ -87,48 +100,16 @@ export function ProposalsClient({ proposals }: { proposals: ProposalRow[] }) { - - - - + + + + )) @@ -139,21 +120,180 @@ export function ProposalsClient({ proposals }: { proposals: ProposalRow[] }) { - - {/* Add Modal Placeholder */} - {isAddModalOpen && ( -
- - -

Yeni Teklif Ekle

-

Bu özellik şu an geliştirme aşamasındadır.

-
- -
-
-
-
- )} ); } + +function ProposalMenu({ proposal, clients, projects, localization }: { + proposal: ProposalRow; + clients: BusinessRelationOption[]; + projects: BusinessRelationOption[]; + localization: ProposalsClientProps["localization"]; +}) { + const t = useTranslations(); + + return ( + + + + + + + + {t("business.proposals.actions.send")} + + + {t("business.proposals.status.accepted")} + + + {t("business.proposals.status.rejected")} + +
+ + + +
+
+ ); +} + +function ProposalDialog({ mode, proposal, clients, projects, localization, trigger = "button" }: { + mode: "create" | "edit"; + proposal?: ProposalRow; + clients: BusinessRelationOption[]; + projects: BusinessRelationOption[]; + localization: ProposalsClientProps["localization"]; + trigger?: "button" | "menu"; +}) { + const t = useTranslations(); + const [open, setOpen] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const action = mode === "create" ? createProposalRecord : updateProposalRecord; + + async function handleSubmit(formData: FormData) { + setIsSubmitting(true); + try { + await action(formData); + setOpen(false); + toast.success(t(mode === "create" ? "business.proposals.messages.created" : "business.proposals.messages.updated")); + } catch (error) { + toast.error(resolveTranslatedError(t, error, "business.proposals.errors.saveFailed")); + } finally { + setIsSubmitting(false); + } + } + + return ( + + + {trigger === "menu" ? ( + + ) : ( + + )} + + +
+ {proposal ? : null} + + {t(mode === "create" ? "business.proposals.form.createTitle" : "business.proposals.form.editTitle")} + {t("business.proposals.form.description")} + +
+
+ ({ + ...field, + label: t(`business.proposals.fields.${field.name}`), + placeholder: "placeholder" in field && typeof field.placeholder === "string" + ? t(`business.proposals.placeholders.${field.name}`) + : undefined, + }))} + values={proposal?.translations} + fallbackValues={{ title: proposal?.title, description: proposal?.description }} + /> +
+ + {t("business.common.none")} + {clients.map((client) => {client.name})} + + + {t("business.common.none")} + {projects.map((project) => {project.name})} + +
+
+ + + {currencyOptions.map((currency) => {currency})} + + + {proposalStatuses.map((status) => {t(`business.proposals.status.${status}`)})} + + +
+
+
+ + + + +
+
+ ); +} + +function ProposalStatusBadge({ status }: { status: ProposalRow["status"] }) { + const t = useTranslations(); + if (status === "draft") return {t("business.proposals.status.draft")}; + if (status === "sent") return {t("business.proposals.status.sent")}; + if (status === "accepted") return {t("business.proposals.status.accepted")}; + return {t("business.proposals.status.rejected")}; +} + +function TableHead({ className = "", children }: { className?: string; children: React.ReactNode }) { + return ; +} + +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return
{children}
; +} + +function SelectField({ name, label, defaultValue, children }: { name: string; label: string; defaultValue: string; children: React.ReactNode }) { + return ( + + + + ); +} + +function formatCurrency(amount: number, currency: string) { + return new Intl.NumberFormat(getDocumentIntlLocale(), { style: "currency", currency }).format(amount); +} + +function formatDate(value: string) { + return new Intl.DateTimeFormat(getDocumentIntlLocale(), { day: "2-digit", month: "short", year: "numeric" }).format(new Date(value)); +} + +function resolveTranslatedError(t: ReturnType, error: unknown, fallbackKey: string) { + if (!(error instanceof Error)) return t(fallbackKey); + if (/^business\./.test(error.message)) return t(error.message); + return error.message || t(fallbackKey); +} diff --git a/app/(dashboard)/business/subscriptions/actions.ts b/app/(dashboard)/business/subscriptions/actions.ts new file mode 100644 index 0000000..94505ba --- /dev/null +++ b/app/(dashboard)/business/subscriptions/actions.ts @@ -0,0 +1,75 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { getSqliteConnection } from "@/server/db/client"; +import { ContentTranslationService, parseContentTranslationsFromFormData } from "@/server/i18n/content"; +import { cleanText, requiredText } from "@/server/web/form-data"; +import { requireFreelancerBackend } from "@/server/web/freelancer"; + +const CYCLES = ["weekly", "monthly", "yearly"] as const; +const STATUSES = ["active", "cancelled"] as const; + +function enumValue(value: FormDataEntryValue | null, values: T, fallback: T[number]): T[number] { + return typeof value === "string" && values.includes(value) ? value as T[number] : fallback; +} + +function payload(formData: FormData, translations: Record>, defaultLocale: string) { + const amountMinor = amountToMinor(formData.get("amount")); + if (amountMinor == null) throw new Error("business.subscriptions.errors.amountRequired"); + const localized = translations[defaultLocale] ?? {}; + + return { + name: localized.name ?? "", + category: localized.category ?? null, + amountMinor, + currency: cleanText(formData.get("currency")) ?? "TRY", + billingCycle: enumValue(formData.get("billing_cycle"), CYCLES, "monthly"), + nextBillingDate: cleanText(formData.get("next_billing_date")), + status: enumValue(formData.get("status"), STATUSES, "active"), + }; +} + +function amountToMinor(value: FormDataEntryValue | null) { + const normalized = typeof value === "string" ? value.trim().replace(",", ".") : ""; + if (!normalized) return null; + const amount = Number(normalized); + if (!Number.isFinite(amount) || amount < 0) throw new Error("business.subscriptions.errors.amountRequired"); + return Math.round((amount + Number.EPSILON) * 100); +} + +export async function createSubscriptionRecord(formData: FormData) { + const backend = await requireFreelancerBackend(); + const i18n = new ContentTranslationService(getSqliteConnection().db); + const context = i18n.getLocalizationContext(backend.actor); + const translations = parseContentTranslationsFromFormData(formData, "subscription", context); + backend.service.createSubscription(backend.actor, { + ...payload(formData, translations, context.defaultLocale), + translations, + }); + revalidatePath("/business/subscriptions"); +} + +export async function updateSubscriptionRecord(formData: FormData) { + const backend = await requireFreelancerBackend(); + const i18n = new ContentTranslationService(getSqliteConnection().db); + const context = i18n.getLocalizationContext(backend.actor); + const translations = parseContentTranslationsFromFormData(formData, "subscription", context); + backend.service.updateSubscription( + backend.actor, + requiredText(formData.get("id"), "business.subscriptions.errors.notFound"), + { + ...payload(formData, translations, context.defaultLocale), + translations, + }, + ); + revalidatePath("/business/subscriptions"); +} + +export async function deleteSubscriptionRecord(formData: FormData) { + const { actor, service } = await requireFreelancerBackend(); + service.deleteSubscription( + actor, + requiredText(formData.get("id"), "business.subscriptions.errors.deleteNotFound"), + ); + revalidatePath("/business/subscriptions"); +} diff --git a/app/(dashboard)/business/subscriptions/page.tsx b/app/(dashboard)/business/subscriptions/page.tsx index 1505115..767bbdd 100644 --- a/app/(dashboard)/business/subscriptions/page.tsx +++ b/app/(dashboard)/business/subscriptions/page.tsx @@ -1,19 +1,52 @@ import { requireFreelancerBackend } from "@/server/web/freelancer"; +import { getSqliteConnection } from "@/server/db/client"; +import { ContentTranslationService, type ContentTranslationRow } from "@/server/i18n/content"; +import { resolveFreelancerLocale } from "@/server/i18n/resolver"; +import { getClientI18nPayload } from "@/server/i18n/translator"; +import { I18nProvider } from "@/components/i18n/i18n-provider"; import { SubscriptionsClient, type SubscriptionRow } from "./subscriptions-client"; export default async function SubscriptionsPage() { - const { actor, service } = await requireFreelancerBackend(); - const subscriptions: SubscriptionRow[] = service.listSubscriptions(actor).map((subscription) => ({ - id: subscription.id, - name: subscription.name, - amount: subscription.amountMinor / 100, - currency: subscription.currency, - billing_cycle: subscription.billingCycle, - status: subscription.status, - category: subscription.category, - next_billing_date: subscription.nextBillingDate, - created_at: subscription.createdAt.toISOString(), - })); + const { context, actor, service } = await requireFreelancerBackend(); + const locale = await resolveFreelancerLocale(context); + const content = new ContentTranslationService(getSqliteConnection().db); + const localization = content.getLocalizationContext(actor); + const rawSubscriptions = service.listSubscriptions(actor); + const translations = content.listBatch("subscription", rawSubscriptions.map((subscription) => subscription.id)); + const subscriptions: SubscriptionRow[] = rawSubscriptions.map((subscription) => { + const translationRows = translations.get(subscription.id) ?? []; + const resolved = content.resolveEntity("subscription", subscription, { + locale: locale.locale, + defaultLocale: localization.defaultLocale, + translations: translationRows, + }); - return ; + return { + id: subscription.id, + name: resolved.name, + amount: subscription.amountMinor / 100, + currency: subscription.currency, + billing_cycle: subscription.billingCycle, + status: subscription.status, + category: resolved.category, + next_billing_date: subscription.nextBillingDate, + created_at: subscription.createdAt.toISOString(), + translations: toLocalizedValues(translationRows), + }; + }); + const i18nPayload = getClientI18nPayload(locale.locale, ["business", "common"]); + + return ( + + + + ); +} + +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)/business/subscriptions/subscriptions-client.tsx b/app/(dashboard)/business/subscriptions/subscriptions-client.tsx index 1564f3e..fc2ae88 100644 --- a/app/(dashboard)/business/subscriptions/subscriptions-client.tsx +++ b/app/(dashboard)/business/subscriptions/subscriptions-client.tsx @@ -1,16 +1,31 @@ "use client"; +import { LocalizedFields, type LocalizedFieldLocale, type LocalizedFieldValues } from "@/components/i18n/localized-fields"; +import { useTranslations } from "@/components/i18n/i18n-provider"; +import { contentTranslationRegistry } from "@/lib/i18n/content"; import { getDocumentIntlLocale } from "@/lib/i18n/browser"; +import { createSubscriptionRecord, deleteSubscriptionRecord, updateSubscriptionRecord } from "./actions"; +import { CreditCard, FileEdit, MoreHorizontal, Plus, RefreshCw, StopCircle, Trash2 } from "lucide-react"; import { useState } from "react"; -import { format } from "date-fns"; -import { getDocumentDateFnsLocale } from "@/lib/i18n/date-fns"; -import { CreditCard, Plus, MoreHorizontal, FileEdit, Trash2, StopCircle, RefreshCw } from "lucide-react"; -import { Button, Card, CardContent, Badge } from "poyraz-ui/atoms"; +import { Badge, Button, Card, CardContent, Input, Label } from "poyraz-ui/atoms"; import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + toast, } from "poyraz-ui/molecules"; export type SubscriptionRow = { @@ -23,137 +38,81 @@ export type SubscriptionRow = { category: string | null; next_billing_date: string | null; created_at: string; + translations?: LocalizedFieldValues; }; -export function SubscriptionsClient({ subscriptions }: { subscriptions: SubscriptionRow[] }) { - const [isAddModalOpen, setIsAddModalOpen] = useState(false); - - const formatCurrency = (amount: number, currency: string) => { - return new Intl.NumberFormat(getDocumentIntlLocale(), { style: "currency", currency }).format(amount); +type SubscriptionsClientProps = { + subscriptions: SubscriptionRow[]; + localization: { + defaultLocale: string; + locales: LocalizedFieldLocale[]; }; +}; - const getCycleBadge = (cycle: string) => { - switch (cycle) { - case "monthly": - return "Aylık"; - case "yearly": - return "Yıllık"; - case "weekly": - return "Haftalık"; - default: - return cycle; - } - }; +const billingCycles = ["weekly", "monthly", "yearly"] as const; +const subscriptionStatuses = ["active", "cancelled"] as const; +const currencyOptions = ["TRY", "USD", "EUR", "GBP"] as const; +export function SubscriptionsClient({ subscriptions, localization }: SubscriptionsClientProps) { + const t = useTranslations(); const activeMonthlyTotal = subscriptions - .filter(s => s.status === "active") - .reduce((acc, s) => { - let monthlyEquivalent = s.amount; - if (s.billing_cycle === "yearly") monthlyEquivalent = s.amount / 12; - if (s.billing_cycle === "weekly") monthlyEquivalent = s.amount * 4.33; - return acc + monthlyEquivalent; - }, 0); + .filter((subscription) => subscription.status === "active") + .reduce((total, subscription) => total + monthlyEquivalent(subscription), 0); return ( -
+
-
-

Abonelikler ve Masraflar

-
- +

{t("business.subscriptions.title")}

+
-
- +
+ -
+
-

Aylık Tahmini Gider

+

{t("business.subscriptions.stats.monthlyTotal")}

-

- {formatCurrency(activeMonthlyTotal, "TRY")} -

-

Aktif aboneliklerin aylık ortalaması

+

{formatCurrency(activeMonthlyTotal, "TRY")}

+

{t("business.subscriptions.stats.monthlyTotalDesc")}

-
+
Teklif AdıMüşteriTutarDurumGeçerlilikİşlemler
- Henüz hiç teklif bulunmuyor. + + {t("business.proposals.empty")}
{proposal.title} - {proposal.projectName && ( -
{proposal.projectName}
- )} -
- {proposal.clientName || "-"} - - {formatCurrency(proposal.amount, proposal.currency)} - - {getStatusBadge(proposal.status)} - - {proposal.valid_until ? format(new Date(proposal.valid_until), "dd MMM yyyy", { locale: getDocumentDateFnsLocale() }) : "-"} + {proposal.projectName ? ( +
{proposal.projectName}
+ ) : null}
{proposal.clientName || "-"}{formatCurrency(proposal.amount, proposal.currency)}{proposal.valid_until ? formatDate(proposal.valid_until) : "-"} - - - - - - - Düzenle - - - Gönder - - - Kabul Edildi - - - Reddedildi - - - Sil - - - +
{children}
- - - - - - - - + + {t("business.subscriptions.table.name")} + {t("business.subscriptions.fields.category")} + {t("business.common.amount")} + {t("business.subscriptions.fields.billingCycle")} + {t("business.common.status")} + {t("business.subscriptions.fields.nextBillingDate")} + {t("business.common.actions")} {subscriptions.length === 0 ? ( - ) : ( - subscriptions.map((sub) => ( - - - - - - - + subscriptions.map((subscription) => ( + + + + + + + )) @@ -164,20 +123,167 @@ export function SubscriptionsClient({ subscriptions }: { subscriptions: Subscrip - - {isAddModalOpen && ( -
- - -

Yeni Abonelik Ekle

-

Bu özellik şu an geliştirme aşamasındadır.

-
- -
-
-
-
- )} ); } + +function SubscriptionMenu({ subscription, localization }: { subscription: SubscriptionRow; localization: SubscriptionsClientProps["localization"] }) { + const t = useTranslations(); + return ( + + + + + + + + {subscription.status === "active" ? : } + {subscription.status === "active" ? t("business.subscriptions.actions.cancel") : t("business.subscriptions.actions.reactivate")} + +
+ + + +
+
+ ); +} + +function SubscriptionDialog({ mode, subscription, localization, trigger = "button" }: { + mode: "create" | "edit"; + subscription?: SubscriptionRow; + localization: SubscriptionsClientProps["localization"]; + trigger?: "button" | "menu"; +}) { + const t = useTranslations(); + const [open, setOpen] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const action = mode === "create" ? createSubscriptionRecord : updateSubscriptionRecord; + + async function handleSubmit(formData: FormData) { + setIsSubmitting(true); + try { + await action(formData); + setOpen(false); + toast.success(t(mode === "create" ? "business.subscriptions.messages.created" : "business.subscriptions.messages.updated")); + } catch (error) { + toast.error(resolveTranslatedError(t, error, "business.subscriptions.errors.saveFailed")); + } finally { + setIsSubmitting(false); + } + } + + return ( + + + {trigger === "menu" ? ( + + ) : ( + + )} + + +
+ {subscription ? : null} + + {t(mode === "create" ? "business.subscriptions.form.createTitle" : "business.subscriptions.form.editTitle")} + {t("business.subscriptions.form.description")} + +
+
+ ({ + ...field, + label: t(`business.subscriptions.fields.${field.name}`), + placeholder: "placeholder" in field && typeof field.placeholder === "string" + ? t(`business.subscriptions.placeholders.${field.name}`) + : undefined, + }))} + values={subscription?.translations} + fallbackValues={{ name: subscription?.name, category: subscription?.category }} + /> +
+ + + {currencyOptions.map((currency) => {currency})} + + + {billingCycles.map((cycle) => {t(`business.subscriptions.billingCycle.${cycle}`)})} + + + {subscriptionStatuses.map((status) => {t(`business.subscriptions.status.${status}`)})} + +
+ + + +
+
+ + + + +
+
+ ); +} + +function SubscriptionStatusBadge({ status }: { status: SubscriptionRow["status"] }) { + const t = useTranslations(); + return status === "active" + ? {t("business.subscriptions.status.active")} + : {t("business.subscriptions.status.cancelled")}; +} + +function TableHead({ className = "", children }: { className?: string; children: React.ReactNode }) { + return ; +} + +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return
{children}
; +} + +function SelectField({ name, label, defaultValue, children }: { name: string; label: string; defaultValue: string; children: React.ReactNode }) { + return ( + + + + ); +} + +function monthlyEquivalent(subscription: SubscriptionRow) { + if (subscription.billing_cycle === "yearly") return subscription.amount / 12; + if (subscription.billing_cycle === "weekly") return subscription.amount * 4.33; + return subscription.amount; +} + +function formatCurrency(amount: number, currency: string) { + return new Intl.NumberFormat(getDocumentIntlLocale(), { style: "currency", currency }).format(amount); +} + +function formatDate(value: string) { + return new Intl.DateTimeFormat(getDocumentIntlLocale(), { day: "2-digit", month: "short", year: "numeric" }).format(new Date(`${value}T00:00:00`)); +} + +function resolveTranslatedError(t: ReturnType, error: unknown, fallbackKey: string) { + if (!(error instanceof Error)) return t(fallbackKey); + if (/^business\./.test(error.message)) return t(error.message); + return error.message || t(fallbackKey); +} diff --git a/app/(dashboard)/finance/actions.ts b/app/(dashboard)/finance/actions.ts index 3d21eda..756ee97 100644 --- a/app/(dashboard)/finance/actions.ts +++ b/app/(dashboard)/finance/actions.ts @@ -1,6 +1,11 @@ "use server"; import { revalidatePath } from "next/cache"; +import { getSqliteConnection } from "@/server/db/client"; +import { + ContentTranslationService, + parseContentTranslationsFromFormData, +} from "@/server/i18n/content"; import { cleanText, decimalToMinor, requiredText } from "@/server/web/form-data"; import { requireFreelancerBackend } from "@/server/web/freelancer"; @@ -11,19 +16,20 @@ function enumValue(value: FormDataEntryValue | null return typeof value === "string" && values.includes(value) ? value as T[number] : fallback; } -function payload(formData: FormData) { +function payload(formData: FormData, translations?: Record>, defaultLocale = "tr") { const amountMinor = decimalToMinor(formData.get("amount")); - if (amountMinor == null) throw new Error("Tutar zorunludur."); + if (amountMinor == null) throw new Error("finance.errors.amountRequired"); + const localized = translations?.[defaultLocale] ?? {}; return { type: enumValue(formData.get("type"), TYPES, "expense"), amountMinor, currency: cleanText(formData.get("currency")) ?? "USD", transactionDate: cleanText(formData.get("transaction_date")) ?? new Date().toISOString().slice(0, 10), - category: cleanText(formData.get("category")), + category: localized.category ?? cleanText(formData.get("category")), paymentStatus: enumValue(formData.get("payment_status"), STATUSES, "planned"), clientId: cleanText(formData.get("client_id")), projectId: cleanText(formData.get("project_id")), - description: cleanText(formData.get("description")), + description: localized.description ?? cleanText(formData.get("description")), }; } @@ -38,9 +44,13 @@ function completeRelations( export async function createFinanceTransactionRecord(formData: FormData) { const backend = await requireFreelancerBackend(); + const i18n = new ContentTranslationService(getSqliteConnection().db); + const context = i18n.getLocalizationContext(backend.actor); + const translations = parseContentTranslationsFromFormData(formData, "finance_transaction", context); + const data = payload(formData, translations, context.defaultLocale); backend.service.createFinanceTransaction( backend.actor, - completeRelations(payload(formData), backend.service, backend.actor), + { ...completeRelations(data, backend.service, backend.actor), translations }, ); revalidatePath("/finance"); revalidatePath("/clients"); @@ -49,11 +59,15 @@ export async function createFinanceTransactionRecord(formData: FormData) { export async function updateFinanceTransactionRecord(formData: FormData) { const backend = await requireFreelancerBackend(); - const id = requiredText(formData.get("id"), "Finans kaydı bulunamadı."); + const i18n = new ContentTranslationService(getSqliteConnection().db); + const context = i18n.getLocalizationContext(backend.actor); + const translations = parseContentTranslationsFromFormData(formData, "finance_transaction", context); + const id = requiredText(formData.get("id"), "finance.errors.notFound"); + const data = payload(formData, translations, context.defaultLocale); backend.service.updateFinanceTransaction( backend.actor, id, - completeRelations(payload(formData), backend.service, backend.actor), + { ...completeRelations(data, backend.service, backend.actor), translations }, ); revalidatePath("/finance"); revalidatePath("/clients"); @@ -64,7 +78,7 @@ export async function deleteFinanceTransactionRecord(formData: FormData) { const { actor, service } = await requireFreelancerBackend(); service.deleteFinanceTransaction( actor, - requiredText(formData.get("id"), "Silinecek finans kaydı bulunamadı."), + requiredText(formData.get("id"), "finance.errors.deleteNotFound"), ); revalidatePath("/finance"); revalidatePath("/clients"); diff --git a/app/(dashboard)/finance/finance-client.tsx b/app/(dashboard)/finance/finance-client.tsx index 6388ac3..8579061 100644 --- a/app/(dashboard)/finance/finance-client.tsx +++ b/app/(dashboard)/finance/finance-client.tsx @@ -2,12 +2,14 @@ import { getDocumentIntlLocale } from "@/lib/i18n/browser"; import { useTranslations } from "@/components/i18n/i18n-provider"; +import { LocalizedFields, type LocalizedFieldLocale, type LocalizedFieldValues } from "@/components/i18n/localized-fields"; +import { contentTranslationRegistry } from "@/lib/i18n/content"; import { createFinanceTransactionRecord, deleteFinanceTransactionRecord, updateFinanceTransactionRecord, } from "@/app/(dashboard)/finance/actions"; -import { Badge, Button, Card, CardContent, Input, Label, Textarea } from "poyraz-ui/atoms"; +import { Badge, Button, Card, CardContent, Input, Label } from "poyraz-ui/atoms"; import { Dialog, DialogContent, @@ -57,18 +59,7 @@ export type FinanceTransactionItem = { clientName: string | null; projectName: string | null; description: string | null; -}; - -const typeLabels = { - income: "Gelir", - expense: "Gider", -}; - -const paymentStatusLabels = { - planned: "Planlandı", - pending: "Bekliyor", - paid: "Ödendi", - cancelled: "İptal edildi", + translations?: LocalizedFieldValues; }; const paymentStatusClasses = { @@ -79,32 +70,34 @@ const paymentStatusClasses = { }; const currencyOptions = [ - { value: "USD", label: "Dolar (USD)" }, - { value: "EUR", label: "Euro (EUR)" }, - { value: "TRY", label: "Türk lirası (TRY)" }, - { value: "GBP", label: "Sterlin (GBP)" }, - { value: "CAD", label: "Kanada doları (CAD)" }, - { value: "AUD", label: "Avustralya doları (AUD)" }, + { value: "USD", labelKey: "finance.currency.usd" }, + { value: "EUR", labelKey: "finance.currency.eur" }, + { value: "TRY", labelKey: "finance.currency.try" }, + { value: "GBP", labelKey: "finance.currency.gbp" }, + { value: "CAD", labelKey: "finance.currency.cad" }, + { value: "AUD", labelKey: "finance.currency.aud" }, ]; -// Dizilim ve featured alanı, özet şeridinde hangi metriklerin önce -// gösterileceğini tek bir yerden değiştirmeyi sağlar. const financeSummaryCardConfig = [ - { key: "afterTax", label: "Vergi Sonrası Net", tone: "green", icon: Wallet, featured: true }, - { key: "net", label: "Brüt kazanç", tone: "primary", icon: Wallet, featured: true }, - { key: "income", label: "Aylık gelir", tone: "green", icon: ArrowUpRight, featured: false }, - { key: "expense", label: "Aylık gider", tone: "rose", icon: ArrowDownRight, featured: false }, - { key: "pending", label: "Bekleyen", tone: "amber", icon: Wallet, featured: false }, - { key: "tax", label: "KDV Tahmini (%20)", tone: "amber", icon: Wallet, featured: false }, + { key: "afterTax", tone: "green", icon: Wallet, featured: true }, + { key: "net", tone: "primary", icon: Wallet, featured: true }, + { key: "income", tone: "green", icon: ArrowUpRight, featured: false }, + { key: "expense", tone: "rose", icon: ArrowDownRight, featured: false }, + { key: "pending", tone: "amber", icon: Wallet, featured: false }, + { key: "tax", tone: "amber", icon: Wallet, featured: false }, ] as const; type FinanceClientProps = { transactions: FinanceTransactionItem[]; clients: FinanceRelationOption[]; projects: FinanceRelationOption[]; + localization: { + defaultLocale: string; + locales: LocalizedFieldLocale[]; + }; }; -export function FinanceClient({ transactions, clients, projects }: FinanceClientProps) { +export function FinanceClient({ transactions, clients, projects, localization }: FinanceClientProps) { const t = useTranslations(); const [query, setQuery] = useState(""); const [monthFilter, setMonthFilter] = useState(() => new Date().toISOString().slice(0, 7)); @@ -120,7 +113,7 @@ export function FinanceClient({ transactions, clients, projects }: FinanceClient transaction.category, transaction.clientName, transaction.projectName, - typeLabels[transaction.type], + t(`finance.types.${transaction.type}`), ] .filter(Boolean) .some((value) => value!.toLowerCase().includes(normalizedQuery)), @@ -131,6 +124,7 @@ export function FinanceClient({ transactions, clients, projects }: FinanceClient const categoryBreakdown = useMemo(() => calculateExpenseCategories(filteredByMonth), [filteredByMonth]); const summaryCards = financeSummaryCardConfig.map((card) => ({ ...card, + label: t(`finance.summary.${card.key}`), value: formatCurrency(summary[card.key]), })); @@ -154,7 +148,7 @@ export function FinanceClient({ transactions, clients, projects }: FinanceClient
- +
@@ -162,10 +156,10 @@ export function FinanceClient({ transactions, clients, projects }: FinanceClient

- Finans özeti + {t("finance.summary.title")}

- Öne çıkan metrikler önce gösterilir; diğer kartlar arasında kaydırarak ilerleyebilirsin. + {t("finance.summary.description")}

@@ -174,7 +168,7 @@ export function FinanceClient({ transactions, clients, projects }: FinanceClient type="button" variant="secondary" size="icon" - aria-label="Önceki finans özet kartları" + aria-label={t("finance.summary.previous")} onClick={() => scrollSummary(-1)} > @@ -184,7 +178,7 @@ export function FinanceClient({ transactions, clients, projects }: FinanceClient type="button" variant="secondary" size="icon" - aria-label="Sonraki finans özet kartları" + aria-label={t("finance.summary.next")} onClick={() => scrollSummary(1)} > @@ -195,7 +189,7 @@ export function FinanceClient({ transactions, clients, projects }: FinanceClient
{ if (event.key === "ArrowLeft") { @@ -228,16 +222,16 @@ export function FinanceClient({ transactions, clients, projects }: FinanceClient
-

İşlem listesi

+

{t("finance.list.title")}

- {filteredTransactions.length} kayıt görüntüleniyor. + {t("finance.list.description", { count: filteredTransactions.length })}

setQuery(event.target.value)} - placeholder="Kategori, müşteri, proje veya açıklama ara" + placeholder={t("finance.list.searchPlaceholder")} className="sm:w-80" />
- İşlem - Tarih - Tutar - Durum - İşlem + {t("finance.list.headers.transaction")} + {t("finance.list.headers.date")} + {t("finance.list.headers.amount")} + {t("finance.list.headers.status")} + {t("finance.list.headers.action")}
{filteredTransactions.map((transaction) => ( @@ -266,6 +260,7 @@ export function FinanceClient({ transactions, clients, projects }: FinanceClient transaction={transaction} clients={clients} projects={projects} + localization={localization} /> ))}
@@ -280,15 +275,19 @@ export function FinanceClient({ transactions, clients, projects }: FinanceClient
-

Gider kategorileri

-

Aylık gider dağılımı

+

{t("finance.expenseCategories.title")}

+

{t("finance.expenseCategories.description")}

{categoryBreakdown.length > 0 ? (
{categoryBreakdown.map((item) => (
- {item.category} + + {item.category === "__uncategorized" + ? t("finance.expenseCategories.noCategory") + : item.category} + {formatCurrency(item.amount)}
@@ -301,7 +300,7 @@ export function FinanceClient({ transactions, clients, projects }: FinanceClient ))}
) : ( -

Bu ay gider kaydı yok.

+

{t("finance.expenseCategories.noExpense")}

)} @@ -314,11 +313,17 @@ function TransactionRow({ transaction, clients, projects, + localization, }: { transaction: FinanceTransactionItem; clients: FinanceRelationOption[]; projects: FinanceRelationOption[]; + localization: { + defaultLocale: string; + locales: LocalizedFieldLocale[]; + }; }) { + const t = useTranslations(); const isIncome = transaction.type === "income"; return ( @@ -329,10 +334,10 @@ function TransactionRow({
- {transaction.description || typeLabels[transaction.type]} + {transaction.description || t(`finance.types.${transaction.type}`)}
- {transaction.category || "Kategori yok"} · {transaction.projectName || transaction.clientName || "Bağlantı yok"} + {transaction.category || t("finance.expenseCategories.noCategory")} · {transaction.projectName || transaction.clientName || t("finance.form.noClient")}
@@ -343,16 +348,16 @@ function TransactionRow({
- {paymentStatusLabels[transaction.payment_status]} + {t(`finance.paymentStatus.${transaction.payment_status}`)}
- +
@@ -365,12 +370,18 @@ function FinanceDialog({ transaction, clients, projects, + localization, }: { mode: "create" | "edit"; transaction?: FinanceTransactionItem; clients: FinanceRelationOption[]; projects: FinanceRelationOption[]; + localization: { + defaultLocale: string; + locales: LocalizedFieldLocale[]; + }; }) { + const t = useTranslations(); const [open, setOpen] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); const action = mode === "create" ? createFinanceTransactionRecord : updateFinanceTransactionRecord; @@ -380,13 +391,9 @@ function FinanceDialog({ try { await action(formData); setOpen(false); - toast.success(mode === "create" ? "İşlem eklendi." : "İşlem güncellendi."); + toast.success(mode === "create" ? t("finance.form.messages.createSuccess") : t("finance.form.messages.updateSuccess")); } catch (error) { - toast.error( - error instanceof Error - ? error.message - : "Finans işlemi kaydedilirken beklenmeyen bir hata oluştu.", - ); + toast.error(resolveTranslatedError(t, error, "finance.form.messages.error")); } finally { setIsSubmitting(false); } @@ -397,23 +404,23 @@ function FinanceDialog({
{transaction ? : null} - {mode === "create" ? "Yeni finans işlemi" : "Finans işlemini düzenle"} - Gelir veya gider kaydını müşteri/proje bağlantısıyla kaydet. + {mode === "create" ? t("finance.form.createTitle") : t("finance.form.editTitle")} + {t("finance.form.description")}
- +
@@ -426,11 +433,17 @@ function FinanceFormFields({ transaction, clients, projects, + localization, }: { transaction?: FinanceTransactionItem; clients: FinanceRelationOption[]; projects: FinanceRelationOption[]; + localization: { + defaultLocale: string; + locales: LocalizedFieldLocale[]; + }; }) { + const t = useTranslations(); const [clientId, setClientId] = useState(transaction?.client_id || "__none"); const [projectId, setProjectId] = useState(transaction?.project_id || "__none"); const selectedProject = @@ -469,32 +482,32 @@ function FinanceFormFields({ return (
- - Gelir - Gider + + {t("finance.types.income")} + {t("finance.types.expense")} - - Planlandı - Bekliyor - Ödendi - İptal edildi + + {t("finance.paymentStatus.planned")} + {t("finance.paymentStatus.pending")} + {t("finance.paymentStatus.paid")} + {t("finance.paymentStatus.cancelled")}
- +
- +
- +
-
- - -
+ f.name === "category") + .map((f) => ({ + ...f, + label: t(`finance.fields.${f.name}`) || f.label, + placeholder: "placeholder" in f && typeof f.placeholder === "string" + ? t(`finance.placeholders.${f.name}`) || f.placeholder + : undefined, + }))} + values={transaction?.translations} + fallbackValues={{ + category: transaction?.category, + }} + />
- + {shouldLockClient ? : null}
- +
-
- -
Abonelik AdıKategoriTutarPeriyotDurumSonraki Ödemeİşlemler
- Henüz hiç abonelik bulunmuyor. + + {t("business.subscriptions.empty")}
- {sub.name} - - {sub.category || "-"} - - {formatCurrency(sub.amount, sub.currency)} - - {getCycleBadge(sub.billing_cycle)} - - {sub.status === "active" ? ( - Aktif - ) : ( - İptal Edildi - )} - - {sub.next_billing_date ? format(new Date(sub.next_billing_date), "dd MMM yyyy", { locale: getDocumentDateFnsLocale() }) : "-"} -
{subscription.name}{subscription.category || "-"}{formatCurrency(subscription.amount, subscription.currency)}{t(`business.subscriptions.billingCycle.${subscription.billing_cycle}`)}{subscription.next_billing_date ? formatDate(subscription.next_billing_date) : "-"} - - - - - - - Düzenle - - {sub.status === "active" ? ( - - İptal Et - - ) : ( - - Yeniden Aktifleştir - - )} - - Sil - - - +
{children}