diff --git a/app/(dashboard)/analytics/page.tsx b/app/(dashboard)/analytics/page.tsx index 44a15fc..af6dddf 100644 --- a/app/(dashboard)/analytics/page.tsx +++ b/app/(dashboard)/analytics/page.tsx @@ -1,59 +1,19 @@ -import { createClient } from "@/lib/supabase/server"; -import { AnalyticsClient } from "./analytics-client"; -import { redirect } from "next/navigation"; +import { AnalyticsClient, type AnalyticsData } from "./analytics-client"; +import { parseDashboardRange, resolveDashboardRange } from "@/server/services/analytics-range"; +import { requireFreelancerBackend } from "@/server/web/freelancer"; -export const metadata = { - title: "Analizler - Neta", -}; +export const metadata = { title: "Analizler - Neta" }; export default async function AnalyticsPage({ searchParams, }: { - searchParams: { [key: string]: string | string[] | undefined }; + searchParams: Promise<{ [key: string]: string | string[] | undefined }>; }) { - const supabase = await createClient(); - const { data: { user } } = await supabase.auth.getUser(); + 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 }; - if (!user) { - redirect("/login"); - } - - const range = typeof searchParams.range === "string" ? searchParams.range : "this_month"; - - const now = new Date(); - let startDate = new Date(); - let endDate = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59); - - if (range === "this_week") { - const tempNow = new Date(); - const firstDay = new Date(tempNow.setDate(tempNow.getDate() - tempNow.getDay() + (tempNow.getDay() === 0 ? -6 : 1))); - firstDay.setHours(0, 0, 0, 0); - startDate = firstDay; - endDate = new Date(firstDay.getTime()); - endDate.setDate(endDate.getDate() + 6); - endDate.setHours(23, 59, 59, 999); - } else if (range === "this_month") { - startDate = new Date(now.getFullYear(), now.getMonth(), 1, 0, 0, 0); - endDate = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59); - } else if (range === "this_year") { - startDate = new Date(now.getFullYear(), 0, 1, 0, 0, 0); - endDate = new Date(now.getFullYear(), 11, 31, 23, 59, 59); - } - - // Fetch metrics using RPC - const { data: metricsData } = await supabase.rpc('get_analytics_metrics', { - p_start_date: startDate.toISOString(), - p_end_date: endDate.toISOString() - }); - - const analyticsData = { - metrics: metricsData || { - projectIncomeData: [], - completedTasks: 0, - activeTasks: 0 - }, - range - }; - - return ; + return ; } diff --git a/app/(dashboard)/calendar/actions.ts b/app/(dashboard)/calendar/actions.ts index ef6cc5c..23780a1 100644 --- a/app/(dashboard)/calendar/actions.ts +++ b/app/(dashboard)/calendar/actions.ts @@ -1,106 +1,67 @@ "use server"; -import { createClient } from "@/lib/supabase/server"; import { revalidatePath } from "next/cache"; +import { cleanText, optionalDate, requiredText } from "@/server/web/form-data"; +import { requireFreelancerBackend } from "@/server/web/freelancer"; const EVENT_TYPES = ["meeting", "focus", "deadline", "personal", "finance"] as const; -function cleanText(value: FormDataEntryValue | null) { - const text = typeof value === "string" ? value.trim() : ""; - return text.length > 0 && text !== "__none" ? text : null; +function eventType(value: FormDataEntryValue | null) { + return typeof value === "string" && EVENT_TYPES.includes(value as (typeof EVENT_TYPES)[number]) + ? value as (typeof EVENT_TYPES)[number] + : "focus"; } -function readType(value: FormDataEntryValue | null) { - const type = typeof value === "string" ? value : "focus"; - return EVENT_TYPES.includes(type as (typeof EVENT_TYPES)[number]) ? type : "focus"; -} - -async function getCurrentUserId() { - const supabase = await createClient(); - const { - data: { user }, - error, - } = await supabase.auth.getUser(); - - if (error || !user) { - throw new Error("Takvim işlemi için giriş yapmış kullanıcı bulunamadı."); - } - - return { supabase, userId: user.id }; -} - -function readPayload(formData: FormData) { +function payload(formData: FormData) { return { - title: cleanText(formData.get("title")), + title: requiredText(formData.get("title"), "Etkinlik başlığı zorunludur."), description: cleanText(formData.get("description")), - type: readType(formData.get("type")), - starts_at: cleanText(formData.get("starts_at")), - ends_at: cleanText(formData.get("ends_at")), - client_id: cleanText(formData.get("client_id")), - project_id: cleanText(formData.get("project_id")), - task_id: cleanText(formData.get("task_id")), + 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")), + }; +} + +function completeRelations( + value: ReturnType, + service: Awaited>["service"], + actor: Awaited>["actor"], +) { + const task = value.taskId ? service.listTasks(actor).find((item) => item.id === value.taskId) : null; + const projectId = value.projectId ?? task?.projectId ?? null; + const project = projectId ? service.getProject(actor, projectId) : null; + return { + ...value, + projectId, + clientId: value.clientId ?? task?.clientId ?? project?.clientId ?? null, }; } export async function createCalendarEventRecord(formData: FormData) { - const { supabase, userId } = await getCurrentUserId(); - const payload = readPayload(formData); - - if (!payload.title || !payload.starts_at) { - throw new Error("Etkinlik başlığı ve başlangıç zamanı zorunludur."); - } - - const { error } = await supabase.from("calendar_events").insert({ - user_id: userId, - ...payload, - }); - - if (error) { - throw new Error(`Etkinlik eklenemedi: ${error.message}`); - } - + const backend = await requireFreelancerBackend(); + const value = completeRelations(payload(formData), backend.service, backend.actor); + if (!value.startsAt) throw new Error("Etkinlik başlangıç zamanı zorunludur."); + backend.service.createCalendarEvent(backend.actor, value); revalidatePath("/calendar"); } export async function updateCalendarEventRecord(formData: FormData) { - const { supabase, userId } = await getCurrentUserId(); - const id = cleanText(formData.get("id")); - const payload = readPayload(formData); - - if (!id || !payload.title || !payload.starts_at) { - throw new Error("Etkinlik güncellemek için başlık, başlangıç ve kayıt kimliği zorunludur."); - } - - const { error } = await supabase - .from("calendar_events") - .update(payload) - .eq("id", id) - .eq("user_id", userId); - - if (error) { - throw new Error(`Etkinlik güncellenemedi: ${error.message}`); - } - + const backend = await requireFreelancerBackend(); + const id = requiredText(formData.get("id"), "Etkinlik kaydı bulunamadı."); + const value = completeRelations(payload(formData), backend.service, backend.actor); + if (!value.startsAt) throw new Error("Etkinlik başlangıç zamanı zorunludur."); + backend.service.updateCalendarEvent(backend.actor, id, value); revalidatePath("/calendar"); } export async function deleteCalendarEventRecord(formData: FormData) { - const { supabase, userId } = await getCurrentUserId(); - const id = cleanText(formData.get("id")); - - if (!id) { - throw new Error("Silinecek etkinlik bulunamadı."); - } - - const { error } = await supabase - .from("calendar_events") - .delete() - .eq("id", id) - .eq("user_id", userId); - - if (error) { - throw new Error(`Etkinlik silinemedi: ${error.message}`); - } - + const { actor, service } = await requireFreelancerBackend(); + service.deleteCalendarEvent( + actor, + requiredText(formData.get("id"), "Silinecek etkinlik bulunamadı."), + ); revalidatePath("/calendar"); } diff --git a/app/(dashboard)/calendar/page.tsx b/app/(dashboard)/calendar/page.tsx index 1395ffb..bd40f28 100644 --- a/app/(dashboard)/calendar/page.tsx +++ b/app/(dashboard)/calendar/page.tsx @@ -1,107 +1,39 @@ -import { - CalendarClient, - type CalendarEventItem, - type CalendarRelationOption, - type CalendarTaskOption, -} from "@/app/(dashboard)/calendar/calendar-client"; -import { createClient } from "@/lib/supabase/server"; - -type CalendarEventRow = { - id: string; - title: string; - description: string | null; - type: CalendarEventItem["type"]; - starts_at: string; - ends_at: string | null; - client_id: string | null; - project_id: string | null; - task_id: string | null; - clients: { name: string } | { name: string }[] | null; - projects: { name: string } | { name: string }[] | null; - tasks: { title: string } | { title: string }[] | null; -}; +import { CalendarClient, type CalendarEventItem, type CalendarRelationOption, type CalendarTaskOption } from "@/app/(dashboard)/calendar/calendar-client"; +import { requireFreelancerBackend } from "@/server/web/freelancer"; export default async function CalendarPage() { - const supabase = await createClient(); - const { - data: { user }, - } = await supabase.auth.getUser(); + const { actor, service } = await requireFreelancerBackend(); + const eventRows = service.listCalendarEvents(actor); + const clientRows = service.listClients(actor); + const projectRows = service.listProjects(actor); + const taskRows = service.listTasks(actor); + const clients = new Map(clientRows.map((item) => [item.id, item.name])); + const projects = new Map(projectRows.map((item) => [item.id, item.name])); + const tasks = new Map(taskRows.map((item) => [item.id, item.title])); - if (!user) { - return null; - } - - const [{ data: eventRows }, { data: clientRows }, { data: projectRows }, { data: taskRows }] = - await Promise.all([ - supabase - .from("calendar_events") - .select("id, title, description, type, starts_at, ends_at, client_id, project_id, task_id, clients(name), projects(name), tasks(title)") - .eq("user_id", user.id) - .order("starts_at", { ascending: true }), - supabase - .from("clients") - .select("id, name") - .eq("user_id", user.id) - .neq("status", "archived") - .order("name", { ascending: true }), - supabase - .from("projects") - .select("id, name") - .eq("user_id", user.id) - .neq("status", "cancelled") - .order("name", { ascending: true }), - supabase - .from("tasks") - .select("id, title") - .eq("user_id", user.id) - .neq("status", "done") - .order("created_at", { ascending: false }), - ]); - - const events: CalendarEventItem[] = ((eventRows || []) as unknown as CalendarEventRow[]).map((event) => ({ + const events: CalendarEventItem[] = eventRows.map((event) => ({ id: event.id, title: event.title, description: event.description, - type: normalizeType(event.type), - starts_at: event.starts_at, - ends_at: event.ends_at, - client_id: event.client_id, - project_id: event.project_id, - task_id: event.task_id, - clientName: getRelationName(event.clients), - projectName: getRelationName(event.projects), - taskTitle: getRelationTitle(event.tasks), + type: event.type, + starts_at: event.startsAt.toISOString(), + ends_at: event.endsAt?.toISOString() ?? null, + client_id: event.clientId, + project_id: event.projectId, + task_id: event.taskId, + clientName: event.clientId ? clients.get(event.clientId) ?? null : null, + projectName: event.projectId ? projects.get(event.projectId) ?? null : null, + taskTitle: event.taskId ? tasks.get(event.taskId) ?? null : null, })); + const clientOptions: CalendarRelationOption[] = clientRows + .filter((item) => item.status !== "archived") + .map(({ id, name }) => ({ id, name })); + const projectOptions: CalendarRelationOption[] = projectRows + .filter((item) => item.status !== "cancelled") + .map(({ id, name }) => ({ id, name })); + const taskOptions: CalendarTaskOption[] = taskRows + .filter((item) => item.status !== "done" && item.status !== "cancelled") + .map(({ id, title }) => ({ id, title })); - return ( - - ); -} - -function getRelationName(relation: CalendarEventRow["clients"] | CalendarEventRow["projects"]) { - if (!relation) return null; - return Array.isArray(relation) ? relation[0]?.name || null : relation.name; -} - -function getRelationTitle(relation: CalendarEventRow["tasks"]) { - if (!relation) return null; - return Array.isArray(relation) ? relation[0]?.title || null : relation.title; -} - -function normalizeType(type: string): CalendarEventItem["type"] { - if ( - type === "meeting" || - type === "deadline" || - type === "personal" || - type === "finance" - ) { - return type; - } - - return "focus"; + return ; } diff --git a/app/(dashboard)/clients/[id]/actions.ts b/app/(dashboard)/clients/[id]/actions.ts index 374eefc..5d16f4a 100644 --- a/app/(dashboard)/clients/[id]/actions.ts +++ b/app/(dashboard)/clients/[id]/actions.ts @@ -1,49 +1,26 @@ "use server"; -import { createClient } from "@/lib/supabase/server"; import { revalidatePath } from "next/cache"; +import { cleanText, optionalDate, requiredText } from "@/server/web/form-data"; +import { requireFreelancerBackend } from "@/server/web/freelancer"; -function cleanText(value: FormDataEntryValue | null) { - const text = typeof value === "string" ? value.trim() : ""; - return text.length > 0 ? text : null; -} +const ACTIVITY_TYPES = ["note", "call", "meeting", "email"] as const; export async function addClientActivity(clientId: string, formData: FormData) { - const supabase = await createClient(); - const { - data: { user }, - error: userError, - } = await supabase.auth.getUser(); + const { actor, service } = await requireFreelancerBackend(); + const rawType = cleanText(formData.get("type")); + const type = rawType && ACTIVITY_TYPES.includes(rawType as (typeof ACTIVITY_TYPES)[number]) + ? rawType as (typeof ACTIVITY_TYPES)[number] + : "note"; - if (userError || !user) { - throw new Error("Kullanıcı bulunamadı."); - } - - const title = cleanText(formData.get("title")); - if (!title) { - throw new Error("Aktivite başlığı zorunludur."); - } - - const { error } = await supabase.from("client_activities").insert({ - user_id: user.id, - client_id: clientId, - type: formData.get("type") as string || "note", - title, + service.addClientActivity(actor, { + clientId, + type, + title: requiredText(formData.get("title"), "Aktivite başlığı zorunludur."), content: cleanText(formData.get("content")), - activity_date: formData.get("activity_date") as string || new Date().toISOString(), + activityDate: optionalDate(formData.get("activity_date")) ?? new Date(), }); - if (error) { - throw new Error(`Aktivite eklenemedi: ${error.message}`); - } - - // Update client's last_contact_date - await supabase - .from("clients") - .update({ last_contact_date: new Date().toISOString() }) - .eq("id", clientId) - .eq("user_id", user.id); - revalidatePath(`/clients/${clientId}`); - revalidatePath(`/clients`); + revalidatePath("/clients"); } diff --git a/app/(dashboard)/clients/[id]/page.tsx b/app/(dashboard)/clients/[id]/page.tsx index 485736e..4c69f49 100644 --- a/app/(dashboard)/clients/[id]/page.tsx +++ b/app/(dashboard)/clients/[id]/page.tsx @@ -1,34 +1,41 @@ -import { createClient } from "@/lib/supabase/server"; -import { ClientDetailClient, type ClientDetailData, type ClientActivity } from "./client-detail-client"; import { notFound } from "next/navigation"; +import { ClientDetailClient, type ClientDetailData, type ClientActivity } from "./client-detail-client"; +import { DomainError } from "@/server/domain/errors"; +import { requireFreelancerBackend } from "@/server/web/freelancer"; export default async function ClientDetailPage({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; - const supabase = await createClient(); - const { data: { user } } = await supabase.auth.getUser(); + const { actor, service } = await requireFreelancerBackend(); - if (!user) return null; + let data: { client: ClientDetailData; activities: ClientActivity[] }; + try { + const row = service.getClient(actor, id); + const client: ClientDetailData = { + id: row.id, + name: row.name, + company_name: row.companyName, + email: row.email, + phone: row.phone, + website: row.website, + pipeline_stage: row.pipelineStage, + status: row.status, + notes: row.notes, + client_auth_id: row.authUserId, + }; + const activities: ClientActivity[] = service.listClientActivities(actor, id).map((activity) => ({ + id: activity.id, + type: activity.type, + title: activity.title, + content: activity.content, + activity_date: activity.activityDate.toISOString(), + created_at: activity.createdAt.toISOString(), + })); - const { data: clientData, error } = await supabase - .from("clients") - .select("id, name, company_name, email, phone, website, pipeline_stage, status, notes, client_auth_id") - .eq("id", id) - .eq("user_id", user.id) - .single(); - - if (error || !clientData) { - notFound(); + data = { client, activities }; + } catch (error) { + if (error instanceof DomainError && error.code === "NOT_FOUND") notFound(); + throw error; } - const { data: activitiesData } = await supabase - .from("client_activities") - .select("id, type, title, content, activity_date, created_at") - .eq("client_id", id) - .eq("user_id", user.id) - .order("activity_date", { ascending: false }); - - const client: ClientDetailData = clientData as ClientDetailData; - const activities: ClientActivity[] = (activitiesData || []) as ClientActivity[]; - - return ; + return ; } diff --git a/app/(dashboard)/clients/actions.ts b/app/(dashboard)/clients/actions.ts index 89e6e6b..7391761 100644 --- a/app/(dashboard)/clients/actions.ts +++ b/app/(dashboard)/clients/actions.ts @@ -1,143 +1,66 @@ "use server"; -import { createClient } from "@/lib/supabase/server"; import { revalidatePath } from "next/cache"; +import { requireFreelancerBackend } from "@/server/web/freelancer"; +import { cleanText, requiredText } from "@/server/web/form-data"; const CLIENT_STATUSES = ["active", "paused", "archived"] as const; +const PIPELINE_STAGES = ["lead", "contacted", "proposal_sent", "won", "lost"] as const; -function cleanText(value: FormDataEntryValue | null) { - const text = typeof value === "string" ? value.trim() : ""; - return text.length > 0 ? text : null; -} - -function readStatus(value: FormDataEntryValue | null) { - const status = typeof value === "string" ? value : "active"; - return CLIENT_STATUSES.includes(status as (typeof CLIENT_STATUSES)[number]) - ? status - : "active"; +function enumValue( + value: FormDataEntryValue | string | null, + values: T, + fallback: T[number], +): T[number] { + return typeof value === "string" && values.includes(value) ? value as T[number] : fallback; } function cleanWebsite(value: FormDataEntryValue | null) { - const website = cleanText(value)?.replace(/\s/g, "") || null; - - if (!website) { - return null; - } - - return /^https?:\/\//i.test(website) ? website : `https://${website}`; + const website = cleanText(value)?.replace(/\s/g, "") ?? null; + return website && !/^https?:\/\//i.test(website) ? `https://${website}` : website; } -async function getCurrentUserId() { - const supabase = await createClient(); - const { - data: { user }, - error, - } = await supabase.auth.getUser(); - - if (error || !user) { - throw new Error("Müşteri işlemi için giriş yapmış kullanıcı bulunamadı."); - } - - return { supabase, userId: user.id }; -} - -export async function createClientRecord(formData: FormData) { - const { supabase, userId } = await getCurrentUserId(); - const name = cleanText(formData.get("name")); - - if (!name) { - throw new Error("Müşteri adı zorunludur."); - } - - const { error } = await supabase.from("clients").insert({ - user_id: userId, - name, - company_name: cleanText(formData.get("company_name")), +function readPayload(formData: FormData) { + return { + name: requiredText(formData.get("name"), "Müşteri adı zorunludur."), + companyName: cleanText(formData.get("company_name")), email: cleanText(formData.get("email")), phone: cleanText(formData.get("phone")), website: cleanWebsite(formData.get("website")), - status: readStatus(formData.get("status")), + status: enumValue(formData.get("status"), CLIENT_STATUSES, "active"), notes: cleanText(formData.get("notes")), - pipeline_stage: formData.get("pipeline_stage") ? String(formData.get("pipeline_stage")) : "lead", - next_follow_up_date: cleanText(formData.get("next_follow_up_date")) || null, - }); - - if (error) { - throw new Error(`Müşteri eklenemedi: ${error.message}`); - } + pipelineStage: enumValue(formData.get("pipeline_stage"), PIPELINE_STAGES, "lead"), + nextFollowUpDate: cleanText(formData.get("next_follow_up_date")), + }; +} +export async function createClientRecord(formData: FormData) { + const { actor, service } = await requireFreelancerBackend(); + service.createClient(actor, readPayload(formData)); revalidatePath("/clients"); } export async function updateClientRecord(formData: FormData) { - const { supabase, userId } = await getCurrentUserId(); - const id = cleanText(formData.get("id")); - const name = cleanText(formData.get("name")); - - if (!id || !name) { - throw new Error("Müşteri güncellemek için müşteri adı ve kayıt kimliği zorunludur."); - } - - const { error } = await supabase - .from("clients") - .update({ - name, - company_name: cleanText(formData.get("company_name")), - email: cleanText(formData.get("email")), - phone: cleanText(formData.get("phone")), - website: cleanWebsite(formData.get("website")), - status: readStatus(formData.get("status")), - notes: cleanText(formData.get("notes")), - pipeline_stage: formData.get("pipeline_stage") ? String(formData.get("pipeline_stage")) : "lead", - next_follow_up_date: cleanText(formData.get("next_follow_up_date")) || null, - }) - .eq("id", id) - .eq("user_id", userId); - - if (error) { - throw new Error(`Müşteri güncellenemedi: ${error.message}`); - } - + const { actor, service } = await requireFreelancerBackend(); + const id = requiredText(formData.get("id"), "Müşteri kaydı bulunamadı."); + service.updateClient(actor, id, readPayload(formData)); revalidatePath("/clients"); + revalidatePath(`/clients/${id}`); } export async function archiveClientRecord(formData: FormData) { - const { supabase, userId } = await getCurrentUserId(); - const id = cleanText(formData.get("id")); - - if (!id) { - throw new Error("Arşivlenecek müşteri bulunamadı."); - } - - const { error } = await supabase - .from("clients") - .update({ status: "archived" }) - .eq("id", id) - .eq("user_id", userId); - - if (error) { - throw new Error(`Müşteri arşivlenemedi: ${error.message}`); - } - + const { actor, service } = await requireFreelancerBackend(); + const id = requiredText(formData.get("id"), "Arşivlenecek müşteri bulunamadı."); + service.updateClient(actor, id, { status: "archived" }); revalidatePath("/clients"); + revalidatePath(`/clients/${id}`); } export async function updateClientPipelineStage(id: string, stage: string) { - const { supabase, userId } = await getCurrentUserId(); - - if (!id || !stage) { - throw new Error("Eksik bilgi."); - } - - const { error } = await supabase - .from("clients") - .update({ pipeline_stage: stage }) - .eq("id", id) - .eq("user_id", userId); - - if (error) { - throw new Error(`Aşama güncellenemedi: ${error.message}`); - } - + const { actor, service } = await requireFreelancerBackend(); + service.updateClient(actor, id, { + pipelineStage: enumValue(stage, PIPELINE_STAGES, "lead"), + }); revalidatePath("/clients"); + revalidatePath(`/clients/${id}`); } diff --git a/app/(dashboard)/clients/page.tsx b/app/(dashboard)/clients/page.tsx index a72c8a8..721cee8 100644 --- a/app/(dashboard)/clients/page.tsx +++ b/app/(dashboard)/clients/page.tsx @@ -1,110 +1,64 @@ import { ClientsClient, type ClientListItem } from "@/app/(dashboard)/clients/clients-client"; -import { createClient } from "@/lib/supabase/server"; - -type ClientRow = { - id: string; - name: string; - company_name: string | null; - email: string | null; - phone: string | null; - website: string | null; - status: "active" | "paused" | "archived"; - notes: string | null; - pipeline_stage: "lead" | "contacted" | "proposal_sent" | "won" | "lost"; - next_follow_up_date: string | null; - last_contact_date: string | null; - client_value_score: number; - created_at: string; -}; - -type ProjectRow = { - client_id: string | null; -}; - -type FinanceRow = { - client_id: string | null; - amount: number | string; - type: "income" | "expense"; - payment_status: "planned" | "pending" | "paid" | "cancelled"; -}; +import { requireFreelancerBackend } from "@/server/web/freelancer"; export default async function ClientsPage() { - const supabase = await createClient(); - const { - data: { user }, - } = await supabase.auth.getUser(); + const { actor, service } = await requireFreelancerBackend(); + const clientsData = service.listClients(actor); + const projects = service.listProjects(actor); + const finance = service.listFinanceTransactions(actor); + const activities = service.listAllClientActivities(actor); - if (!user) { - return null; + const projectCountByClient = new Map(); + for (const project of projects) { + if (project.clientId) { + projectCountByClient.set(project.clientId, (projectCountByClient.get(project.clientId) ?? 0) + 1); + } } - const [{ data: clientRows }, { data: projectRows }, { data: financeRows }] = - await Promise.all([ - supabase - .from("clients") - .select("id, name, company_name, email, phone, website, status, notes, created_at, pipeline_stage, next_follow_up_date, last_contact_date, client_value_score") - .eq("user_id", user.id) - .order("created_at", { ascending: false }), - supabase.from("projects").select("client_id").eq("user_id", user.id), - supabase - .from("finance_transactions") - .select("client_id, amount, type, payment_status") - .eq("user_id", user.id), - ]); + const revenueByClient = new Map(); + for (const transaction of finance) { + if (transaction.clientId && transaction.type === "income" && transaction.paymentStatus === "paid") { + revenueByClient.set( + transaction.clientId, + (revenueByClient.get(transaction.clientId) ?? 0) + transaction.amountMinor / 100, + ); + } + } - const projectCountByClient = countProjectsByClient((projectRows || []) as ProjectRow[]); - const revenueByClient = sumRevenueByClient((financeRows || []) as FinanceRow[]); + const lastActivityByClient = new Map(); + for (const activity of activities) { + if (!lastActivityByClient.has(activity.clientId)) { + lastActivityByClient.set(activity.clientId, activity.activityDate); + } + } - const clients: ClientListItem[] = ((clientRows || []) as ClientRow[]).map((client) => ({ - ...client, - projectCount: projectCountByClient.get(client.id) || 0, - revenueTotal: revenueByClient.get(client.id) || 0, - })); - - const activeCount = clients.filter((client) => client.status === "active").length; - const pausedCount = clients.filter((client) => client.status === "paused").length; - const archivedCount = clients.filter((client) => client.status === "archived").length; - const totalRevenue = clients.reduce((sum, client) => sum + client.revenueTotal, 0); + const clients: ClientListItem[] = clientsData.map((client) => { + return { + id: client.id, + name: client.name, + company_name: client.companyName, + email: client.email, + phone: client.phone, + website: client.website, + status: client.status, + notes: client.notes, + pipeline_stage: client.pipelineStage, + next_follow_up_date: client.nextFollowUpDate, + last_contact_date: lastActivityByClient.get(client.id)?.toISOString() ?? null, + client_value_score: 0, + created_at: client.createdAt.toISOString(), + projectCount: projectCountByClient.get(client.id) ?? 0, + revenueTotal: revenueByClient.get(client.id) ?? 0, + }; + }); return ( sum + client.revenueTotal, 0)} + activeCount={clients.filter((client) => client.status === "active").length} + pausedCount={clients.filter((client) => client.status === "paused").length} + archivedCount={clients.filter((client) => client.status === "archived").length} /> ); } - -function countProjectsByClient(projects: ProjectRow[]) { - const countByClient = new Map(); - - for (const project of projects) { - if (!project.client_id) continue; - countByClient.set(project.client_id, (countByClient.get(project.client_id) || 0) + 1); - } - - return countByClient; -} - -function sumRevenueByClient(transactions: FinanceRow[]) { - const revenueByClient = new Map(); - - for (const transaction of transactions) { - if ( - !transaction.client_id || - transaction.type !== "income" || - transaction.payment_status !== "paid" - ) { - continue; - } - - revenueByClient.set( - transaction.client_id, - (revenueByClient.get(transaction.client_id) || 0) + Number(transaction.amount || 0), - ); - } - - return revenueByClient; -} diff --git a/app/(dashboard)/finance/actions.ts b/app/(dashboard)/finance/actions.ts index 196e5c2..3d21eda 100644 --- a/app/(dashboard)/finance/actions.ts +++ b/app/(dashboard)/finance/actions.ts @@ -1,122 +1,72 @@ "use server"; -import { createClient } from "@/lib/supabase/server"; import { revalidatePath } from "next/cache"; +import { cleanText, decimalToMinor, requiredText } from "@/server/web/form-data"; +import { requireFreelancerBackend } from "@/server/web/freelancer"; -const TRANSACTION_TYPES = ["income", "expense"] as const; -const PAYMENT_STATUSES = ["planned", "pending", "paid", "cancelled"] as const; +const TYPES = ["income", "expense"] as const; +const STATUSES = ["planned", "pending", "paid", "cancelled"] as const; -function cleanText(value: FormDataEntryValue | null) { - const text = typeof value === "string" ? value.trim() : ""; - return text.length > 0 && text !== "__none" ? text : null; +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 readType(value: FormDataEntryValue | null) { - const type = typeof value === "string" ? value : "expense"; - return TRANSACTION_TYPES.includes(type as (typeof TRANSACTION_TYPES)[number]) - ? type - : "expense"; -} - -function readPaymentStatus(value: FormDataEntryValue | null) { - const status = typeof value === "string" ? value : "planned"; - return PAYMENT_STATUSES.includes(status as (typeof PAYMENT_STATUSES)[number]) - ? status - : "planned"; -} - -function readAmount(value: FormDataEntryValue | null) { - const amount = Number(typeof value === "string" ? value.replace(",", ".") : value); - return Number.isFinite(amount) && amount >= 0 ? amount : null; -} - -async function getCurrentUserId() { - const supabase = await createClient(); - const { - data: { user }, - error, - } = await supabase.auth.getUser(); - - if (error || !user) { - throw new Error("Finans işlemi için giriş yapmış kullanıcı bulunamadı."); - } - - return { supabase, userId: user.id }; -} - -function readPayload(formData: FormData) { +function payload(formData: FormData) { + const amountMinor = decimalToMinor(formData.get("amount")); + if (amountMinor == null) throw new Error("Tutar zorunludur."); return { - type: readType(formData.get("type")), - amount: readAmount(formData.get("amount")), - currency: cleanText(formData.get("currency")) || "USD", - transaction_date: cleanText(formData.get("transaction_date")) || new Date().toISOString().slice(0, 10), + 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")), - payment_status: readPaymentStatus(formData.get("payment_status")), - client_id: cleanText(formData.get("client_id")), - project_id: cleanText(formData.get("project_id")), + 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")), }; } +function completeRelations( + value: ReturnType, + service: Awaited>["service"], + actor: Awaited>["actor"], +) { + const project = value.projectId ? service.getProject(actor, value.projectId) : null; + return { ...value, clientId: value.clientId ?? project?.clientId ?? null }; +} + export async function createFinanceTransactionRecord(formData: FormData) { - const { supabase, userId } = await getCurrentUserId(); - const payload = readPayload(formData); - - if (payload.amount === null) { - throw new Error("Tutar zorunludur."); - } - - const { error } = await supabase.from("finance_transactions").insert({ - user_id: userId, - ...payload, - }); - - if (error) { - throw new Error(`Finans işlemi eklenemedi: ${error.message}`); - } - + const backend = await requireFreelancerBackend(); + backend.service.createFinanceTransaction( + backend.actor, + completeRelations(payload(formData), backend.service, backend.actor), + ); revalidatePath("/finance"); + revalidatePath("/clients"); + revalidatePath("/projects"); } export async function updateFinanceTransactionRecord(formData: FormData) { - const { supabase, userId } = await getCurrentUserId(); - const id = cleanText(formData.get("id")); - const payload = readPayload(formData); - - if (!id || payload.amount === null) { - throw new Error("Finans işlemini güncellemek için kayıt kimliği ve tutar zorunludur."); - } - - const { error } = await supabase - .from("finance_transactions") - .update(payload) - .eq("id", id) - .eq("user_id", userId); - - if (error) { - throw new Error(`Finans işlemi güncellenemedi: ${error.message}`); - } - + const backend = await requireFreelancerBackend(); + const id = requiredText(formData.get("id"), "Finans kaydı bulunamadı."); + backend.service.updateFinanceTransaction( + backend.actor, + id, + completeRelations(payload(formData), backend.service, backend.actor), + ); revalidatePath("/finance"); + revalidatePath("/clients"); + revalidatePath("/projects"); } export async function deleteFinanceTransactionRecord(formData: FormData) { - const { supabase, userId } = await getCurrentUserId(); - const id = cleanText(formData.get("id")); - - if (!id) { - throw new Error("Silinecek finans işlemi bulunamadı."); - } - - const { error } = await supabase - .from("finance_transactions") - .delete() - .eq("id", id) - .eq("user_id", userId); - - if (error) { - throw new Error(`Finans işlemi silinemedi: ${error.message}`); - } - + const { actor, service } = await requireFreelancerBackend(); + service.deleteFinanceTransaction( + actor, + requiredText(formData.get("id"), "Silinecek finans kaydı bulunamadı."), + ); revalidatePath("/finance"); + revalidatePath("/clients"); + revalidatePath("/projects"); } diff --git a/app/(dashboard)/finance/page.tsx b/app/(dashboard)/finance/page.tsx index ff354a8..df90924 100644 --- a/app/(dashboard)/finance/page.tsx +++ b/app/(dashboard)/finance/page.tsx @@ -1,93 +1,34 @@ -import { - FinanceClient, - type FinanceRelationOption, - type FinanceTransactionItem, -} from "@/app/(dashboard)/finance/finance-client"; -import { createClient } from "@/lib/supabase/server"; - -type FinanceRow = { - id: string; - type: "income" | "expense"; - amount: number | string; - currency: string; - transaction_date: string; - category: string | null; - payment_status: "planned" | "pending" | "paid" | "cancelled"; - client_id: string | null; - project_id: string | null; - description: string | null; - clients: { name: string } | { name: string }[] | null; - projects: { name: string } | { name: string }[] | null; -}; +import { FinanceClient, type FinanceRelationOption, type FinanceTransactionItem } from "@/app/(dashboard)/finance/finance-client"; +import { requireFreelancerBackend } from "@/server/web/freelancer"; export default async function FinancePage() { - const supabase = await createClient(); - const { - data: { user }, - } = await supabase.auth.getUser(); + const { actor, service } = await requireFreelancerBackend(); + const rows = service.listFinanceTransactions(actor); + const clientRows = service.listClients(actor); + const projectRows = service.listProjects(actor); + const clients = new Map(clientRows.map((item) => [item.id, item.name])); + const projects = new Map(projectRows.map((item) => [item.id, item.name])); - if (!user) { - return null; - } - - const [{ data: financeRows }, { data: clientRows }, { data: projectRows }] = - await Promise.all([ - supabase - .from("finance_transactions") - .select("id, type, amount, currency, transaction_date, category, payment_status, client_id, project_id, description, clients(name), projects(name)") - .eq("user_id", user.id) - .order("transaction_date", { ascending: false }), - supabase - .from("clients") - .select("id, name") - .eq("user_id", user.id) - .neq("status", "archived") - .order("name", { ascending: true }), - supabase - .from("projects") - .select("id, name, client_id") - .eq("user_id", user.id) - .neq("status", "cancelled") - .order("name", { ascending: true }), - ]); - - const transactions: FinanceTransactionItem[] = ((financeRows || []) as unknown as FinanceRow[]).map((transaction) => ({ + const transactions: FinanceTransactionItem[] = rows.map((transaction) => ({ id: transaction.id, - type: normalizeType(transaction.type), - amount: Number(transaction.amount), + type: transaction.type, + amount: transaction.amountMinor / 100, currency: transaction.currency, - transaction_date: transaction.transaction_date, + transaction_date: transaction.transactionDate, category: transaction.category, - payment_status: normalizePaymentStatus(transaction.payment_status), - client_id: transaction.client_id, - project_id: transaction.project_id, - clientName: getRelationName(transaction.clients), - projectName: getRelationName(transaction.projects), + payment_status: transaction.paymentStatus, + client_id: transaction.clientId, + project_id: transaction.projectId, + clientName: transaction.clientId ? clients.get(transaction.clientId) ?? null : null, + projectName: transaction.projectId ? projects.get(transaction.projectId) ?? null : null, description: transaction.description, })); + const clientOptions: FinanceRelationOption[] = clientRows + .filter((item) => item.status !== "archived") + .map(({ id, name }) => ({ id, name })); + const projectOptions: FinanceRelationOption[] = projectRows + .filter((item) => item.status !== "cancelled") + .map(({ id, name, clientId }) => ({ id, name, client_id: clientId })); - return ( - - ); -} - -function getRelationName(relation: FinanceRow["clients"] | FinanceRow["projects"]) { - if (!relation) return null; - return Array.isArray(relation) ? relation[0]?.name || null : relation.name; -} - -function normalizeType(type: string): FinanceTransactionItem["type"] { - return type === "income" ? "income" : "expense"; -} - -function normalizePaymentStatus(status: string): FinanceTransactionItem["payment_status"] { - if (status === "pending" || status === "paid" || status === "cancelled") { - return status; - } - - return "planned"; + return ; } diff --git a/app/(dashboard)/journal/actions.ts b/app/(dashboard)/journal/actions.ts index d9e512d..b98648b 100644 --- a/app/(dashboard)/journal/actions.ts +++ b/app/(dashboard)/journal/actions.ts @@ -1,106 +1,48 @@ "use server"; -import { createClient } from "@/lib/supabase/server"; import { revalidatePath } from "next/cache"; +import { cleanText, requiredText } from "@/server/web/form-data"; +import { requireFreelancerBackend } from "@/server/web/freelancer"; -function cleanText(value: FormDataEntryValue | null) { - const text = typeof value === "string" ? value.trim() : ""; - return text.length > 0 ? text : null; +function score(value: FormDataEntryValue | null): number | null { + const parsed = Number(value); + return Number.isInteger(parsed) && parsed >= 1 && parsed <= 5 ? parsed : null; } -function readScore(value: FormDataEntryValue | null) { - const score = Number(typeof value === "string" ? value : value?.toString()); - return Number.isInteger(score) && score >= 1 && score <= 5 ? score : null; -} - -async function getCurrentUserId() { - const supabase = await createClient(); - const { - data: { user }, - error, - } = await supabase.auth.getUser(); - - if (error || !user) { - throw new Error("Günlük kaydı için giriş yapmış kullanıcı bulunamadı."); - } - - return { supabase, userId: user.id }; -} - -function readPayload(formData: FormData) { +function payload(formData: FormData) { + const moodScore = score(formData.get("mood_score")); + const energyScore = score(formData.get("energy_score")); + if (!moodScore || !energyScore) throw new Error("Mood ve enerji skorları zorunludur."); return { - log_date: cleanText(formData.get("log_date")) || new Date().toISOString().slice(0, 10), - mood_score: readScore(formData.get("mood_score")), - energy_score: readScore(formData.get("energy_score")), - work_satisfaction_score: readScore(formData.get("work_satisfaction_score")), + entryDate: cleanText(formData.get("log_date")) ?? new Date().toISOString().slice(0, 10), + moodScore, + energyScore, + workSatisfactionScore: score(formData.get("work_satisfaction_score")), note: cleanText(formData.get("note")), }; } export async function createDailyLogRecord(formData: FormData) { - const { supabase, userId } = await getCurrentUserId(); - const payload = readPayload(formData); - - if (!payload.mood_score || !payload.energy_score) { - throw new Error("Mood ve enerji skorları zorunludur."); - } - - const { error } = await supabase - .from("daily_logs") - .upsert( - { - user_id: userId, - ...payload, - }, - { onConflict: "user_id,log_date" }, - ); - - if (error) { - throw new Error(`Günlük kaydı eklenemedi: ${error.message}`); - } - + const { actor, service } = await requireFreelancerBackend(); + service.saveJournalEntry(actor, payload(formData)); revalidatePath("/journal"); } export async function updateDailyLogRecord(formData: FormData) { - const { supabase, userId } = await getCurrentUserId(); - const id = cleanText(formData.get("id")); - const payload = readPayload(formData); - - if (!id || !payload.mood_score || !payload.energy_score) { - throw new Error("Günlük kaydını güncellemek için kayıt kimliği, mood ve enerji skorları zorunludur."); - } - - const { error } = await supabase - .from("daily_logs") - .update(payload) - .eq("id", id) - .eq("user_id", userId); - - if (error) { - throw new Error(`Günlük kaydı güncellenemedi: ${error.message}`); - } - + const { actor, service } = await requireFreelancerBackend(); + service.updateJournalEntry( + actor, + requiredText(formData.get("id"), "Günlük kaydı bulunamadı."), + payload(formData), + ); revalidatePath("/journal"); } export async function deleteDailyLogRecord(formData: FormData) { - const { supabase, userId } = await getCurrentUserId(); - const id = cleanText(formData.get("id")); - - if (!id) { - throw new Error("Silinecek günlük kaydı bulunamadı."); - } - - const { error } = await supabase - .from("daily_logs") - .delete() - .eq("id", id) - .eq("user_id", userId); - - if (error) { - throw new Error(`Günlük kaydı silinemedi: ${error.message}`); - } - + const { actor, service } = await requireFreelancerBackend(); + service.deleteJournalEntry( + actor, + requiredText(formData.get("id"), "Silinecek günlük kaydı bulunamadı."), + ); revalidatePath("/journal"); } diff --git a/app/(dashboard)/journal/page.tsx b/app/(dashboard)/journal/page.tsx index 483be4e..7fa3a8e 100644 --- a/app/(dashboard)/journal/page.tsx +++ b/app/(dashboard)/journal/page.tsx @@ -1,41 +1,22 @@ import { JournalClient, type DailyLogItem } from "@/app/(dashboard)/journal/journal-client"; -import { createClient } from "@/lib/supabase/server"; - -type DailyLogRow = { - id: string; - log_date: string; - mood_score: number; - energy_score: number; - work_satisfaction_score: number | null; - note: string | null; -}; +import { requireFreelancerBackend } from "@/server/web/freelancer"; export default async function JournalPage() { - const supabase = await createClient(); - const { - data: { user }, - } = await supabase.auth.getUser(); - - if (!user) { - return null; - } - - const { data: logRows } = await supabase - .from("daily_logs") - .select("id, log_date, mood_score, energy_score, work_satisfaction_score, note") - .eq("user_id", user.id) - .order("log_date", { ascending: false }) - .limit(180); - - const logs: DailyLogItem[] = ((logRows || []) as DailyLogRow[]).map((log) => ({ - id: log.id, - log_date: log.log_date, - mood_score: Number(log.mood_score), - energy_score: Number(log.energy_score), - work_satisfaction_score: - typeof log.work_satisfaction_score === "number" ? Number(log.work_satisfaction_score) : null, - note: log.note, - })); + const { actor, service } = await requireFreelancerBackend(); + const logs: DailyLogItem[] = service.listJournalEntries(actor) + .slice(0, 180) + .flatMap((entry) => + entry.moodScore == null || entry.energyScore == null + ? [] + : [{ + id: entry.id, + log_date: entry.entryDate, + mood_score: entry.moodScore, + energy_score: entry.energyScore, + work_satisfaction_score: entry.workSatisfactionScore, + note: entry.note, + }], + ); return ; } diff --git a/app/(dashboard)/page.tsx b/app/(dashboard)/page.tsx index 34ac59d..11b4775 100644 --- a/app/(dashboard)/page.tsx +++ b/app/(dashboard)/page.tsx @@ -1,85 +1,35 @@ -import { createClient } from "@/lib/supabase/server"; -import { DashboardClient } from "./dashboard-client"; -import { redirect } from "next/navigation"; +import { DashboardClient, type DashboardData } from "./dashboard-client"; +import { parseDashboardRange, resolveDashboardRange } from "@/server/services/analytics-range"; +import { requireFreelancerBackend } from "@/server/web/freelancer"; -export const metadata = { - title: "Dashboard - Neta", -}; +export const metadata = { title: "Dashboard - Neta" }; export default async function DashboardPage({ searchParams, }: { - searchParams: { [key: string]: string | string[] | undefined }; + searchParams: Promise<{ [key: string]: string | string[] | undefined }>; }) { - const supabase = await createClient(); - const { data: { user } } = await supabase.auth.getUser(); + const params = await searchParams; + const range = parseDashboardRange(params.range); + const { actor, service } = await requireFreelancerBackend(); + const result = service.getFreelancerDashboard(actor, resolveDashboardRange(range)); - if (!user) { - redirect("/login"); - } - - const range = typeof searchParams.range === "string" ? searchParams.range : "this_month"; - - const now = new Date(); - let startDate = new Date(); - let endDate = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59); // default to end of month - - if (range === "today") { - startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0); - endDate = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59); - } else if (range === "this_week") { - // Reset `now` because setDate mutates - const tempNow = new Date(); - const firstDay = new Date(tempNow.setDate(tempNow.getDate() - tempNow.getDay() + (tempNow.getDay() === 0 ? -6 : 1))); - firstDay.setHours(0, 0, 0, 0); - startDate = firstDay; - endDate = new Date(firstDay.getTime()); - endDate.setDate(endDate.getDate() + 6); - endDate.setHours(23, 59, 59, 999); - } else if (range === "this_month") { - startDate = new Date(now.getFullYear(), now.getMonth(), 1, 0, 0, 0); - endDate = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59); - } else if (range === "this_year") { - startDate = new Date(now.getFullYear(), 0, 1, 0, 0, 0); - endDate = new Date(now.getFullYear(), 11, 31, 23, 59, 59); - } - - // Fetch metrics using RPC - const { data: metricsData } = await supabase.rpc('get_dashboard_metrics', { - p_start_date: startDate.toISOString(), - p_end_date: endDate.toISOString() - }); - - // Fetch limited recent data - const [ - { data: projects }, - { data: clients }, - ] = await Promise.all([ - supabase - .from("projects") - .select("id, status, name, created_at") - .order("created_at", { ascending: false }) - .limit(5), - supabase - .from("clients") - .select("id, name, company_name, created_at") - .order("created_at", { ascending: false }) - .limit(5), - ]); - - const dashboardData = { - metrics: metricsData || { - netProfit: 0, - activeProjectsCount: 0, - completedTasksCount: 0, - avgMood: "0.0", - financeTrend: [], - moodTrend: [] - }, - projects: projects || [], - clients: clients || [], - range + const data: DashboardData = { + metrics: result.metrics, + projects: result.projects.map((project) => ({ + id: project.id, + status: project.status, + name: project.name, + created_at: project.createdAt.toISOString(), + })), + clients: result.clients.map((client) => ({ + id: client.id, + name: client.name, + company_name: client.companyName ?? "", + created_at: client.createdAt.toISOString(), + })), + range, }; - return ; + return ; } diff --git a/app/(dashboard)/projects/[id]/page.tsx b/app/(dashboard)/projects/[id]/page.tsx index da178ba..1bdc742 100644 --- a/app/(dashboard)/projects/[id]/page.tsx +++ b/app/(dashboard)/projects/[id]/page.tsx @@ -1,3 +1,4 @@ +import { notFound } from "next/navigation"; import { ProjectDetailClient, type ProjectDetail, @@ -5,236 +6,91 @@ import { type ProjectFinanceItem, type ProjectPlanningSectionItem, } from "@/app/(dashboard)/projects/[id]/project-detail-client"; -import { createServiceRoleClient } from "@/lib/supabase/admin"; -import { createClient } from "@/lib/supabase/server"; -import { notFound } from "next/navigation"; +import { DomainError } from "@/server/domain/errors"; +import { requireFreelancerBackend } from "@/server/web/freelancer"; -type ProjectRow = { - id: string; - client_id: string | null; - name: string; - type: "client_project" | "side_project"; - description: string | null; - status: "planning" | "active" | "paused" | "completed" | "cancelled"; - start_date: string | null; - due_date: string | null; - budget_amount: number | string | null; - currency: string; - progress: number; - progress_type: "manual" | "auto" | null; - revision_quota: number | null; - cover_image_path: string | null; - cover_image_alt: string | null; - clients: { name: string } | { name: string }[] | null; -}; - -type SectionRow = ProjectPlanningSectionItem; - -type TaskRow = { - id: string; - title: string; - status: string | null; - priority: string | null; - due_at: string | null; - is_public_to_client: boolean | null; -}; - -type FinanceRow = { - id: string; - type: string; - amount: number | string; - currency: string; - payment_status: string; - transaction_date: string; - category: string | null; -}; - -export default async function ProjectDetailPage({ - params, -}: { - params: Promise<{ id: string }>; -}) { +export default async function ProjectDetailPage({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; - const supabase = await createClient(); - const { - data: { user }, - } = await supabase.auth.getUser(); + const { actor, service } = await requireFreelancerBackend(); - if (!user) { - return null; - } - - const [{ data: projectRow }, { data: sectionRows }, { data: taskRows }, { data: financeRows }, { data: revisionRows }] = - await Promise.all([ - supabase - .from("projects") - .select( - "id, client_id, name, type, description, status, start_date, due_date, budget_amount, currency, progress, progress_type, revision_quota, cover_image_path, cover_image_alt, clients(name)", - ) - .eq("id", id) - .eq("user_id", user.id) - .maybeSingle(), - supabase - .from("project_planning_sections") - .select("id, project_id, category, title, content, sort_order") - .eq("project_id", id) - .eq("user_id", user.id) - .order("sort_order", { ascending: true }) - .order("created_at", { ascending: true }), - supabase - .from("tasks") - .select("id, title, status, priority, due_at, is_public_to_client") - .eq("project_id", id) - .eq("user_id", user.id) - .order("created_at", { ascending: false }), - supabase - .from("finance_transactions") - .select("id, type, amount, currency, payment_status, transaction_date, category") - .eq("project_id", id) - .eq("user_id", user.id) - .order("transaction_date", { ascending: false }), - supabase - .from("project_revisions") - .select("id, description, status, created_at, requested_by") - .eq("project_id", id) - .order("created_at", { ascending: false }), - ]); - - if (!projectRow) { - notFound(); - } - - const projectData = projectRow as unknown as ProjectRow; - const coverImageUrl = projectData.cover_image_path - ? await createProjectImageUrl(projectData.cover_image_path) - : null; - - const project: ProjectDetail = { - id: projectData.id, - client_id: projectData.client_id, - clientName: getClientName(projectData.clients), - name: projectData.name, - type: normalizeProjectType(projectData.type), - description: projectData.description, - status: normalizeProjectStatus(projectData.status), - start_date: projectData.start_date, - due_date: projectData.due_date, - budget_amount: - projectData.budget_amount === null ? null : Number(projectData.budget_amount), - currency: projectData.currency, - progress: Number(projectData.progress || 0), - progress_type: projectData.progress_type === "auto" ? "auto" : "manual", - revision_quota: Number(projectData.revision_quota || 0), - cover_image_alt: projectData.cover_image_alt, - coverImageUrl, + let data: { + project: ProjectDetail; + sections: ProjectPlanningSectionItem[]; + tasks: ProjectDetailTaskItem[]; + financeTransactions: ProjectFinanceItem[]; + revisions: Array>; }; + try { + const row = service.getProject(actor, id); + 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, + type: row.type, + description: row.description, + status: row.status, + start_date: row.startDate, + due_date: row.dueDate, + budget_amount: row.budgetAmountMinor == null ? null : row.budgetAmountMinor / 100, + currency: row.currency, + progress: row.progress, + progress_type: row.progressType, + revision_quota: row.revisionQuota, + cover_image_alt: row.coverImageAlt, + coverImageUrl: row.legacyCoverImagePath, + }; + 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) + .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, + })); + const financeTransactions: ProjectFinanceItem[] = service.listFinanceTransactions(actor) + .filter((transaction) => transaction.projectId === id) + .map((transaction) => ({ + id: transaction.id, + type: transaction.type, + amount: transaction.amountMinor / 100, + currency: transaction.currency, + payment_status: transaction.paymentStatus, + transaction_date: transaction.transactionDate, + category: transaction.category, + })); + const revisions = service.listRevisions(actor, id).map((revision) => ({ + id: revision.id, + description: revision.description, + status: revision.status, + created_at: revision.createdAt.toISOString(), + requested_by: revision.requestedByUserId, + })); - const sections = ((sectionRows || []) as unknown as SectionRow[]).map((section) => ({ - ...section, - category: normalizeSectionCategory(section.category), - sort_order: Number(section.sort_order || 0), - })); - const tasks: ProjectDetailTaskItem[] = ((taskRows || []) as TaskRow[]).map((task) => ({ - id: task.id, - title: task.title, - status: normalizeTaskStatus(task.status), - priority: normalizeTaskPriority(task.priority), - due_at: task.due_at, - is_public_to_client: task.is_public_to_client || false, - })); - const revisions = revisionRows || []; - const financeTransactions: ProjectFinanceItem[] = ((financeRows || []) as FinanceRow[]).map( - (transaction) => ({ - id: transaction.id, - type: transaction.type === "income" ? "income" : "expense", - amount: Number(transaction.amount || 0), - currency: transaction.currency, - payment_status: normalizePaymentStatus(transaction.payment_status), - transaction_date: transaction.transaction_date, - category: transaction.category, - }), - ); + data = { project, sections, tasks, financeTransactions, revisions }; + } catch (error) { + if (error instanceof DomainError && error.code === "NOT_FOUND") notFound(); + throw error; + } return ( ); } - -async function createProjectImageUrl(path: string) { - const admin = createServiceRoleClient(); - const { data } = await admin.storage - .from("project-assets") - .createSignedUrl(path, 60 * 15); - - return data?.signedUrl || null; -} - -function getClientName(client: ProjectRow["clients"]) { - if (!client) return null; - return Array.isArray(client) ? client[0]?.name || null : client.name; -} - -function normalizeProjectType(type: string): ProjectDetail["type"] { - return type === "side_project" ? "side_project" : "client_project"; -} - -function normalizeProjectStatus(status: string): ProjectDetail["status"] { - if ( - status === "active" || - status === "paused" || - status === "completed" || - status === "cancelled" - ) { - return status; - } - - return "planning"; -} - -function normalizeSectionCategory(category: string): ProjectPlanningSectionItem["category"] { - if ( - category === "problem" || - category === "goal" || - category === "audience" || - category === "scope" || - category === "design_system" || - category === "color_palette" || - category === "typography" || - category === "assets" || - category === "notes" - ) { - return category; - } - - return "overview"; -} - -function normalizeTaskStatus(status: string | null): ProjectDetailTaskItem["status"] { - if (status === "in_progress" || status === "done") { - return status; - } - - return "todo"; -} - -function normalizeTaskPriority(priority: string | null): ProjectDetailTaskItem["priority"] { - if (priority === "low" || priority === "high" || priority === "urgent") { - return priority; - } - - return "medium"; -} - -function normalizePaymentStatus(status: string): ProjectFinanceItem["payment_status"] { - if (status === "pending" || status === "paid" || status === "cancelled") { - return status; - } - - return "planned"; -} diff --git a/app/(dashboard)/projects/actions.ts b/app/(dashboard)/projects/actions.ts index 2072b00..1a7794f 100644 --- a/app/(dashboard)/projects/actions.ts +++ b/app/(dashboard)/projects/actions.ts @@ -1,361 +1,152 @@ "use server"; -import { createClient } from "@/lib/supabase/server"; -import { createServiceRoleClient } from "@/lib/supabase/admin"; -import { randomUUID } from "crypto"; +import { randomUUID } from "node:crypto"; import { revalidatePath } from "next/cache"; +import { getFileService } from "@/server/files/runtime"; +import { cleanText, decimalToMinor, requiredText } from "@/server/web/form-data"; +import { requireFreelancerBackend } from "@/server/web/freelancer"; const PROJECT_TYPES = ["client_project", "side_project"] as const; const PROJECT_STATUSES = ["planning", "active", "paused", "completed", "cancelled"] as const; -const PLANNING_SECTION_CATEGORIES = [ - "overview", - "problem", - "goal", - "audience", - "scope", - "design_system", - "color_palette", - "typography", - "assets", - "notes", -] as const; -const PROJECT_ASSETS_BUCKET = "project-assets"; +const SECTION_CATEGORIES = ["overview", "problem", "goal", "audience", "scope", "design_system", "color_palette", "typography", "assets", "notes"] as const; +const REVISION_STATUSES = ["pending", "in_progress", "completed", "rejected"] as const; -function cleanText(value: FormDataEntryValue | null) { - const text = typeof value === "string" ? value.trim() : ""; - return text.length > 0 ? text : null; +function enumValue(value: FormDataEntryValue | string | null, values: T, fallback: T[number]): T[number] { + return typeof value === "string" && values.includes(value) ? value as T[number] : fallback; } -function readProjectType(value: FormDataEntryValue | null) { - const type = typeof value === "string" ? value : "client_project"; - return PROJECT_TYPES.includes(type as (typeof PROJECT_TYPES)[number]) - ? type - : "client_project"; +function numberValue(value: FormDataEntryValue | null, fallback = 0) { + const parsed = Number(typeof value === "string" ? value.replace(",", ".") : value); + return Number.isFinite(parsed) ? parsed : fallback; } -function readProjectStatus(value: FormDataEntryValue | null) { - const status = typeof value === "string" ? value : "planning"; - return PROJECT_STATUSES.includes(status as (typeof PROJECT_STATUSES)[number]) - ? status - : "planning"; -} - -function readPlanningSectionCategory(value: FormDataEntryValue | null) { - const category = typeof value === "string" ? value : "overview"; - return PLANNING_SECTION_CATEGORIES.includes( - category as (typeof PLANNING_SECTION_CATEGORIES)[number], - ) - ? category - : "overview"; -} - -function readNumber(value: FormDataEntryValue | null) { - const number = Number(typeof value === "string" ? value.replace(",", ".") : value); - return Number.isFinite(number) ? number : null; -} - -function readProgress(value: FormDataEntryValue | null) { - const progress = Math.round(readNumber(value) ?? 0); - return Math.min(Math.max(progress, 0), 100); -} - -async function getCurrentUserId() { - const supabase = await createClient(); - const { - data: { user }, - error, - } = await supabase.auth.getUser(); - - if (error || !user) { - throw new Error("Proje işlemi için giriş yapmış kullanıcı bulunamadı."); - } - - return { supabase, userId: user.id }; -} - -function readPayload(formData: FormData) { - const type = readProjectType(formData.get("type")); - const clientId = cleanText(formData.get("client_id")); - +function projectPayload(formData: FormData) { + const type = enumValue(formData.get("type"), PROJECT_TYPES, "client_project"); return { - name: cleanText(formData.get("name")), + name: requiredText(formData.get("name"), "Proje adı zorunludur."), type, - client_id: type === "client_project" ? clientId : null, + clientId: type === "client_project" ? cleanText(formData.get("client_id")) : null, description: cleanText(formData.get("description")), - status: readProjectStatus(formData.get("status")), - start_date: cleanText(formData.get("start_date")), - due_date: cleanText(formData.get("due_date")), - budget_amount: readNumber(formData.get("budget_amount")), - currency: cleanText(formData.get("currency")) || "USD", - progress: readProgress(formData.get("progress")), - cover_image_alt: cleanText(formData.get("cover_image_alt")), + 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")), }; } -function readImageFile(formData: FormData) { +async function uploadCover( + actor: Parameters["upload"]>[0], + projectId: string, + formData: FormData, +) { const file = formData.get("cover_image"); - - if (!(file instanceof File) || file.size === 0) { - return null; - } - - if (!file.type.startsWith("image/")) { - throw new Error("Kapak görseli bir görsel dosyası olmalıdır."); - } - - return file; -} - -function sanitizeFileName(name: string) { - return name - .toLowerCase() - .replace(/[^a-z0-9._-]+/g, "-") - .replace(/^-+|-+$/g, "") - .slice(0, 120); -} - -async function uploadCoverImage({ - userId, - projectId, - formData, -}: { - userId: string; - projectId: string; - formData: FormData; -}) { - const file = readImageFile(formData); - - if (!file) { - return null; - } - - const fileName = `${Date.now()}-${sanitizeFileName(file.name) || "cover-image"}`; - const path = `${userId}/projects/${projectId}/${fileName}`; - const admin = createServiceRoleClient(); - const { error } = await admin.storage - .from(PROJECT_ASSETS_BUCKET) - .upload(path, file, { - cacheControl: "3600", - contentType: file.type, - upsert: true, - }); - - if (error) { - throw new Error(`Kapak görseli yüklenemedi: ${error.message}`); - } - - return path; + if (!(file instanceof File) || file.size === 0) return null; + const stored = getFileService().upload(actor, { + kind: "project_asset", + originalName: file.name, + claimedMimeType: file.type, + bytes: new Uint8Array(await file.arrayBuffer()), + projectId, + portalVisible: true, + }); + return `/api/files/${stored.id}`; } export async function createProjectRecord(formData: FormData) { - const { supabase, userId } = await getCurrentUserId(); - const projectId = randomUUID(); - const payload = readPayload(formData); - - if (!payload.name) { - throw new Error("Proje adı zorunludur."); + const { actor, service } = await requireFreelancerBackend(); + const id = randomUUID(); + service.createProject(actor, { id, ...projectPayload(formData) }); + try { + const cover = await uploadCover(actor, id, formData); + if (cover) service.updateProject(actor, id, { legacyCoverImagePath: cover }); + } catch (error) { + service.deleteProject(actor, id); + throw error; } - - const coverImagePath = await uploadCoverImage({ - userId, - projectId, - formData, - }); - - const { error } = await supabase.from("projects").insert({ - id: projectId, - user_id: userId, - ...payload, - cover_image_path: coverImagePath, - }); - - if (error) { - throw new Error(`Proje eklenemedi: ${error.message}`); - } - revalidatePath("/projects"); } export async function updateProjectRecord(formData: FormData) { - const { supabase, userId } = await getCurrentUserId(); - const id = cleanText(formData.get("id")); - const payload = readPayload(formData); - - if (!id || !payload.name) { - throw new Error("Proje güncellemek için proje adı ve kayıt kimliği zorunludur."); - } - - const coverImagePath = await uploadCoverImage({ - userId, - projectId: id, - formData, - }); - - const updatePayload = { - ...payload, - ...(coverImagePath ? { cover_image_path: coverImagePath } : {}), - }; - - const { error } = await supabase - .from("projects") - .update(updatePayload) - .eq("id", id) - .eq("user_id", userId); - - if (error) { - throw new Error(`Proje güncellenemedi: ${error.message}`); - } - - revalidatePath("/projects"); -} - -export async function completeProjectRecord(formData: FormData) { - const { supabase, userId } = await getCurrentUserId(); - const id = cleanText(formData.get("id")); - - if (!id) { - throw new Error("Tamamlanacak proje bulunamadı."); - } - - const { error } = await supabase - .from("projects") - .update({ status: "completed", progress: 100 }) - .eq("id", id) - .eq("user_id", userId); - - if (error) { - throw new Error(`Proje tamamlanamadı: ${error.message}`); - } - + const { actor, service } = await requireFreelancerBackend(); + const id = requiredText(formData.get("id"), "Proje kaydı bulunamadı."); + service.updateProject(actor, id, projectPayload(formData)); + const cover = await uploadCover(actor, id, formData); + if (cover) service.updateProject(actor, id, { legacyCoverImagePath: cover }); revalidatePath("/projects"); revalidatePath(`/projects/${id}`); } -function readPlanningSectionPayload(formData: FormData) { +export async function completeProjectRecord(formData: FormData) { + const { actor, service } = await requireFreelancerBackend(); + const id = requiredText(formData.get("id"), "Tamamlanacak proje bulunamadı."); + service.updateProject(actor, id, { status: "completed", progress: 100 }); + revalidatePath("/projects"); + revalidatePath(`/projects/${id}`); +} + +function sectionPayload(formData: FormData) { return { - project_id: cleanText(formData.get("project_id")), - category: readPlanningSectionCategory(formData.get("category")), - title: cleanText(formData.get("title")), + 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")), - sort_order: Math.round(readNumber(formData.get("sort_order")) ?? 0), + sortOrder: Math.max(0, Math.round(numberValue(formData.get("sort_order")))), }; } export async function createProjectPlanningSectionRecord(formData: FormData) { - const { supabase, userId } = await getCurrentUserId(); - const payload = readPlanningSectionPayload(formData); - - if (!payload.project_id || !payload.title) { - throw new Error("Planlama alanı eklemek için proje ve başlık zorunludur."); - } - - const { error } = await supabase.from("project_planning_sections").insert({ - user_id: userId, - project_id: payload.project_id, - category: payload.category, - title: payload.title, - content: payload.content, - sort_order: payload.sort_order, - }); - - if (error) { - throw new Error(`Planlama alanı eklenemedi: ${error.message}`); - } - + const { actor, service } = await requireFreelancerBackend(); + const payload = sectionPayload(formData); + service.addPlanningSection(actor, payload); revalidatePath("/projects"); - revalidatePath(`/projects/${payload.project_id}`); + revalidatePath(`/projects/${payload.projectId}`); } export async function updateProjectPlanningSectionRecord(formData: FormData) { - const { supabase, userId } = await getCurrentUserId(); - const id = cleanText(formData.get("id")); - const payload = readPlanningSectionPayload(formData); - - if (!id || !payload.project_id || !payload.title) { - throw new Error("Planlama alanını güncellemek için kayıt kimliği, proje ve başlık zorunludur."); + const { actor, service } = await requireFreelancerBackend(); + const id = requiredText(formData.get("id"), "Planlama alanı bulunamadı."); + const payload = sectionPayload(formData); + if (!service.listPlanningSections(actor, payload.projectId).some((section) => section.id === id)) { + throw new Error("Planlama alanı bu projeye ait değil."); } - - const { error } = await supabase - .from("project_planning_sections") - .update({ - category: payload.category, - title: payload.title, - content: payload.content, - sort_order: payload.sort_order, - }) - .eq("id", id) - .eq("project_id", payload.project_id) - .eq("user_id", userId); - - if (error) { - throw new Error(`Planlama alanı güncellenemedi: ${error.message}`); - } - + service.updatePlanningSection(actor, id, { + category: payload.category, + title: payload.title, + content: payload.content, + sortOrder: payload.sortOrder, + }); revalidatePath("/projects"); - revalidatePath(`/projects/${payload.project_id}`); + revalidatePath(`/projects/${payload.projectId}`); } export async function deleteProjectPlanningSectionRecord(formData: FormData) { - const { supabase, userId } = await getCurrentUserId(); - const id = cleanText(formData.get("id")); - const projectId = cleanText(formData.get("project_id")); - - if (!id || !projectId) { - throw new Error("Silinecek planlama alanı bulunamadı."); + const { actor, service } = await requireFreelancerBackend(); + const id = requiredText(formData.get("id"), "Silinecek planlama alanı bulunamadı."); + const projectId = requiredText(formData.get("project_id"), "Proje zorunludur."); + if (!service.listPlanningSections(actor, projectId).some((section) => section.id === id)) { + throw new Error("Planlama alanı bu projeye ait değil."); } - - const { error } = await supabase - .from("project_planning_sections") - .delete() - .eq("id", id) - .eq("project_id", projectId) - .eq("user_id", userId); - - if (error) { - throw new Error(`Planlama alanı silinemedi: ${error.message}`); - } - + service.deletePlanningSection(actor, id); revalidatePath("/projects"); revalidatePath(`/projects/${projectId}`); } export async function updateRevisionStatus(id: string, projectId: string, status: string) { - const { supabase } = await getCurrentUserId(); - - const { error } = await supabase - .from("project_revisions") - .update({ status }) - .eq("id", id) - .eq("project_id", projectId); - - if (error) { - throw new Error(`Revizyon durumu güncellenemedi: ${error.message}`); - } - + const { actor, service } = await requireFreelancerBackend(); + service.updateRevisionStatus(actor, id, enumValue(status, REVISION_STATUSES, "pending"), projectId); revalidatePath(`/projects/${projectId}`); } export async function updateProjectSettings(projectId: string, progressType: "manual" | "auto", progress: number, revisionQuota: number) { - const { supabase, userId } = await getCurrentUserId(); - - if (!projectId) { - throw new Error("Proje ID zorunludur."); - } - - const { error } = await supabase - .from("projects") - .update({ - progress_type: progressType, - progress: progress, - revision_quota: revisionQuota - }) - .eq("id", projectId) - .eq("user_id", userId); - - if (error) { - throw new Error(`Ayarlar güncellenemedi: ${error.message}`); - } - + const { actor, service } = await requireFreelancerBackend(); + service.updateProject(actor, projectId, { + progressType, + progress: Math.min(100, Math.max(0, Math.round(progress))), + revisionQuota: Math.max(0, Math.round(revisionQuota)), + }); revalidatePath("/projects"); revalidatePath(`/projects/${projectId}`); } diff --git a/app/(dashboard)/projects/page.tsx b/app/(dashboard)/projects/page.tsx index 0408e8b..b736fb2 100644 --- a/app/(dashboard)/projects/page.tsx +++ b/app/(dashboard)/projects/page.tsx @@ -1,139 +1,48 @@ -import { - ProjectsClient, - type ProjectClientOption, - type ProjectListItem, -} from "@/app/(dashboard)/projects/projects-client"; -import { createClient } from "@/lib/supabase/server"; -import { createServiceRoleClient } from "@/lib/supabase/admin"; - -type ProjectRow = { - id: string; - user_id: string; - client_id: string | null; - name: string; - type: "client_project" | "side_project"; - description: string | null; - status: "planning" | "active" | "paused" | "completed" | "cancelled"; - start_date: string | null; - due_date: string | null; - budget_amount: number | string | null; - currency: string; - progress: number; - cover_image_path: string | null; - cover_image_alt: string | null; - clients: { name: string } | { name: string }[] | null; -}; - -type TaskRow = { - project_id: string | null; - status: string | null; -}; +import { ProjectsClient, type ProjectClientOption, type ProjectListItem } from "@/app/(dashboard)/projects/projects-client"; +import { requireFreelancerBackend } from "@/server/web/freelancer"; export default async function ProjectsPage() { - const supabase = await createClient(); - const { - data: { user }, - } = await supabase.auth.getUser(); + const { actor, service } = await requireFreelancerBackend(); + const projectRows = service.listProjects(actor); + const clientRows = service.listClients(actor); + const taskRows = service.listTasks(actor); + const clientNames = new Map(clientRows.map((client) => [client.id, client.name])); + const taskStats = new Map(); - if (!user) { - return null; + for (const task of taskRows) { + if (!task.projectId || task.status === "cancelled") continue; + const stats = taskStats.get(task.projectId) ?? { total: 0, done: 0 }; + stats.total += 1; + if (task.status === "done") stats.done += 1; + taskStats.set(task.projectId, stats); } - const [{ data: projectRows }, { data: clientRows }, { data: taskRows }] = - await Promise.all([ - supabase - .from("projects") - .select( - "id, user_id, client_id, name, type, description, status, start_date, due_date, budget_amount, currency, progress, cover_image_path, cover_image_alt, clients(name)", - ) - .eq("user_id", user.id) - .order("created_at", { ascending: false }), - supabase - .from("clients") - .select("id, name") - .eq("user_id", user.id) - .neq("status", "archived") - .order("name", { ascending: true }), - supabase.from("tasks").select("project_id, status").eq("user_id", user.id), - ]); - - const taskStats = countTasksByProject((taskRows || []) as TaskRow[]); - const clients = (clientRows || []) as ProjectClientOption[]; - const signedUrls = await createProjectImageUrls( - ((projectRows || []) as unknown as ProjectRow[]) - .map((project) => project.cover_image_path) - .filter(Boolean) as string[], - ); - - const projects: ProjectListItem[] = ((projectRows || []) as unknown as ProjectRow[]).map((project) => { - const stats = taskStats.get(project.id) || { total: 0, done: 0 }; - + const projects: ProjectListItem[] = projectRows.map((project) => { + const stats = taskStats.get(project.id) ?? { total: 0, done: 0 }; return { id: project.id, - client_id: project.client_id, - clientName: getClientName(project.clients), + client_id: project.clientId, + clientName: project.clientId ? clientNames.get(project.clientId) ?? null : null, name: project.name, type: project.type, description: project.description, status: project.status, - start_date: project.start_date, - due_date: project.due_date, - budget_amount: project.budget_amount === null ? null : Number(project.budget_amount), + start_date: project.startDate, + due_date: project.dueDate, + budget_amount: project.budgetAmountMinor == null ? null : project.budgetAmountMinor / 100, currency: project.currency, progress: project.progress, - cover_image_path: project.cover_image_path, - cover_image_alt: project.cover_image_alt, - coverImageUrl: project.cover_image_path ? signedUrls.get(project.cover_image_path) || null : null, + cover_image_path: project.legacyCoverImagePath, + cover_image_alt: project.coverImageAlt, + coverImageUrl: project.legacyCoverImagePath, taskCount: stats.total, doneTaskCount: stats.done, }; }); + const clients: ProjectClientOption[] = clientRows + .filter((client) => client.status !== "archived") + .sort((a, b) => a.name.localeCompare(b.name, "tr")) + .map(({ id, name }) => ({ id, name })); return ; } - -async function createProjectImageUrls( - paths: string[], -) { - const admin = createServiceRoleClient(); - const urls = new Map(); - const uniquePaths = Array.from(new Set(paths)); - - await Promise.all( - uniquePaths.map(async (path) => { - const { data } = await admin.storage - .from("project-assets") - .createSignedUrl(path, 60 * 15); - - if (data?.signedUrl) { - urls.set(path, data.signedUrl); - } - }), - ); - - return urls; -} - -function getClientName(client: ProjectRow["clients"]) { - if (!client) return null; - return Array.isArray(client) ? client[0]?.name || null : client.name; -} - -function countTasksByProject(tasks: TaskRow[]) { - const statsByProject = new Map(); - - for (const task of tasks) { - if (!task.project_id) continue; - - const current = statsByProject.get(task.project_id) || { total: 0, done: 0 }; - current.total += 1; - - if (task.status === "done") { - current.done += 1; - } - - statsByProject.set(task.project_id, current); - } - - return statsByProject; -} diff --git a/app/(dashboard)/settings/actions.ts b/app/(dashboard)/settings/actions.ts index c241422..0cb7318 100644 --- a/app/(dashboard)/settings/actions.ts +++ b/app/(dashboard)/settings/actions.ts @@ -1,90 +1,100 @@ -'use server' +"use server"; -import { revalidatePath } from 'next/cache' +import { eq } from "drizzle-orm"; +import { headers } from "next/headers"; +import { revalidatePath } from "next/cache"; +import { auth } from "@/server/auth/auth"; +import { getSqliteConnection } from "@/server/db/client"; +import { appProfiles } from "@/server/db/schema"; +import { domainActorFromSession } from "@/server/auth/domain-actor"; +import { getFileService } from "@/server/files/runtime"; +import { getPublicAiSettings, updateAiSettings } from "@/server/settings/ai"; +import { requireFreelancerBackend } from "@/server/web/freelancer"; +import { cleanText } from "@/server/web/form-data"; -import { createServiceRoleClient } from '@/lib/supabase/admin' -import { createClient } from '@/lib/supabase/server' +export async function loadSettings() { + const { context, actor } = await requireFreelancerBackend(); + const [firstName = "", ...lastNameParts] = context.profile.displayName.trim().split(/\s+/); + const ai = getPublicAiSettings(actor); -type ProfileUpdateData = { - first_name: string - last_name: string - avatar_url?: string + return { + firstName, + lastName: lastNameParts.join(" "), + avatarUrl: context.user.image ?? "", + aiProvider: ai.provider, + hasApiKey: ai.hasApiKey, + }; } export async function updateProfile(formData: FormData) { - const supabase = await createClient() - - const { - data: { user }, - } = await supabase.auth.getUser() - - if (!user) { - return { error: 'Kullanıcı bulunamadı.' } - } - - const firstName = formData.get('firstName') as string - const lastName = formData.get('lastName') as string - const avatarFile = formData.get('avatar') as File | null - - let avatarUrl: string | undefined - - if (avatarFile && avatarFile.size > 0) { - const fileExt = avatarFile.name.split('.').pop() - const fileName = `${user.id}/${Math.random()}.${fileExt}` - const admin = createServiceRoleClient() - - const { error: uploadError } = await admin.storage - .from('avatars') - .upload(fileName, avatarFile, { upsert: true }) - - if (uploadError) { - return { - error: `Profil fotoğrafı yüklenirken hata oluştu: ${uploadError.message}`, - } + try { + const { context } = await requireFreelancerBackend(); + const firstName = cleanText(formData.get("firstName")); + const lastName = cleanText(formData.get("lastName")); + if (!firstName || !lastName || firstName.length > 80 || lastName.length > 120) { + return { error: "Ad ve soyad zorunludur." }; } - const { - data: { publicUrl }, - } = admin.storage.from('avatars').getPublicUrl(fileName) + const displayName = `${firstName} ${lastName}`; + await auth.api.updateUser({ + headers: await headers(), + body: { name: displayName }, + }); + getSqliteConnection().db + .update(appProfiles) + .set({ displayName, updatedAt: new Date() }) + .where(eq(appProfiles.authUserId, context.user.id)) + .run(); - avatarUrl = publicUrl + const avatar = formData.get("avatar"); + if (avatar instanceof File && avatar.size > 0) { + getFileService().upload(domainActorFromSession(context), { + kind: "avatar", + originalName: avatar.name, + claimedMimeType: avatar.type, + bytes: new Uint8Array(await avatar.arrayBuffer()), + }); + } + + revalidatePath("/settings"); + revalidatePath("/", "layout"); + return { success: true }; + } catch (error) { + return { error: error instanceof Error ? error.message : "Profil güncellenemedi." }; } - - const updateData: ProfileUpdateData = { - first_name: firstName, - last_name: lastName, - } - - if (avatarUrl) { - updateData.avatar_url = avatarUrl - } - - const { error } = await supabase.from('profiles').upsert({ - id: user.id, - ...updateData, - }) - - if (error) { - return { error: `Profil güncellenirken hata oluştu: ${error.message}` } - } - - revalidatePath('/settings') - return { success: true } } export async function updatePassword(formData: FormData) { - const supabase = await createClient() - const password = formData.get('password') as string + const currentPassword = cleanText(formData.get("currentPassword")); + const newPassword = cleanText(formData.get("password")); - if (!password || password.length < 6) { - return { error: 'Şifre en az 6 karakter olmalıdır.' } + if (!currentPassword || !newPassword || newPassword.length < 8) { + return { error: "Mevcut şifre zorunludur; yeni şifre en az 8 karakter olmalıdır." }; } - const { error } = await supabase.auth.updateUser({ password }) + try { + await requireFreelancerBackend(); + await auth.api.changePassword({ + headers: await headers(), + body: { + currentPassword, + newPassword, + revokeOtherSessions: true, + }, + }); + return { success: true }; + } catch { + return { error: "Mevcut şifre doğrulanamadı veya şifre güncellenemedi." }; + } +} - if (error) { - return { error: `Şifre güncellenirken hata oluştu: ${error.message}` } +export async function saveAiSettings(provider: string, apiKey: string) { + try { + const { actor } = await requireFreelancerBackend(); + const settings = updateAiSettings(actor, { provider, apiKey }); + revalidatePath("/settings"); + return { success: true, hasApiKey: settings.hasApiKey }; + } catch (error) { + return { error: error instanceof Error ? error.message : "Ayarlar kaydedilemedi." }; } - - return { success: true } } diff --git a/app/(dashboard)/settings/page.tsx b/app/(dashboard)/settings/page.tsx index cdcd174..d7640e7 100644 --- a/app/(dashboard)/settings/page.tsx +++ b/app/(dashboard)/settings/page.tsx @@ -1,9 +1,9 @@ "use client"; import { useEffect, useRef, useState } from "react"; -import { AlertTriangle, Blocks, Brain, Key, Save, Shield, User } from "lucide-react"; -import { updatePassword, updateProfile } from "./actions"; -import { createClient } from "@/lib/supabase/client"; +import Image from "next/image"; +import { Blocks, Brain, Key, Save, Shield, User } from "lucide-react"; +import { loadSettings, saveAiSettings, updatePassword, updateProfile } from "./actions"; import { Button, Card, CardContent, Input, Label } from "poyraz-ui/atoms"; import { toast } from "poyraz-ui/molecules"; @@ -23,9 +23,7 @@ export default function SettingsPage() { // AI States const [aiProvider, setAiProvider] = useState("gemini"); const [apiKey, setApiKey] = useState(""); - - // Supabase - const [supabase] = useState(() => createClient()); + const [hasApiKey, setHasApiKey] = useState(false); const tabs = [ { name: "Profile & Account", icon: User }, @@ -37,42 +35,18 @@ export default function SettingsPage() { let isActive = true; const fetchData = async () => { - const { data: { user } } = await supabase.auth.getUser(); - if (!user || !isActive) return; - - // 1. Fetch Profile - const { data: profile } = await supabase - .from("profiles") - .select("*") - .eq("id", user.id) - .single(); - - if (profile && isActive) { - setFirstName(profile.first_name || ""); - setLastName(profile.last_name || ""); - setAvatarUrl(profile.avatar_url || ""); - } - - // 2. Fetch User Settings from Supabase - const { data: settings } = await supabase - .from("app_settings") - .select("*") - .eq("user_id", user.id) - .single(); - - if (settings && isActive) { - setAiProvider((settings.ai_provider as AiProvider) || "gemini"); - setApiKey(settings.api_key || ""); - - // Also sync to local storage for existing API route calls if they use it - localStorage.setItem("mindspace_ai_provider", settings.ai_provider || "gemini"); - localStorage.setItem("mindspace_api_key", settings.api_key || ""); - } + const settings = await loadSettings(); + if (!isActive) return; + setFirstName(settings.firstName); + setLastName(settings.lastName); + setAvatarUrl(settings.avatarUrl); + setAiProvider(settings.aiProvider); + setHasApiKey(settings.hasApiKey); }; void fetchData(); return () => { isActive = false; }; - }, [supabase]); + }, []); const handleProfileAction = async (formData: FormData) => { const response = await updateProfile(formData); @@ -96,32 +70,14 @@ export default function SettingsPage() { }; const handleSaveAI = async () => { - try { - const { data: { user } } = await supabase.auth.getUser(); - if (!user) throw new Error("Giriş yapılmamış"); - - // Save to Supabase app_settings table - const { error } = await supabase - .from("app_settings") - .upsert({ - user_id: user.id, - ai_provider: aiProvider, - ai_model: null, // Reset to allow default model fallback - api_key: apiKey, - updated_at: new Date().toISOString() - }, { onConflict: 'user_id' }); - - if (error) throw error; - - // Sync to localStorage as a redundant fallback - localStorage.setItem("mindspace_ai_provider", aiProvider); - localStorage.setItem("mindspace_api_key", apiKey); - - toast.success("Yapay Zeka ayarları kaydedildi!"); - } catch (e: any) { - console.error(e); - toast.error("Hata oluştu, veritabanına kaydedilemedi."); + const response = await saveAiSettings(aiProvider, apiKey); + if (response.error) { + toast.error(response.error); + return; } + setHasApiKey(Boolean(response.hasApiKey)); + setApiKey(""); + toast.success("Yapay Zeka ayarları kaydedildi!"); }; return ( @@ -173,7 +129,14 @@ export default function SettingsPage() {
{avatarUrl ? ( - Avatar + Avatar ) : (
@@ -211,9 +174,13 @@ export default function SettingsPage() {

Şifre İşlemleri

+
+ + +
- +
diff --git a/app/(dashboard)/tasks/actions.ts b/app/(dashboard)/tasks/actions.ts index c63c0cb..aae4e35 100644 --- a/app/(dashboard)/tasks/actions.ts +++ b/app/(dashboard)/tasks/actions.ts @@ -1,193 +1,88 @@ "use server"; -import { createClient } from "@/lib/supabase/server"; import { revalidatePath } from "next/cache"; +import { cleanText, optionalDate, requiredText } from "@/server/web/form-data"; +import { requireFreelancerBackend } from "@/server/web/freelancer"; const TASK_STATUSES = ["todo", "in_progress", "done"] as const; const TASK_PRIORITIES = ["low", "medium", "high", "urgent"] as const; -function cleanText(value: FormDataEntryValue | null) { - const text = typeof value === "string" ? value.trim() : ""; - return text.length > 0 ? text : null; +function enumValue(value: FormDataEntryValue | string | null, values: T, fallback: T[number]): T[number] { + return typeof value === "string" && values.includes(value) ? value as T[number] : fallback; } -function cleanRelationId(value: FormDataEntryValue | null) { - const id = cleanText(value); - return id && id !== "__none" ? id : null; +function minutes(value: FormDataEntryValue | null): number | null { + const parsed = Number(value); + return Number.isFinite(parsed) && parsed >= 0 ? Math.round(parsed) : null; } -function readStatus(value: FormDataEntryValue | null) { - const status = typeof value === "string" ? value : "todo"; - return TASK_STATUSES.includes(status as (typeof TASK_STATUSES)[number]) - ? status - : "todo"; -} - -function readPriority(value: FormDataEntryValue | null) { - const priority = typeof value === "string" ? value : "medium"; - return TASK_PRIORITIES.includes(priority as (typeof TASK_PRIORITIES)[number]) - ? priority - : "medium"; -} - -function readMinutes(value: FormDataEntryValue | null) { - const number = Number(value); - return Number.isFinite(number) && number >= 0 ? Math.round(number) : null; -} - -async function getCurrentUserId() { - const supabase = await createClient(); - const { - data: { user }, - error, - } = await supabase.auth.getUser(); - - if (error || !user) { - throw new Error("Görev işlemi için giriş yapmış kullanıcı bulunamadı."); - } - - return { supabase, userId: user.id }; -} - -function readPayload(formData: FormData) { +function payload(formData: FormData) { + const dueAt = optionalDate(formData.get("due_at")); return { - title: cleanText(formData.get("title")), + title: requiredText(formData.get("title"), "Görev başlığı zorunludur."), description: cleanText(formData.get("description")), - status: readStatus(formData.get("status")), - priority: readPriority(formData.get("priority")), - client_id: cleanRelationId(formData.get("client_id")), - project_id: cleanRelationId(formData.get("project_id")), - due_at: cleanText(formData.get("due_at")), - estimated_minutes: readMinutes(formData.get("estimated_minutes")), - actual_minutes: readMinutes(formData.get("actual_minutes")), - is_public_to_client: formData.get("is_public_to_client") === "on", + status: enumValue(formData.get("status"), TASK_STATUSES, "todo"), + priority: enumValue(formData.get("priority"), TASK_PRIORITIES, "medium"), + clientId: cleanText(formData.get("client_id")), + projectId: cleanText(formData.get("project_id")), + scheduledDate: dueAt?.toISOString().slice(0, 10) ?? null, + dueAt, + estimatedMinutes: minutes(formData.get("estimated_minutes")), + actualMinutes: minutes(formData.get("actual_minutes")), + isPublicToClient: formData.get("is_public_to_client") === "on", }; } -export async function createTaskRecord(formData: FormData) { - const { supabase, userId } = await getCurrentUserId(); - const payload = readPayload(formData); - - if (!payload.title) { - throw new Error("Görev başlığı zorunludur."); - } - - const { error } = await supabase.from("tasks").insert({ - user_id: userId, - date: payload.due_at || new Date().toISOString(), - ...payload, - }); - - if (error) { - throw new Error(`Görev eklenemedi: ${error.message}`); - } +function completeRelations( + value: ReturnType, + service: Awaited>["service"], + actor: Awaited>["actor"], +) { + const project = value.projectId ? service.getProject(actor, value.projectId) : null; + return { ...value, clientId: value.clientId ?? project?.clientId ?? null }; +} +function revalidate(projectId?: string | null) { revalidatePath("/tasks"); + revalidatePath("/projects"); + if (projectId) revalidatePath(`/projects/${projectId}`); +} - if (payload.project_id) { - revalidatePath(`/projects/${payload.project_id}`); - } +export async function createTaskRecord(formData: FormData) { + const { actor, service } = await requireFreelancerBackend(); + const value = completeRelations(payload(formData), service, actor); + service.createTask(actor, value); + revalidate(value.projectId); } export async function updateTaskRecord(formData: FormData) { - const { supabase, userId } = await getCurrentUserId(); - const id = cleanText(formData.get("id")); - const payload = readPayload(formData); - - if (!id || !payload.title) { - throw new Error("Görev güncellemek için başlık ve kayıt kimliği zorunludur."); - } - - const { error } = await supabase - .from("tasks") - .update(payload) - .eq("id", id) - .eq("user_id", userId); - - if (error) { - throw new Error(`Görev güncellenemedi: ${error.message}`); - } - - revalidatePath("/tasks"); - - if (payload.project_id) { - revalidatePath(`/projects/${payload.project_id}`); - } + const { actor, service } = await requireFreelancerBackend(); + const id = requiredText(formData.get("id"), "Görev kaydı bulunamadı."); + const value = completeRelations(payload(formData), service, actor); + const current = service.listTasks(actor).find((task) => task.id === id); + service.updateTask(actor, id, value); + revalidate(value.projectId); + if (current?.projectId !== value.projectId) revalidate(current?.projectId); } export async function completeTaskRecord(formData: FormData) { - const { supabase, userId } = await getCurrentUserId(); - const id = cleanText(formData.get("id")); - const projectId = cleanRelationId(formData.get("project_id")); - - if (!id) { - throw new Error("Tamamlanacak görev bulunamadı."); - } - - const { error } = await supabase - .from("tasks") - .update({ status: "done" }) - .eq("id", id) - .eq("user_id", userId); - - if (error) { - throw new Error(`Görev tamamlanamadı: ${error.message}`); - } - - revalidatePath("/tasks"); - - if (projectId) { - revalidatePath(`/projects/${projectId}`); - } + const id = requiredText(formData.get("id"), "Tamamlanacak görev bulunamadı."); + const projectId = cleanText(formData.get("project_id")); + const { actor, service } = await requireFreelancerBackend(); + service.updateTask(actor, id, { status: "done" }); + revalidate(projectId); } export async function updateTaskStatusRecord(taskId: string, status: string, projectId?: string) { - const { supabase, userId } = await getCurrentUserId(); - const nextStatus = readStatus(status); - - if (!taskId) { - throw new Error("Durumu güncellenecek görev bulunamadı."); - } - - const { error } = await supabase - .from("tasks") - .update({ status: nextStatus }) - .eq("id", taskId) - .eq("user_id", userId); - - if (error) { - throw new Error(`Görev durumu güncellenemedi: ${error.message}`); - } - - revalidatePath("/tasks"); - - if (projectId) { - revalidatePath(`/projects/${projectId}`); - } + const { actor, service } = await requireFreelancerBackend(); + service.updateTask(actor, taskId, { status: enumValue(status, TASK_STATUSES, "todo") }); + revalidate(projectId); } export async function deleteTaskRecord(formData: FormData) { - const { supabase, userId } = await getCurrentUserId(); - const id = cleanText(formData.get("id")); - const projectId = cleanRelationId(formData.get("project_id")); - - if (!id) { - throw new Error("Silinecek görev bulunamadı."); - } - - const { error } = await supabase - .from("tasks") - .delete() - .eq("id", id) - .eq("user_id", userId); - - if (error) { - throw new Error(`Görev silinemedi: ${error.message}`); - } - - revalidatePath("/tasks"); - - if (projectId) { - revalidatePath(`/projects/${projectId}`); - } + const id = requiredText(formData.get("id"), "Silinecek görev bulunamadı."); + const projectId = cleanText(formData.get("project_id")); + const { actor, service } = await requireFreelancerBackend(); + service.deleteTask(actor, id); + revalidate(projectId); } diff --git a/app/(dashboard)/tasks/page.tsx b/app/(dashboard)/tasks/page.tsx index fe4c70d..b604552 100644 --- a/app/(dashboard)/tasks/page.tsx +++ b/app/(dashboard)/tasks/page.tsx @@ -1,93 +1,37 @@ -import { - TasksClient, - type TaskListItem, - type TaskRelationOption, -} from "@/app/(dashboard)/tasks/tasks-client"; -import { createClient } from "@/lib/supabase/server"; - -type TaskRow = { - id: string; - title: string; - description: string | null; - status: "todo" | "in_progress" | "done"; - priority: "low" | "medium" | "high" | "urgent"; - due_at: string | null; - estimated_minutes: number | null; - actual_minutes: number | null; - client_id: string | null; - project_id: string | null; - created_at: string; - clients: { name: string } | { name: string }[] | null; - projects: { name: string } | { name: string }[] | null; -}; +import { TasksClient, type TaskListItem, type TaskRelationOption } from "@/app/(dashboard)/tasks/tasks-client"; +import { requireFreelancerBackend } from "@/server/web/freelancer"; export default async function TasksPage() { - const supabase = await createClient(); - const { - data: { user }, - } = await supabase.auth.getUser(); + const { actor, service } = await requireFreelancerBackend(); + const taskRows = service.listTasks(actor); + const clientRows = service.listClients(actor); + const projectRows = service.listProjects(actor); + const clientNames = new Map(clientRows.map((item) => [item.id, item.name])); + const projectNames = new Map(projectRows.map((item) => [item.id, item.name])); - if (!user) { - return null; - } - - const [{ data: taskRows }, { data: clientRows }, { data: projectRows }] = - await Promise.all([ - supabase - .from("tasks") - .select( - "id, title, description, status, priority, due_at, estimated_minutes, actual_minutes, client_id, project_id, created_at, clients(name), projects(name)", - ) - .eq("user_id", user.id) - .order("created_at", { ascending: false }), - supabase - .from("clients") - .select("id, name") - .eq("user_id", user.id) - .neq("status", "archived") - .order("name", { ascending: true }), - supabase - .from("projects") - .select("id, name, client_id") - .eq("user_id", user.id) - .neq("status", "cancelled") - .order("name", { ascending: true }), - ]); - - const clients = (clientRows || []) as TaskRelationOption[]; - const projects = (projectRows || []) as TaskRelationOption[]; - const tasks: TaskListItem[] = ((taskRows || []) as unknown as TaskRow[]).map((task) => ({ - id: task.id, - title: task.title, - description: task.description, - status: normalizeStatus(task.status), - priority: normalizePriority(task.priority), - due_at: task.due_at, - estimated_minutes: task.estimated_minutes, - actual_minutes: task.actual_minutes, - client_id: task.client_id, - clientName: getRelationName(task.clients), - project_id: task.project_id, - projectName: getRelationName(task.projects), - created_at: task.created_at, - })); + const tasks: TaskListItem[] = taskRows + .filter((task) => task.status !== "cancelled") + .map((task) => ({ + id: task.id, + title: task.title, + description: task.description, + status: task.status as TaskListItem["status"], + priority: task.priority, + due_at: task.dueAt?.toISOString() ?? null, + estimated_minutes: task.estimatedMinutes, + actual_minutes: task.actualMinutes, + client_id: task.clientId, + clientName: task.clientId ? clientNames.get(task.clientId) ?? null : null, + project_id: task.projectId, + projectName: task.projectId ? projectNames.get(task.projectId) ?? null : null, + created_at: task.createdAt.toISOString(), + })); + const clients: TaskRelationOption[] = clientRows + .filter((client) => client.status !== "archived") + .map(({ id, name }) => ({ id, name })); + const projects: TaskRelationOption[] = projectRows + .filter((project) => project.status !== "cancelled") + .map(({ id, name, clientId }) => ({ id, name, client_id: clientId })); return ; } - -function getRelationName(relation: TaskRow["clients"] | TaskRow["projects"]) { - if (!relation) return null; - return Array.isArray(relation) ? relation[0]?.name || null : relation.name; -} - -function normalizeStatus(status: string): TaskListItem["status"] { - return status === "in_progress" || status === "done" ? status : "todo"; -} - -function normalizePriority(priority: string): TaskListItem["priority"] { - if (priority === "low" || priority === "high" || priority === "urgent") { - return priority; - } - - return "medium"; -} diff --git a/app/portal/layout.tsx b/app/portal/layout.tsx index 9d8956f..992f5a6 100644 --- a/app/portal/layout.tsx +++ b/app/portal/layout.tsx @@ -1,14 +1,19 @@ import { PortalShell } from "@/components/layout/portal-shell"; -import { requireClientUser } from "@/server/auth/session"; import { getPublicBranding } from "@/server/branding/runtime"; +import { requirePortalBackend } from "@/server/web/portal"; export default async function PortalLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { - const { user, profile } = await requireClientUser(); + const { context, actor, service } = await requirePortalBackend(); + const { user, profile } = context; const branding = getPublicBranding(); + const projects = service.listProjects(actor); + const progress = projects.length + ? Math.round(projects.reduce((sum, project) => sum + project.progress, 0) / projects.length) + : 0; const displayName = profile.displayName || user.name || user.email.split("@")[0] || "Müşteri"; const shortName = @@ -34,7 +39,7 @@ export default async function PortalLayout({ shortName, avatarUrl: user.image || null, }} - progress={0} + progress={progress} > {children} diff --git a/app/portal/page.tsx b/app/portal/page.tsx index bf7b5fa..e4eacf8 100644 --- a/app/portal/page.tsx +++ b/app/portal/page.tsx @@ -1,51 +1,22 @@ -import { createClient } from "@/lib/supabase/server"; import { Card, CardContent, Badge } from "poyraz-ui/atoms"; -import { FolderKanban, CheckCircle2, Clock, Activity, BarChart } from "lucide-react"; +import { FolderKanban, CheckCircle2, Clock, Activity, BarChart, type LucideIcon } from "lucide-react"; import Link from "next/link"; import { format } from "date-fns"; import { tr } from "date-fns/locale"; +import { requirePortalBackend } from "@/server/web/portal"; export default async function PortalDashboardPage() { - const supabase = await createClient(); - const { data: { user } } = await supabase.auth.getUser(); - - if (!user) return null; - - // 1. Get the Client record - const { data: clientData } = await supabase - .from("clients") - .select("id, name, company_name") - .eq("client_auth_id", user.id) - .single(); - - if (!clientData) { - return ( -
-

Hesabınız Henüz Aktif Değil

-

- Freelancer'ınız sizin için hesabı oluşturdu ancak müşteri kartınızla henüz eşleşmedi veya bir hata oluştu. Lütfen iletişime geçin. -

-
- ); - } - - // 2. Get Projects - const { data: projectsData } = await supabase - .from("projects") - .select("id, name, status, progress, due_date, created_at") - .eq("client_id", clientData.id) - .order("created_at", { ascending: false }); - - const projects = projectsData || []; - - const activeProjects = projects.filter(p => p.status !== 'completed' && p.status !== 'cancelled'); - const completedProjects = projects.filter(p => p.status === 'completed'); - - const avgProgress = projects.length > 0 ? (projects.reduce((sum, p) => sum + (p.progress || 0), 0) / projects.length).toFixed(0) : "0"; + const { context, actor, service } = await requirePortalBackend(); + const client = service.getClient(actor, context.profile.clientId!); + const projects = service.listProjects(actor); + const activeProjects = projects.filter((project) => project.status !== "completed" && project.status !== "cancelled"); + const completedProjects = projects.filter((project) => project.status === "completed"); + const avgProgress = projects.length + ? Math.round(projects.reduce((sum, project) => sum + project.progress, 0) / projects.length) + : 0; return (
- {/* Header */}
@@ -53,24 +24,20 @@ export default async function PortalDashboardPage() { Genel Bakış
-

- Müşteri Paneli -

+

Müşteri Paneli

- Hoş geldiniz, {clientData.name}. Aktif projelerinizi ve ilerlemeleri buradan takip edin. + Hoş geldiniz, {client.name}. Aktif projelerinizi ve ilerlemeleri buradan takip edin.

- {/* KPI Cards */}
- - + +
- {/* Projects */}

Tüm Projeleriniz

@@ -78,64 +45,52 @@ export default async function PortalDashboardPage() {
Henüz size atanmış bir proje bulunmuyor.
- ) : ( - projects.map(project => ( - - - -
-
-
-
-

{project.name}

+ ) : projects.map((project) => ( + + + +
+
+
+
+

{project.name}

+
+
+
+ + {project.status === "completed" ? "Tamamlandı" : project.status === "active" ? "Aktif" : "Beklemede"} + + {project.dueDate && ( +
+ + Teslim: {format(new Date(project.dueDate), "d MMM yyyy", { locale: tr })}
-
- -
- - {project.status === 'completed' ? 'Tamamlandı' : project.status === 'active' ? 'Aktif' : 'Beklemede'} - - {project.due_date && ( -
- - Teslim: {format(new Date(project.due_date), 'd MMM yyyy', { locale: tr })} -
- )} -
+ )}
- -
-
- İlerleme - %{project.progress} -
-
-
-
+
+
+
+ İlerleme + %{project.progress}
- - - - )) - )} +
+
+
+
+ + + + ))}
); } -function StatCard({ - label, - value, - icon: Icon, - tone, -}: { +function StatCard({ label, value, icon: Icon, tone }: { label: string; value: string; - icon: any; + icon: LucideIcon; tone: "green" | "blue" | "amber"; }) { const toneClass = { @@ -143,7 +98,6 @@ function StatCard({ blue: "bg-blue-50 text-blue-700", amber: "bg-amber-50 text-amber-700", }[tone]; - return ( diff --git a/app/portal/projects/[id]/actions.ts b/app/portal/projects/[id]/actions.ts index a914db4..3b3a141 100644 --- a/app/portal/projects/[id]/actions.ts +++ b/app/portal/projects/[id]/actions.ts @@ -1,36 +1,22 @@ "use server"; -import { createClient } from "@/lib/supabase/server"; import { revalidatePath } from "next/cache"; +import { cleanText } from "@/server/web/form-data"; +import { requirePortalBackend } from "@/server/web/portal"; -export async function createRevisionRequest(projectId: string, clientId: string, formData: FormData) { - const supabase = await createClient(); - const { data: { user } } = await supabase.auth.getUser(); +export async function createRevisionRequest(projectId: string, formData: FormData) { + try { + const { actor, service } = await requirePortalBackend(); + const description = cleanText(formData.get("description")); + if (!description) return { error: "Revizyon açıklaması boş olamaz." }; - if (!user) { - return { error: "Oturum süresi dolmuş." }; + service.requestRevision(actor, { projectId, description }); + revalidatePath(`/portal/projects/${projectId}`); + revalidatePath("/portal/revisions"); + return { success: true }; + } catch (error) { + return { + error: error instanceof Error ? error.message : "Revizyon talebi oluşturulamadı.", + }; } - - const description = formData.get("description") as string; - - if (!description?.trim()) { - return { error: "Revizyon açıklaması boş olamaz." }; - } - - const { error } = await supabase - .from("project_revisions") - .insert({ - project_id: projectId, - client_id: clientId, - requested_by: user.id, - description, - status: "pending" - }); - - if (error) { - return { error: error.message }; - } - - revalidatePath(`/portal/projects/${projectId}`); - return { success: true }; } diff --git a/app/portal/projects/[id]/page.tsx b/app/portal/projects/[id]/page.tsx index 60cccc5..560a5eb 100644 --- a/app/portal/projects/[id]/page.tsx +++ b/app/portal/projects/[id]/page.tsx @@ -1,67 +1,70 @@ -import { createClient } from "@/lib/supabase/server"; import { notFound } from "next/navigation"; -import { PortalProjectClient } from "./portal-project-client"; +import { DomainError } from "@/server/domain/errors"; +import { requirePortalBackend } from "@/server/web/portal"; +import { + PortalProjectClient, + type PortalPlanningSection, + type PortalProjectDetail, + type PortalRevision, + type PortalTask, +} from "./portal-project-client"; export default async function PortalProjectPage({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; - const supabase = await createClient(); - const { data: { user } } = await supabase.auth.getUser(); + const { actor, service } = await requirePortalBackend(); + let data: { + project: PortalProjectDetail; + sections: PortalPlanningSection[]; + tasks: PortalTask[]; + revisions: PortalRevision[]; + }; - if (!user) return null; - - // 1. Get Client Record - const { data: clientData } = await supabase - .from("clients") - .select("id") - .eq("client_auth_id", user.id) - .single(); - - if (!clientData) { - notFound(); + try { + const row = service.getProject(actor, id); + const allowance = service.getRevisionAllowance(actor, id); + data = { + project: { + id: row.id, + name: row.name, + description: row.description, + status: row.status, + progress: row.progress, + due_date: row.dueDate, + revision_quota: allowance.remaining, + can_request_revision: allowance.canRequest, + }, + sections: service.listPlanningSections(actor, id).map((section) => ({ + id: section.id, + title: section.title, + content: section.content, + type: section.category, + })), + tasks: service.listTasks(actor, id) + .filter((task) => task.status !== "cancelled") + .map((task) => ({ + id: task.id, + title: task.title, + status: task.status as PortalTask["status"], + date: task.dueAt?.toISOString() ?? task.scheduledDate, + })), + revisions: service.listRevisions(actor, id).map((revision) => ({ + id: revision.id, + description: revision.description, + status: revision.status, + created_at: revision.createdAt.toISOString(), + })), + }; + } catch (error) { + if (error instanceof DomainError && error.code === "NOT_FOUND") notFound(); + throw error; } - // 2. Get Project - const { data: project, error } = await supabase - .from("projects") - .select("id, name, description, status, progress, due_date, revision_quota") - .eq("id", id) - .eq("client_id", clientData.id) - .single(); - - if (error || !project) { - notFound(); - } - - // 3. Get Planning Sections (Milestones etc.) - const { data: sectionsData } = await supabase - .from("project_planning_sections") - .select("*") - .eq("project_id", id) - .order("order_index", { ascending: true }); - - // 4. Get Public Tasks - const { data: tasksData } = await supabase - .from("tasks") - .select("*") - .eq("project_id", id) - .eq("is_public_to_client", true) - .order("date", { ascending: false }); - - // 5. Get Revisions - const { data: revisionsData } = await supabase - .from("project_revisions") - .select("id, description, status, created_at, requested_by") - .eq("project_id", id) - .eq("client_id", clientData.id) - .order("created_at", { ascending: false }); - return ( ); } diff --git a/app/portal/projects/[id]/portal-project-client.tsx b/app/portal/projects/[id]/portal-project-client.tsx index 6633325..e9192b6 100644 --- a/app/portal/projects/[id]/portal-project-client.tsx +++ b/app/portal/projects/[id]/portal-project-client.tsx @@ -11,7 +11,46 @@ import { createRevisionRequest } from "./actions"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "poyraz-ui/molecules"; -export function PortalProjectClient({ project, sections, tasks, revisions, clientId }: any) { +export type PortalProjectDetail = { + id: string; + name: string; + description: string | null; + status: "planning" | "active" | "paused" | "completed" | "cancelled"; + progress: number; + due_date: string | null; + revision_quota: number; + can_request_revision: boolean; +}; + +export type PortalPlanningSection = { + id: string; + title: string; + content: string | null; + type: string; +}; + +export type PortalTask = { + id: string; + title: string; + status: "todo" | "in_progress" | "done"; + date: string | null; +}; + +export type PortalRevision = { + id: string; + description: string; + status: "pending" | "in_progress" | "completed" | "rejected"; + created_at: string; +}; + +type PortalProjectClientProps = { + project: PortalProjectDetail; + sections: PortalPlanningSection[]; + tasks: PortalTask[]; + revisions: PortalRevision[]; +}; + +export function PortalProjectClient({ project, sections, tasks, revisions }: PortalProjectClientProps) { const [isSubmitting, setIsSubmitting] = useState(false); const [openRevision, setOpenRevision] = useState(false); @@ -20,19 +59,19 @@ export function PortalProjectClient({ project, sections, tasks, revisions, clien setIsSubmitting(true); const formData = new FormData(e.currentTarget); try { - const res = await createRevisionRequest(project.id, clientId, formData); + const res = await createRevisionRequest(project.id, formData); if (res.error) throw new Error(res.error); toast.success("Revizyon talebiniz başarıyla iletildi."); setOpenRevision(false); - } catch (err: any) { - toast.error(err.message); + } catch (error: unknown) { + toast.error(error instanceof Error ? error.message : "Revizyon talebi oluşturulamadı."); } finally { setIsSubmitting(false); } }; - const pendingRevisions = revisions.filter((r: any) => r.status === 'pending' || r.status === 'in_progress').length; - const hasRevisionQuota = project.revision_quota === null || project.revision_quota > 0; + const pendingRevisions = revisions.filter((revision) => revision.status === 'pending' || revision.status === 'in_progress').length; + const hasRevisionQuota = project.can_request_revision; return (
@@ -64,8 +103,8 @@ export function PortalProjectClient({ project, sections, tasks, revisions, clien
)}
- -