feat: support localized domain content
This commit is contained in:
@@ -7,12 +7,18 @@ import {
|
||||
type ProjectPlanningSectionItem,
|
||||
type ProjectRevisionItem,
|
||||
} from "@/app/(dashboard)/projects/[id]/project-detail-client";
|
||||
import { getSqliteConnection } from "@/server/db/client";
|
||||
import { DomainError } from "@/server/domain/errors";
|
||||
import { ContentTranslationService, type ContentTranslationRow } from "@/server/i18n/content";
|
||||
import { resolveRequestLocale } from "@/server/i18n/resolver";
|
||||
import { requireFreelancerBackend } from "@/server/web/freelancer";
|
||||
|
||||
export default async function ProjectDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const locale = await resolveRequestLocale();
|
||||
const { actor, service } = await requireFreelancerBackend();
|
||||
const content = new ContentTranslationService(getSqliteConnection().db);
|
||||
const localization = content.getLocalizationContext(actor);
|
||||
|
||||
let data: {
|
||||
project: ProjectDetail;
|
||||
@@ -23,14 +29,20 @@ export default async function ProjectDetailPage({ params }: { params: Promise<{
|
||||
};
|
||||
try {
|
||||
const row = service.getProject(actor, id);
|
||||
const projectTranslationRows = content.listEntityTranslations("project", row.id);
|
||||
const resolvedProject = content.resolveEntity("project", row, {
|
||||
locale: locale.locale,
|
||||
defaultLocale: localization.defaultLocale,
|
||||
translations: projectTranslationRows,
|
||||
});
|
||||
const client = row.clientId ? service.getClient(actor, row.clientId) : null;
|
||||
const project: ProjectDetail = {
|
||||
id: row.id,
|
||||
client_id: row.clientId,
|
||||
clientName: client?.name ?? null,
|
||||
name: row.name,
|
||||
name: resolvedProject.name,
|
||||
type: row.type,
|
||||
description: row.description,
|
||||
description: resolvedProject.description,
|
||||
status: row.status,
|
||||
start_date: row.startDate,
|
||||
due_date: row.dueDate,
|
||||
@@ -39,27 +51,50 @@ export default async function ProjectDetailPage({ params }: { params: Promise<{
|
||||
progress: row.progress,
|
||||
progress_type: row.progressType,
|
||||
revision_quota: row.revisionQuota,
|
||||
cover_image_alt: row.coverImageAlt,
|
||||
cover_image_alt: resolvedProject.coverImageAlt,
|
||||
coverImageUrl: row.legacyCoverImagePath,
|
||||
translations: toLocalizedValues(projectTranslationRows),
|
||||
};
|
||||
const sections: ProjectPlanningSectionItem[] = service.listPlanningSections(actor, id).map((section) => ({
|
||||
id: section.id,
|
||||
project_id: section.projectId,
|
||||
category: section.category,
|
||||
title: section.title,
|
||||
content: section.content,
|
||||
sort_order: section.sortOrder,
|
||||
}));
|
||||
const tasks: ProjectDetailTaskItem[] = service.listTasks(actor, id)
|
||||
const sectionRows = service.listPlanningSections(actor, id);
|
||||
const sectionTranslations = content.listBatch("planning_section", sectionRows.map((section) => section.id));
|
||||
const sections: ProjectPlanningSectionItem[] = sectionRows.map((section) => {
|
||||
const translationRows = sectionTranslations.get(section.id) ?? [];
|
||||
const resolvedSection = content.resolveEntity("planning_section", section, {
|
||||
locale: locale.locale,
|
||||
defaultLocale: localization.defaultLocale,
|
||||
translations: translationRows,
|
||||
});
|
||||
return {
|
||||
id: section.id,
|
||||
project_id: section.projectId,
|
||||
category: section.category,
|
||||
title: resolvedSection.title,
|
||||
content: resolvedSection.content,
|
||||
sort_order: section.sortOrder,
|
||||
translations: toLocalizedValues(translationRows),
|
||||
};
|
||||
});
|
||||
const taskRows = service.listTasks(actor, id).filter((task) => task.status !== "cancelled");
|
||||
const taskTranslations = content.listBatch("task", taskRows.map((task) => task.id));
|
||||
const tasks: ProjectDetailTaskItem[] = taskRows
|
||||
.filter((task) => task.status !== "cancelled")
|
||||
.map((task) => ({
|
||||
id: task.id,
|
||||
title: task.title,
|
||||
status: task.status as ProjectDetailTaskItem["status"],
|
||||
priority: task.priority,
|
||||
due_at: task.dueAt?.toISOString() ?? null,
|
||||
is_public_to_client: task.isPublicToClient,
|
||||
}));
|
||||
.map((task) => {
|
||||
const translationRows = taskTranslations.get(task.id) ?? [];
|
||||
const resolvedTask = content.resolveEntity("task", task, {
|
||||
locale: locale.locale,
|
||||
defaultLocale: localization.defaultLocale,
|
||||
translations: translationRows,
|
||||
});
|
||||
return {
|
||||
id: task.id,
|
||||
title: resolvedTask.title,
|
||||
status: task.status as ProjectDetailTaskItem["status"],
|
||||
priority: task.priority,
|
||||
due_at: task.dueAt?.toISOString() ?? null,
|
||||
is_public_to_client: task.isPublicToClient,
|
||||
translations: toLocalizedValues(translationRows),
|
||||
};
|
||||
});
|
||||
const financeTransactions: ProjectFinanceItem[] = service.listFinanceTransactions(actor)
|
||||
.filter((transaction) => transaction.projectId === id)
|
||||
.map((transaction) => ({
|
||||
@@ -92,6 +127,15 @@ export default async function ProjectDetailPage({ params }: { params: Promise<{
|
||||
tasks={data.tasks}
|
||||
financeTransactions={data.financeTransactions}
|
||||
revisions={data.revisions}
|
||||
localization={localization}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function toLocalizedValues(rows: ContentTranslationRow[]) {
|
||||
return rows.reduce<Record<string, Record<string, string>>>((result, row) => {
|
||||
result[row.locale] = result[row.locale] ?? {};
|
||||
result[row.locale][row.field] = row.value;
|
||||
return result;
|
||||
}, {});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { getDocumentIntlLocale } from "@/lib/i18n/browser";
|
||||
import {
|
||||
completeProjectRecord,
|
||||
createProjectPlanningSectionRecord,
|
||||
@@ -10,9 +11,11 @@ import {
|
||||
createTaskRecord,
|
||||
updateTaskStatusRecord,
|
||||
} from "@/app/(dashboard)/tasks/actions";
|
||||
import { LocalizedFields, type LocalizedFieldLocale, type LocalizedFieldValues } from "@/components/i18n/localized-fields";
|
||||
import { PendingLink } from "@/components/ui/pending-link";
|
||||
import { PendingSubmitButton } from "@/components/ui/pending-submit-button";
|
||||
import { Badge, Button, Card, CardContent, Input, Label, Textarea } from "poyraz-ui/atoms";
|
||||
import { contentTranslationRegistry } from "@/lib/i18n/content";
|
||||
import { Badge, Button, Card, CardContent, Input, Label } from "poyraz-ui/atoms";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -66,6 +69,7 @@ export type ProjectDetail = {
|
||||
revision_quota: number;
|
||||
cover_image_alt: string | null;
|
||||
coverImageUrl: string | null;
|
||||
translations?: LocalizedFieldValues;
|
||||
};
|
||||
|
||||
export type ProjectPlanningSectionItem = {
|
||||
@@ -85,6 +89,7 @@ export type ProjectPlanningSectionItem = {
|
||||
title: string;
|
||||
content: string | null;
|
||||
sort_order: number;
|
||||
translations?: LocalizedFieldValues;
|
||||
};
|
||||
|
||||
export type ProjectDetailTaskItem = {
|
||||
@@ -94,6 +99,7 @@ export type ProjectDetailTaskItem = {
|
||||
priority: "low" | "medium" | "high" | "urgent";
|
||||
due_at: string | null;
|
||||
is_public_to_client: boolean;
|
||||
translations?: LocalizedFieldValues;
|
||||
};
|
||||
|
||||
export type ProjectFinanceItem = {
|
||||
@@ -120,6 +126,10 @@ type ProjectDetailClientProps = {
|
||||
tasks: ProjectDetailTaskItem[];
|
||||
financeTransactions: ProjectFinanceItem[];
|
||||
revisions: ProjectRevisionItem[];
|
||||
localization: {
|
||||
defaultLocale: string;
|
||||
locales: LocalizedFieldLocale[];
|
||||
};
|
||||
};
|
||||
|
||||
const typeLabels = {
|
||||
@@ -185,6 +195,7 @@ export function ProjectDetailClient({
|
||||
tasks,
|
||||
financeTransactions,
|
||||
revisions,
|
||||
localization,
|
||||
}: ProjectDetailClientProps) {
|
||||
const [activeTab, setActiveTab] = useState<"planning" | "design" | "tasks" | "finance" | "revisions">(
|
||||
"planning",
|
||||
@@ -227,7 +238,7 @@ export function ProjectDetailClient({
|
||||
|
||||
<div className="flex gap-2">
|
||||
<ProjectSettingsDialog project={project} />
|
||||
<SectionDialog projectId={project.id} mode="create" defaultCategory="overview" />
|
||||
<SectionDialog projectId={project.id} mode="create" defaultCategory="overview" localization={localization} />
|
||||
{project.status !== "completed" ? (
|
||||
<form action={completeProjectRecord}>
|
||||
<input type="hidden" name="id" value={project.id} />
|
||||
@@ -329,6 +340,7 @@ export function ProjectDetailClient({
|
||||
description="Problem, amaç, hedef kitle, kapsam ve proje notlarını burada tut."
|
||||
sections={planningSections}
|
||||
defaultCategory="overview"
|
||||
localization={localization}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -339,11 +351,12 @@ export function ProjectDetailClient({
|
||||
description="Renk paleti, tipografi, görsel dil ve asset notlarını proje kaynağına bağla."
|
||||
sections={designSections}
|
||||
defaultCategory="design_system"
|
||||
localization={localization}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{activeTab === "tasks" ? (
|
||||
<TaskPanel projectId={project.id} clientId={project.client_id} tasks={tasks} />
|
||||
<TaskPanel projectId={project.id} clientId={project.client_id} tasks={tasks} localization={localization} />
|
||||
) : null}
|
||||
{activeTab === "finance" ? <FinancePanel transactions={financeTransactions} /> : null}
|
||||
{activeTab === "revisions" ? <RevisionsPanel projectId={project.id} revisions={revisions} /> : null}
|
||||
@@ -391,7 +404,7 @@ function RevisionsPanel({
|
||||
<div key={rev.id} className="p-4 border rounded-md">
|
||||
<div className="flex justify-between items-start mb-3">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{new Date(rev.created_at).toLocaleString('tr-TR')}
|
||||
{new Date(rev.created_at).toLocaleString(getDocumentIntlLocale())}
|
||||
</div>
|
||||
<Select
|
||||
defaultValue={rev.status}
|
||||
@@ -430,12 +443,14 @@ function SectionGrid({
|
||||
description,
|
||||
sections,
|
||||
defaultCategory,
|
||||
localization,
|
||||
}: {
|
||||
projectId: string;
|
||||
title: string;
|
||||
description: string;
|
||||
sections: ProjectPlanningSectionItem[];
|
||||
defaultCategory: ProjectPlanningSectionItem["category"];
|
||||
localization: ProjectDetailClientProps["localization"];
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
@@ -445,13 +460,13 @@ function SectionGrid({
|
||||
<h2 className="text-lg font-semibold text-foreground">{title}</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
<SectionDialog projectId={projectId} mode="create" defaultCategory={defaultCategory} />
|
||||
<SectionDialog projectId={projectId} mode="create" defaultCategory={defaultCategory} localization={localization} />
|
||||
</div>
|
||||
|
||||
{sections.length > 0 ? (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{sections.map((section) => (
|
||||
<PlanningSectionCard key={section.id} section={section} />
|
||||
<PlanningSectionCard key={section.id} section={section} localization={localization} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
@@ -469,7 +484,13 @@ function SectionGrid({
|
||||
);
|
||||
}
|
||||
|
||||
function PlanningSectionCard({ section }: { section: ProjectPlanningSectionItem }) {
|
||||
function PlanningSectionCard({
|
||||
section,
|
||||
localization,
|
||||
}: {
|
||||
section: ProjectPlanningSectionItem;
|
||||
localization: ProjectDetailClientProps["localization"];
|
||||
}) {
|
||||
return (
|
||||
<Card className="transition-colors hover:border-primary/30">
|
||||
<CardContent className="flex h-full flex-col gap-4 p-4">
|
||||
@@ -479,7 +500,7 @@ function PlanningSectionCard({ section }: { section: ProjectPlanningSectionItem
|
||||
<h3 className="mt-3 text-base font-semibold text-foreground">{section.title}</h3>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<SectionDialog projectId={section.project_id} mode="edit" section={section} />
|
||||
<SectionDialog projectId={section.project_id} mode="edit" section={section} localization={localization} />
|
||||
<form action={deleteProjectPlanningSectionRecord}>
|
||||
<input type="hidden" name="id" value={section.id} />
|
||||
<input type="hidden" name="project_id" value={section.project_id} />
|
||||
@@ -505,11 +526,13 @@ function SectionDialog({
|
||||
mode,
|
||||
defaultCategory,
|
||||
section,
|
||||
localization,
|
||||
}: {
|
||||
projectId: string;
|
||||
mode: "create" | "edit";
|
||||
defaultCategory?: ProjectPlanningSectionItem["category"];
|
||||
section?: ProjectPlanningSectionItem;
|
||||
localization: ProjectDetailClientProps["localization"];
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
@@ -569,26 +592,17 @@ function SectionDialog({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor={`section-title-${section?.id || "new"}`}>Başlık</Label>
|
||||
<Input
|
||||
id={`section-title-${section?.id || "new"}`}
|
||||
name="title"
|
||||
defaultValue={section?.title || ""}
|
||||
required
|
||||
placeholder="Örn. Başarı kriterleri"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor={`section-content-${section?.id || "new"}`}>İçerik</Label>
|
||||
<Textarea
|
||||
id={`section-content-${section?.id || "new"}`}
|
||||
name="content"
|
||||
defaultValue={section?.content || ""}
|
||||
rows={8}
|
||||
placeholder="Kısa notlar, kriterler, renkler, tipografi kararları..."
|
||||
/>
|
||||
</div>
|
||||
<LocalizedFields
|
||||
idPrefix={`section-${section?.id || "new"}`}
|
||||
defaultLocale={localization.defaultLocale}
|
||||
locales={localization.locales}
|
||||
fields={contentTranslationRegistry.planning_section}
|
||||
values={section?.translations}
|
||||
fallbackValues={{
|
||||
title: section?.title,
|
||||
content: section?.content,
|
||||
}}
|
||||
/>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor={`section-order-${section?.id || "new"}`}>Sıra</Label>
|
||||
<Input
|
||||
@@ -615,10 +629,12 @@ function TaskPanel({
|
||||
projectId,
|
||||
clientId,
|
||||
tasks,
|
||||
localization,
|
||||
}: {
|
||||
projectId: string;
|
||||
clientId: string | null;
|
||||
tasks: ProjectDetailTaskItem[];
|
||||
localization: ProjectDetailClientProps["localization"];
|
||||
}) {
|
||||
const [view, setView] = useState<"list" | "kanban">("list");
|
||||
const [statusOverrides, setStatusOverrides] = useState<
|
||||
@@ -703,7 +719,7 @@ function TaskPanel({
|
||||
Kanban
|
||||
</Button>
|
||||
</div>
|
||||
<ProjectTaskDialog projectId={projectId} clientId={clientId} />
|
||||
<ProjectTaskDialog projectId={projectId} clientId={clientId} localization={localization} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -989,9 +1005,11 @@ function ProjectSettingsDialog({ project }: { project: ProjectDetail }) {
|
||||
function ProjectTaskDialog({
|
||||
projectId,
|
||||
clientId,
|
||||
localization,
|
||||
}: {
|
||||
projectId: string;
|
||||
clientId: string | null;
|
||||
localization: ProjectDetailClientProps["localization"];
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
@@ -1027,24 +1045,12 @@ function ProjectTaskDialog({
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="project-task-title">Başlık</Label>
|
||||
<Input
|
||||
id="project-task-title"
|
||||
name="title"
|
||||
required
|
||||
placeholder="Örn. Mobil görünüm kontrolü"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="project-task-description">Açıklama</Label>
|
||||
<Textarea
|
||||
id="project-task-description"
|
||||
name="description"
|
||||
rows={3}
|
||||
placeholder="Kapsam, teslim notu veya kabul kriterleri..."
|
||||
/>
|
||||
</div>
|
||||
<LocalizedFields
|
||||
idPrefix="project-task"
|
||||
defaultLocale={localization.defaultLocale}
|
||||
locales={localization.locales}
|
||||
fields={contentTranslationRegistry.task}
|
||||
/>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="grid gap-2">
|
||||
<Label>Durum</Label>
|
||||
@@ -1262,7 +1268,7 @@ function TabButton({
|
||||
}
|
||||
|
||||
function formatDate(value: string) {
|
||||
return new Intl.DateTimeFormat("tr-TR", {
|
||||
return new Intl.DateTimeFormat(getDocumentIntlLocale(), {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
@@ -1270,7 +1276,7 @@ function formatDate(value: string) {
|
||||
}
|
||||
|
||||
function formatDateTime(value: string) {
|
||||
return new Intl.DateTimeFormat("tr-TR", {
|
||||
return new Intl.DateTimeFormat(getDocumentIntlLocale(), {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
hour: "2-digit",
|
||||
@@ -1285,7 +1291,7 @@ function getTaskStatusLabel(status: ProjectDetailTaskItem["status"]) {
|
||||
}
|
||||
|
||||
function formatCurrency(value: number, currency: string) {
|
||||
return new Intl.NumberFormat("tr-TR", {
|
||||
return new Intl.NumberFormat(getDocumentIntlLocale(), {
|
||||
style: "currency",
|
||||
currency,
|
||||
maximumFractionDigits: 0,
|
||||
|
||||
@@ -3,6 +3,11 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { getFileService } from "@/server/files/runtime";
|
||||
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";
|
||||
|
||||
@@ -20,20 +25,21 @@ function numberValue(value: FormDataEntryValue | null, fallback = 0) {
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
function projectPayload(formData: FormData) {
|
||||
function projectPayload(formData: FormData, translations?: Record<string, Record<string, string | null>>, defaultLocale = "tr") {
|
||||
const type = enumValue(formData.get("type"), PROJECT_TYPES, "client_project");
|
||||
const localized = translations?.[defaultLocale] ?? {};
|
||||
return {
|
||||
name: requiredText(formData.get("name"), "Proje adı zorunludur."),
|
||||
name: localized.name ?? requiredText(formData.get("name"), "Proje adı zorunludur."),
|
||||
type,
|
||||
clientId: type === "client_project" ? cleanText(formData.get("client_id")) : null,
|
||||
description: cleanText(formData.get("description")),
|
||||
description: localized.description ?? cleanText(formData.get("description")),
|
||||
status: enumValue(formData.get("status"), PROJECT_STATUSES, "planning"),
|
||||
startDate: cleanText(formData.get("start_date")),
|
||||
dueDate: cleanText(formData.get("due_date")),
|
||||
budgetAmountMinor: decimalToMinor(formData.get("budget_amount")),
|
||||
currency: cleanText(formData.get("currency")) ?? "USD",
|
||||
progress: Math.min(100, Math.max(0, Math.round(numberValue(formData.get("progress"))))),
|
||||
coverImageAlt: cleanText(formData.get("cover_image_alt")),
|
||||
coverImageAlt: localized.coverImageAlt ?? cleanText(formData.get("cover_image_alt")),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -57,8 +63,11 @@ async function uploadCover(
|
||||
|
||||
export async function createProjectRecord(formData: FormData) {
|
||||
const { actor, service } = await requireFreelancerBackend();
|
||||
const i18n = new ContentTranslationService(getSqliteConnection().db);
|
||||
const context = i18n.getLocalizationContext(actor);
|
||||
const translations = parseContentTranslationsFromFormData(formData, "project", context);
|
||||
const id = randomUUID();
|
||||
service.createProject(actor, { id, ...projectPayload(formData) });
|
||||
service.createProject(actor, { id, ...projectPayload(formData, translations, context.defaultLocale), translations });
|
||||
try {
|
||||
const cover = await uploadCover(actor, id, formData);
|
||||
if (cover) service.updateProject(actor, id, { legacyCoverImagePath: cover });
|
||||
@@ -71,8 +80,11 @@ export async function createProjectRecord(formData: FormData) {
|
||||
|
||||
export async function updateProjectRecord(formData: FormData) {
|
||||
const { actor, service } = await requireFreelancerBackend();
|
||||
const i18n = new ContentTranslationService(getSqliteConnection().db);
|
||||
const context = i18n.getLocalizationContext(actor);
|
||||
const translations = parseContentTranslationsFromFormData(formData, "project", context);
|
||||
const id = requiredText(formData.get("id"), "Proje kaydı bulunamadı.");
|
||||
service.updateProject(actor, id, projectPayload(formData));
|
||||
service.updateProject(actor, id, { ...projectPayload(formData, translations, context.defaultLocale), translations });
|
||||
const cover = await uploadCover(actor, id, formData);
|
||||
if (cover) service.updateProject(actor, id, { legacyCoverImagePath: cover });
|
||||
revalidatePath("/projects");
|
||||
@@ -87,28 +99,35 @@ export async function completeProjectRecord(formData: FormData) {
|
||||
revalidatePath(`/projects/${id}`);
|
||||
}
|
||||
|
||||
function sectionPayload(formData: FormData) {
|
||||
function sectionPayload(formData: FormData, translations?: Record<string, Record<string, string | null>>, defaultLocale = "tr") {
|
||||
const localized = translations?.[defaultLocale] ?? {};
|
||||
return {
|
||||
projectId: requiredText(formData.get("project_id"), "Proje zorunludur."),
|
||||
category: enumValue(formData.get("category"), SECTION_CATEGORIES, "overview"),
|
||||
title: requiredText(formData.get("title"), "Planlama başlığı zorunludur."),
|
||||
content: cleanText(formData.get("content")),
|
||||
title: localized.title ?? requiredText(formData.get("title"), "Planlama başlığı zorunludur."),
|
||||
content: localized.content ?? cleanText(formData.get("content")),
|
||||
sortOrder: Math.max(0, Math.round(numberValue(formData.get("sort_order")))),
|
||||
};
|
||||
}
|
||||
|
||||
export async function createProjectPlanningSectionRecord(formData: FormData) {
|
||||
const { actor, service } = await requireFreelancerBackend();
|
||||
const payload = sectionPayload(formData);
|
||||
service.addPlanningSection(actor, payload);
|
||||
const i18n = new ContentTranslationService(getSqliteConnection().db);
|
||||
const context = i18n.getLocalizationContext(actor);
|
||||
const translations = parseContentTranslationsFromFormData(formData, "planning_section", context);
|
||||
const payload = sectionPayload(formData, translations, context.defaultLocale);
|
||||
service.addPlanningSection(actor, { ...payload, translations });
|
||||
revalidatePath("/projects");
|
||||
revalidatePath(`/projects/${payload.projectId}`);
|
||||
}
|
||||
|
||||
export async function updateProjectPlanningSectionRecord(formData: FormData) {
|
||||
const { actor, service } = await requireFreelancerBackend();
|
||||
const i18n = new ContentTranslationService(getSqliteConnection().db);
|
||||
const context = i18n.getLocalizationContext(actor);
|
||||
const translations = parseContentTranslationsFromFormData(formData, "planning_section", context);
|
||||
const id = requiredText(formData.get("id"), "Planlama alanı bulunamadı.");
|
||||
const payload = sectionPayload(formData);
|
||||
const payload = sectionPayload(formData, translations, context.defaultLocale);
|
||||
if (!service.listPlanningSections(actor, payload.projectId).some((section) => section.id === id)) {
|
||||
throw new Error("Planlama alanı bu projeye ait değil.");
|
||||
}
|
||||
@@ -117,6 +136,7 @@ export async function updateProjectPlanningSectionRecord(formData: FormData) {
|
||||
title: payload.title,
|
||||
content: payload.content,
|
||||
sortOrder: payload.sortOrder,
|
||||
translations,
|
||||
});
|
||||
revalidatePath("/projects");
|
||||
revalidatePath(`/projects/${payload.projectId}`);
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { ProjectsClient, type ProjectClientOption, type ProjectListItem } from "@/app/(dashboard)/projects/projects-client";
|
||||
import { getSqliteConnection } from "@/server/db/client";
|
||||
import { ContentTranslationService, type ContentTranslationRow } from "@/server/i18n/content";
|
||||
import { resolveRequestLocale } from "@/server/i18n/resolver";
|
||||
import { requireFreelancerBackend } from "@/server/web/freelancer";
|
||||
|
||||
export default async function ProjectsPage() {
|
||||
const locale = await resolveRequestLocale();
|
||||
const { actor, service } = await requireFreelancerBackend();
|
||||
const content = new ContentTranslationService(getSqliteConnection().db);
|
||||
const localization = content.getLocalizationContext(actor);
|
||||
const projectRows = service.listProjects(actor);
|
||||
const clientRows = service.listClients(actor);
|
||||
const taskRows = service.listTasks(actor);
|
||||
@@ -17,15 +23,22 @@ export default async function ProjectsPage() {
|
||||
taskStats.set(task.projectId, stats);
|
||||
}
|
||||
|
||||
const projectTranslations = content.listBatch("project", projectRows.map((project) => project.id));
|
||||
const projects: ProjectListItem[] = projectRows.map((project) => {
|
||||
const stats = taskStats.get(project.id) ?? { total: 0, done: 0 };
|
||||
const translationRows = projectTranslations.get(project.id) ?? [];
|
||||
const resolvedProject = content.resolveEntity("project", project, {
|
||||
locale: locale.locale,
|
||||
defaultLocale: localization.defaultLocale,
|
||||
translations: translationRows,
|
||||
});
|
||||
return {
|
||||
id: project.id,
|
||||
client_id: project.clientId,
|
||||
clientName: project.clientId ? clientNames.get(project.clientId) ?? null : null,
|
||||
name: project.name,
|
||||
name: resolvedProject.name,
|
||||
type: project.type,
|
||||
description: project.description,
|
||||
description: resolvedProject.description,
|
||||
status: project.status,
|
||||
start_date: project.startDate,
|
||||
due_date: project.dueDate,
|
||||
@@ -33,16 +46,25 @@ export default async function ProjectsPage() {
|
||||
currency: project.currency,
|
||||
progress: project.progress,
|
||||
cover_image_path: project.legacyCoverImagePath,
|
||||
cover_image_alt: project.coverImageAlt,
|
||||
cover_image_alt: resolvedProject.coverImageAlt,
|
||||
coverImageUrl: project.legacyCoverImagePath,
|
||||
taskCount: stats.total,
|
||||
doneTaskCount: stats.done,
|
||||
translations: toLocalizedValues(translationRows),
|
||||
};
|
||||
});
|
||||
const clients: ProjectClientOption[] = clientRows
|
||||
.filter((client) => client.status !== "archived")
|
||||
.sort((a, b) => a.name.localeCompare(b.name, "tr"))
|
||||
.sort((a, b) => a.name.localeCompare(b.name, locale.locale))
|
||||
.map(({ id, name }) => ({ id, name }));
|
||||
|
||||
return <ProjectsClient projects={projects} clients={clients} />;
|
||||
return <ProjectsClient projects={projects} clients={clients} localization={localization} />;
|
||||
}
|
||||
|
||||
function toLocalizedValues(rows: ContentTranslationRow[]) {
|
||||
return rows.reduce<Record<string, Record<string, string>>>((result, row) => {
|
||||
result[row.locale] = result[row.locale] ?? {};
|
||||
result[row.locale][row.field] = row.value;
|
||||
return result;
|
||||
}, {});
|
||||
}
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { getDocumentIntlLocale } from "@/lib/i18n/browser";
|
||||
import { useTranslations } from "@/components/i18n/i18n-provider";
|
||||
import {
|
||||
completeProjectRecord,
|
||||
createProjectRecord,
|
||||
updateProjectRecord,
|
||||
} from "@/app/(dashboard)/projects/actions";
|
||||
import { LocalizedFields, type LocalizedFieldLocale, type LocalizedFieldValues } from "@/components/i18n/localized-fields";
|
||||
import { PendingLink } from "@/components/ui/pending-link";
|
||||
import { PendingSubmitButton } from "@/components/ui/pending-submit-button";
|
||||
import { Badge, Button, Card, CardContent, Input, Label, Textarea } from "poyraz-ui/atoms";
|
||||
import { contentTranslationRegistry } from "@/lib/i18n/content";
|
||||
import { Badge, Button, Card, CardContent, Input, Label } from "poyraz-ui/atoms";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -66,6 +70,7 @@ export type ProjectListItem = {
|
||||
coverImageUrl: string | null;
|
||||
taskCount: number;
|
||||
doneTaskCount: number;
|
||||
translations?: LocalizedFieldValues;
|
||||
};
|
||||
|
||||
const typeLabels = {
|
||||
@@ -92,9 +97,14 @@ const statusClasses = {
|
||||
type ProjectsClientProps = {
|
||||
projects: ProjectListItem[];
|
||||
clients: ProjectClientOption[];
|
||||
localization: {
|
||||
defaultLocale: string;
|
||||
locales: LocalizedFieldLocale[];
|
||||
};
|
||||
};
|
||||
|
||||
export function ProjectsClient({ projects, clients }: ProjectsClientProps) {
|
||||
export function ProjectsClient({ projects, clients, localization }: ProjectsClientProps) {
|
||||
const t = useTranslations();
|
||||
const [query, setQuery] = useState("");
|
||||
const [view, setView] = useState<"grid" | "list">("grid");
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
@@ -118,21 +128,21 @@ export function ProjectsClient({ projects, clients }: ProjectsClientProps) {
|
||||
<div className="flex flex-col gap-4 border-b border-border pb-5 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-semibold tracking-normal text-foreground">
|
||||
Projeler
|
||||
{t("projects.title")}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<AIProjectRiskDialog />
|
||||
<ProjectDialog mode="create" clients={clients} />
|
||||
<ProjectDialog mode="create" clients={clients} localization={localization} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-4">
|
||||
<StatCard label="Aktif proje" value={activeCount.toString()} icon={FolderKanban} tone="green" />
|
||||
<StatCard label={t("projects.stats.active")} value={activeCount.toString()} icon={FolderKanban} tone="green" />
|
||||
<StatCard label="Side project" value={sideProjectCount.toString()} icon={Target} tone="blue" />
|
||||
<StatCard label="Ortalama ilerleme" value={`${averageProgress}%`} icon={CheckCircle2} tone="amber" />
|
||||
<StatCard label="Toplam bütçe" value={formatCurrency(totalBudget)} icon={Wallet} tone="red" />
|
||||
<StatCard label={t("projects.stats.progress")} value={`${averageProgress}%`} icon={CheckCircle2} tone="amber" />
|
||||
<StatCard label={t("projects.stats.budget")} value={formatCurrency(totalBudget)} icon={Wallet} tone="red" />
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
@@ -178,7 +188,7 @@ export function ProjectsClient({ projects, clients }: ProjectsClientProps) {
|
||||
view === "grid" ? (
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{filteredProjects.map((project) => (
|
||||
<ProjectCard key={project.id} project={project} clients={clients} />
|
||||
<ProjectCard key={project.id} project={project} clients={clients} localization={localization} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
@@ -193,7 +203,7 @@ export function ProjectsClient({ projects, clients }: ProjectsClientProps) {
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
{filteredProjects.map((project) => (
|
||||
<ProjectRow key={project.id} project={project} clients={clients} />
|
||||
<ProjectRow key={project.id} project={project} clients={clients} localization={localization} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -211,9 +221,11 @@ export function ProjectsClient({ projects, clients }: ProjectsClientProps) {
|
||||
function ProjectCard({
|
||||
project,
|
||||
clients,
|
||||
localization,
|
||||
}: {
|
||||
project: ProjectListItem;
|
||||
clients: ProjectClientOption[];
|
||||
localization: ProjectsClientProps["localization"];
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [isNavigating, startNavigation] = useTransition();
|
||||
@@ -274,7 +286,7 @@ function ProjectCard({
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{project.doneTaskCount}/{project.taskCount} görev tamamlandı
|
||||
</div>
|
||||
<ProjectActions project={project} clients={clients} showDetail={false} />
|
||||
<ProjectActions project={project} clients={clients} localization={localization} showDetail={false} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -307,9 +319,11 @@ function ProjectCover({ project }: { project: ProjectListItem }) {
|
||||
function ProjectRow({
|
||||
project,
|
||||
clients,
|
||||
localization,
|
||||
}: {
|
||||
project: ProjectListItem;
|
||||
clients: ProjectClientOption[];
|
||||
localization: ProjectsClientProps["localization"];
|
||||
}) {
|
||||
return (
|
||||
<div className="grid gap-4 px-4 py-4 grid-cols-[1.5fr_1fr_1fr_0.8fr_1fr] items-center">
|
||||
@@ -327,7 +341,7 @@ function ProjectRow({
|
||||
<ProgressBar progress={project.progress} compact />
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<ProjectActions project={project} clients={clients} showDetail />
|
||||
<ProjectActions project={project} clients={clients} localization={localization} showDetail />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -350,10 +364,12 @@ function ProjectMeta({ project }: { project: ProjectListItem }) {
|
||||
function ProjectActions({
|
||||
project,
|
||||
clients,
|
||||
localization,
|
||||
showDetail,
|
||||
}: {
|
||||
project: ProjectListItem;
|
||||
clients: ProjectClientOption[];
|
||||
localization: ProjectsClientProps["localization"];
|
||||
showDetail: boolean;
|
||||
}) {
|
||||
return (
|
||||
@@ -376,7 +392,7 @@ function ProjectActions({
|
||||
</PendingLink>
|
||||
</Button>
|
||||
) : null}
|
||||
<ProjectDialog mode="edit" project={project} clients={clients} iconOnly />
|
||||
<ProjectDialog mode="edit" project={project} clients={clients} localization={localization} iconOnly />
|
||||
{project.status !== "completed" ? (
|
||||
<form action={completeProjectRecord}>
|
||||
<input type="hidden" name="id" value={project.id} />
|
||||
@@ -398,11 +414,13 @@ function ProjectDialog({
|
||||
mode,
|
||||
project,
|
||||
clients,
|
||||
localization,
|
||||
iconOnly = false,
|
||||
}: {
|
||||
mode: "create" | "edit";
|
||||
project?: ProjectListItem;
|
||||
clients: ProjectClientOption[];
|
||||
localization: ProjectsClientProps["localization"];
|
||||
iconOnly?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -456,6 +474,7 @@ function ProjectDialog({
|
||||
<ProjectFormFields
|
||||
project={project}
|
||||
clients={clients}
|
||||
localization={localization}
|
||||
projectType={projectType}
|
||||
onProjectTypeChange={setProjectType}
|
||||
/>
|
||||
@@ -549,15 +568,6 @@ function CoverImageInput({ project }: { project?: ProjectListItem }) {
|
||||
className="sr-only"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor={`cover-alt-${project?.id || "new"}`}>Görsel alt metni</Label>
|
||||
<Input
|
||||
id={`cover-alt-${project?.id || "new"}`}
|
||||
name="cover_image_alt"
|
||||
defaultValue={project?.cover_image_alt || ""}
|
||||
placeholder="Görseli kısaca açıkla"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -565,11 +575,13 @@ function CoverImageInput({ project }: { project?: ProjectListItem }) {
|
||||
function ProjectFormFields({
|
||||
project,
|
||||
clients,
|
||||
localization,
|
||||
projectType,
|
||||
onProjectTypeChange,
|
||||
}: {
|
||||
project?: ProjectListItem;
|
||||
clients: ProjectClientOption[];
|
||||
localization: ProjectsClientProps["localization"];
|
||||
projectType: ProjectListItem["type"];
|
||||
onProjectTypeChange: (value: ProjectListItem["type"]) => void;
|
||||
}) {
|
||||
@@ -577,16 +589,18 @@ function ProjectFormFields({
|
||||
<div className="grid gap-4">
|
||||
<CoverImageInput project={project} />
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor={`name-${project?.id || "new"}`}>Proje adı</Label>
|
||||
<Input
|
||||
id={`name-${project?.id || "new"}`}
|
||||
name="name"
|
||||
defaultValue={project?.name || ""}
|
||||
required
|
||||
placeholder="Örn. Marka web sitesi"
|
||||
/>
|
||||
</div>
|
||||
<LocalizedFields
|
||||
idPrefix={`project-${project?.id || "new"}`}
|
||||
defaultLocale={localization.defaultLocale}
|
||||
locales={localization.locales}
|
||||
fields={contentTranslationRegistry.project}
|
||||
values={project?.translations}
|
||||
fallbackValues={{
|
||||
name: project?.name,
|
||||
description: project?.description,
|
||||
coverImageAlt: project?.cover_image_alt,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="grid gap-2">
|
||||
@@ -626,17 +640,6 @@ function ProjectFormFields({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor={`description-${project?.id || "new"}`}>Açıklama</Label>
|
||||
<Textarea
|
||||
id={`description-${project?.id || "new"}`}
|
||||
name="description"
|
||||
defaultValue={project?.description || ""}
|
||||
placeholder="Kapsam, hedef veya teslimat notları..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div className="grid gap-2">
|
||||
<Label>Durum</Label>
|
||||
@@ -742,7 +745,7 @@ function EmptyState({ hasQuery }: { hasQuery: boolean }) {
|
||||
}
|
||||
|
||||
function formatDate(value: string) {
|
||||
return new Intl.DateTimeFormat("tr-TR", {
|
||||
return new Intl.DateTimeFormat(getDocumentIntlLocale(), {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
@@ -750,7 +753,7 @@ function formatDate(value: string) {
|
||||
}
|
||||
|
||||
function formatCurrency(value: number) {
|
||||
return new Intl.NumberFormat("tr-TR", {
|
||||
return new Intl.NumberFormat(getDocumentIntlLocale(), {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
maximumFractionDigits: 0,
|
||||
|
||||
Reference in New Issue
Block a user