diff --git a/app/(dashboard)/analytics/analytics-client.tsx b/app/(dashboard)/analytics/analytics-client.tsx index 6b29c0e..3fff6e0 100644 --- a/app/(dashboard)/analytics/analytics-client.tsx +++ b/app/(dashboard)/analytics/analytics-client.tsx @@ -1,6 +1,8 @@ "use client"; import { useRouter, usePathname, useSearchParams } from "next/navigation"; +import { useTranslations } from "@/components/i18n/i18n-provider"; +import { getDocumentIntlLocale } from "@/lib/i18n/browser"; import { Card, CardContent } from "poyraz-ui/atoms"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "poyraz-ui/molecules"; import { @@ -24,6 +26,7 @@ type AnalyticsClientProps = { const COLORS = ["var(--poyraz-primary)", "var(--poyraz-destructive)", "#eab308", "#3b82f6", "#8b5cf6"]; export function AnalyticsClient({ data }: AnalyticsClientProps) { + const t = useTranslations(); const router = useRouter(); const pathname = usePathname(); const searchParams = useSearchParams(); @@ -37,28 +40,36 @@ export function AnalyticsClient({ data }: AnalyticsClientProps) { const { projectIncomeData, completedTasks, activeTasks } = data.metrics; const taskStatusData = [ - { name: "Tamamlanan", value: completedTasks }, - { name: "Devam Eden", value: activeTasks } + { name: t("analytics.tasks.completed"), value: completedTasks }, + { name: t("analytics.tasks.ongoing"), value: activeTasks } ]; + const formatCurrency = (val: number) => { + return new Intl.NumberFormat(getDocumentIntlLocale(), { + style: "currency", + currency: "USD", + maximumFractionDigits: 0, + }).format(val); + }; + return (

- Performans ve Finans Analizi + {t("analytics.title")}

@@ -67,7 +78,7 @@ export function AnalyticsClient({ data }: AnalyticsClientProps) {
-

Proje Bazlı Gelir Dağılımı

+

{t("analytics.sections.incomeDistribution")}

{projectIncomeData.length > 0 ? ( @@ -86,7 +97,7 @@ export function AnalyticsClient({ data }: AnalyticsClientProps) { ))} `₺${Number(value ?? 0)}`} + formatter={(value) => formatCurrency(Number(value ?? 0))} contentStyle={{ backgroundColor: 'var(--poyraz-background)', borderColor: 'var(--poyraz-border)', @@ -97,7 +108,7 @@ export function AnalyticsClient({ data }: AnalyticsClientProps) { ) : ( -
Veri bulunamadı.
+
{t("analytics.empty")}
)}
@@ -105,7 +116,7 @@ export function AnalyticsClient({ data }: AnalyticsClientProps) { -

Görev Durumu Analizi

+

{t("analytics.sections.taskStatus")}

@@ -124,7 +135,7 @@ export function AnalyticsClient({ data }: AnalyticsClientProps) {
- Görev Sayısı + {t("analytics.tasks.count")}
{entry.value} diff --git a/app/(dashboard)/analytics/page.tsx b/app/(dashboard)/analytics/page.tsx index 26b63b6..87273f1 100644 --- a/app/(dashboard)/analytics/page.tsx +++ b/app/(dashboard)/analytics/page.tsx @@ -1,6 +1,10 @@ import { AnalyticsClient, type AnalyticsData } from "./analytics-client"; import { parseDashboardRange, resolveDashboardRange } from "@/server/services/analytics-range"; import { requireFreelancerBackend } from "@/server/web/freelancer"; +import { requireFreelancer } from "@/server/auth/session"; +import { resolveFreelancerLocale } from "@/server/i18n/resolver"; +import { getClientI18nPayload } from "@/server/i18n/translator"; +import { I18nProvider } from "@/components/i18n/i18n-provider"; export const metadata = { title: "Analizler" }; @@ -9,11 +13,19 @@ export default async function AnalyticsPage({ }: { searchParams: Promise<{ [key: string]: string | string[] | undefined }>; }) { + const context = await requireFreelancer(); + const resolvedLocale = await resolveFreelancerLocale(context); + const payload = getClientI18nPayload(resolvedLocale.locale, ["analytics", "common"]); + const params = await searchParams; const range = parseDashboardRange(params.range); const { actor, service } = await requireFreelancerBackend(); const metrics = service.getFreelancerAnalytics(actor, resolveDashboardRange(range)); const data: AnalyticsData = { metrics, range }; - return ; + return ( + + + + ); } diff --git a/app/(dashboard)/business/invoices/invoices-client.tsx b/app/(dashboard)/business/invoices/invoices-client.tsx index d78f284..385837b 100644 --- a/app/(dashboard)/business/invoices/invoices-client.tsx +++ b/app/(dashboard)/business/invoices/invoices-client.tsx @@ -1,10 +1,9 @@ "use client"; -import { useState } from "react"; -import { format } from "date-fns"; -import { tr } from "date-fns/locale"; -import { Plus, MoreHorizontal, FileEdit, Trash2, Send, Download, CheckCircle2 } from "lucide-react"; -import { Button, Card, CardContent, Badge } from "poyraz-ui/atoms"; +import { getDocumentIntlLocale } from "@/lib/i18n/browser"; +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, @@ -26,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("tr-TR", { 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 ? ( - ) : ( @@ -87,48 +64,17 @@ export function InvoicesClient({ invoices }: { invoices: InvoiceRow[] }) { - - - - + + + + + )) @@ -139,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 b620c7e..d2896ca 100644 --- a/app/(dashboard)/business/proposals/proposals-client.tsx +++ b/app/(dashboard)/business/proposals/proposals-client.tsx @@ -1,84 +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 { tr } from "date-fns/locale"; -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("tr-TR", { 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: tr }) : "-"} + {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 ? ( - ) : ( @@ -86,48 +100,16 @@ export function ProposalsClient({ proposals }: { proposals: ProposalRow[] }) { - - - - + + + + )) @@ -138,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 2753e90..fc2ae88 100644 --- a/app/(dashboard)/business/subscriptions/subscriptions-client.tsx +++ b/app/(dashboard)/business/subscriptions/subscriptions-client.tsx @@ -1,15 +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 { tr } from "date-fns/locale"; -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 = { @@ -22,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("tr-TR", { 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: tr }) : "-"} + {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) => ( + + + + + + + )) @@ -163,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)/calendar/actions.ts b/app/(dashboard)/calendar/actions.ts index 23780a1..0d8cf25 100644 --- a/app/(dashboard)/calendar/actions.ts +++ b/app/(dashboard)/calendar/actions.ts @@ -3,6 +3,7 @@ import { revalidatePath } from "next/cache"; import { cleanText, optionalDate, requiredText } from "@/server/web/form-data"; import { requireFreelancerBackend } from "@/server/web/freelancer"; +import { parseContentTranslationsFromFormData } from "@/server/i18n/content"; const EVENT_TYPES = ["meeting", "focus", "deadline", "personal", "finance"] as const; @@ -12,16 +13,26 @@ function eventType(value: FormDataEntryValue | null) { : "focus"; } -function payload(formData: FormData) { +function payload( + formData: FormData, + service: Awaited>["service"], + actor: Awaited>["actor"] +) { + const context = service.contentTranslations.getLocalizationContext(actor); + const translations = parseContentTranslationsFromFormData(formData, "calendar_event", context); + const defaultTitle = translations?.[context.defaultLocale]?.title ?? ""; + const defaultDesc = translations?.[context.defaultLocale]?.description ?? ""; + return { - title: requiredText(formData.get("title"), "Etkinlik başlığı zorunludur."), - description: cleanText(formData.get("description")), + title: defaultTitle || requiredText(formData.get("title"), "Etkinlik başlığı zorunludur."), + description: defaultDesc || cleanText(formData.get("description")), type: eventType(formData.get("type")), startsAt: optionalDate(formData.get("starts_at")), endsAt: optionalDate(formData.get("ends_at")), clientId: cleanText(formData.get("client_id")), projectId: cleanText(formData.get("project_id")), taskId: cleanText(formData.get("task_id")), + translations, }; } @@ -42,7 +53,7 @@ function completeRelations( export async function createCalendarEventRecord(formData: FormData) { const backend = await requireFreelancerBackend(); - const value = completeRelations(payload(formData), backend.service, backend.actor); + const value = completeRelations(payload(formData, backend.service, backend.actor), backend.service, backend.actor); if (!value.startsAt) throw new Error("Etkinlik başlangıç zamanı zorunludur."); backend.service.createCalendarEvent(backend.actor, value); revalidatePath("/calendar"); @@ -51,7 +62,7 @@ export async function createCalendarEventRecord(formData: FormData) { export async function updateCalendarEventRecord(formData: FormData) { const backend = await requireFreelancerBackend(); const id = requiredText(formData.get("id"), "Etkinlik kaydı bulunamadı."); - const value = completeRelations(payload(formData), backend.service, backend.actor); + const value = completeRelations(payload(formData, backend.service, backend.actor), backend.service, backend.actor); if (!value.startsAt) throw new Error("Etkinlik başlangıç zamanı zorunludur."); backend.service.updateCalendarEvent(backend.actor, id, value); revalidatePath("/calendar"); diff --git a/app/(dashboard)/calendar/calendar-client.tsx b/app/(dashboard)/calendar/calendar-client.tsx index a2bab19..8e17fab 100644 --- a/app/(dashboard)/calendar/calendar-client.tsx +++ b/app/(dashboard)/calendar/calendar-client.tsx @@ -1,5 +1,7 @@ "use client"; +import { getDocumentIntlLocale } from "@/lib/i18n/browser"; +import { useTranslations } from "@/components/i18n/i18n-provider"; import { createCalendarEventRecord, deleteCalendarEventRecord, @@ -19,6 +21,10 @@ import { SelectItem, SelectTrigger, SelectValue, + Tabs, + TabsContent, + TabsList, + TabsTrigger, toast, } from "poyraz-ui/molecules"; import { Clock, Pencil, Plus, Trash2 } from "lucide-react"; @@ -47,14 +53,7 @@ export type CalendarEventItem = { clientName: string | null; projectName: string | null; taskTitle: string | null; -}; - -const typeLabels = { - meeting: "Toplantı", - focus: "Odak", - deadline: "Deadline", - personal: "Kişisel", - finance: "Finans", + translations?: Record>; }; const typeClasses = { @@ -70,9 +69,11 @@ type CalendarClientProps = { clients: CalendarRelationOption[]; projects: CalendarRelationOption[]; tasks: CalendarTaskOption[]; + activeLocales: { code: string; name: string }[]; }; -export function CalendarClient({ events, clients, projects, tasks }: CalendarClientProps) { +export function CalendarClient({ events, clients, projects, tasks, activeLocales }: CalendarClientProps) { + const t = useTranslations(); const [monthDate, setMonthDate] = useState(() => new Date()); const [selectedDate, setSelectedDate] = useState(() => toDateKey(new Date())); const days = useMemo(() => buildMonthDays(monthDate), [monthDate]); @@ -90,7 +91,7 @@ export function CalendarClient({ events, clients, projects, tasks }: CalendarCli
-

Takvim

+

{t("calendar.title")}

@@ -110,18 +112,12 @@ export function CalendarClient({ events, clients, projects, tasks }: CalendarCli

{formatMonth(monthDate)}

-

{events.length} etkinlik

+

{t("common.itemsCount", { count: events.length })}

- - - + + +
@@ -183,7 +179,7 @@ export function CalendarClient({ events, clients, projects, tasks }: CalendarCli {formatDateLabel(selectedDate)}

- {selectedEvents.length} etkinlik + {t("common.itemsCount", { count: selectedEvents.length })}

- + -

Yaklaşan etkinlikler

- +

{t("calendar.upcoming")}

+
@@ -214,16 +211,19 @@ function EventList({ clients, projects, tasks, + activeLocales, compact = false, }: { events: CalendarEventItem[]; clients: CalendarRelationOption[]; projects: CalendarRelationOption[]; tasks: CalendarTaskOption[]; + activeLocales: { code: string; name: string }[]; compact?: boolean; }) { + const t = useTranslations(); if (events.length === 0) { - return

Etkinlik yok.

; + return

{t("calendar.noEvents")}

; } return ( @@ -243,16 +243,16 @@ function EventList({ ) : null} - {typeLabels[event.type]} + {t(`calendar.types.${event.type}`)} {!compact ? (
- +
@@ -270,6 +270,7 @@ function CalendarEventDialog({ clients, projects, tasks, + activeLocales, }: { mode: "create" | "edit"; event?: CalendarEventItem; @@ -277,7 +278,9 @@ function CalendarEventDialog({ clients: CalendarRelationOption[]; projects: CalendarRelationOption[]; tasks: CalendarTaskOption[]; + activeLocales: { code: string; name: string }[]; }) { + const t = useTranslations(); const [open, setOpen] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); const action = mode === "create" ? createCalendarEventRecord : updateCalendarEventRecord; @@ -287,12 +290,12 @@ function CalendarEventDialog({ try { await action(formData); setOpen(false); - toast.success(mode === "create" ? "Etkinlik eklendi." : "Etkinlik güncellendi."); + toast.success(mode === "create" ? t("calendar.messages.created") : t("calendar.messages.updated")); } catch (error) { toast.error( error instanceof Error ? error.message - : "Etkinlik kaydedilirken beklenmeyen bir hata oluştu.", + : t("calendar.errors.saveFailed"), ); } finally { setIsSubmitting(false); @@ -304,25 +307,25 @@ function CalendarEventDialog({
{event ? : null} - {mode === "create" ? "Yeni etkinlik" : "Etkinliği düzenle"} - Takvim etkinliğini proje, görev veya müşteriyle ilişkilendir. + {mode === "create" ? t("calendar.form.createTitle") : t("calendar.form.editTitle")} + {t("calendar.description")}
- +
@@ -337,55 +340,83 @@ function EventFormFields({ clients, projects, tasks, + activeLocales, }: { event?: CalendarEventItem; defaultDate?: string; clients: CalendarRelationOption[]; projects: CalendarRelationOption[]; tasks: CalendarTaskOption[]; + activeLocales: { code: string; name: string }[]; }) { + const t = useTranslations(); const startsAt = event?.starts_at ? toDateTimeLocal(event.starts_at) : `${defaultDate || toDateKey(new Date())}T09:00`; return (
-
- - -
-
- -
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: tr }) : "-"} -
{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}