feat(backend): migrate freelancer and portal runtimes
This commit is contained in:
@@ -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 <AnalyticsClient data={analyticsData} />;
|
||||
return <AnalyticsClient data={data} />;
|
||||
}
|
||||
|
||||
@@ -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<typeof payload>,
|
||||
service: Awaited<ReturnType<typeof requireFreelancerBackend>>["service"],
|
||||
actor: Awaited<ReturnType<typeof requireFreelancerBackend>>["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");
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<CalendarClient
|
||||
events={events}
|
||||
clients={(clientRows || []) as CalendarRelationOption[]}
|
||||
projects={(projectRows || []) as CalendarRelationOption[]}
|
||||
tasks={(taskRows || []) as CalendarTaskOption[]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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 <CalendarClient events={events} clients={clientOptions} projects={projectOptions} tasks={taskOptions} />;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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 <ClientDetailClient client={client} activities={activities} />;
|
||||
return <ClientDetailClient client={data.client} activities={data.activities} />;
|
||||
}
|
||||
|
||||
@@ -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<T extends readonly string[]>(
|
||||
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}`);
|
||||
}
|
||||
|
||||
@@ -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<string, number>();
|
||||
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<string, number>();
|
||||
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<string, Date>();
|
||||
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 (
|
||||
<ClientsClient
|
||||
clients={clients}
|
||||
totalRevenue={totalRevenue}
|
||||
activeCount={activeCount}
|
||||
pausedCount={pausedCount}
|
||||
archivedCount={archivedCount}
|
||||
totalRevenue={clients.reduce((sum, client) => 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<string, number>();
|
||||
|
||||
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<string, number>();
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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<T extends readonly string[]>(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<typeof payload>,
|
||||
service: Awaited<ReturnType<typeof requireFreelancerBackend>>["service"],
|
||||
actor: Awaited<ReturnType<typeof requireFreelancerBackend>>["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");
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<FinanceClient
|
||||
transactions={transactions}
|
||||
clients={(clientRows || []) as FinanceRelationOption[]}
|
||||
projects={(projectRows || []) as FinanceRelationOption[]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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 <FinanceClient transactions={transactions} clients={clientOptions} projects={projectOptions} />;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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 <JournalClient logs={logs} />;
|
||||
}
|
||||
|
||||
+25
-75
@@ -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 <DashboardClient data={dashboardData} />;
|
||||
return <DashboardClient data={data} />;
|
||||
}
|
||||
|
||||
@@ -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<Record<string, unknown>>;
|
||||
};
|
||||
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 (
|
||||
<ProjectDetailClient
|
||||
project={project}
|
||||
sections={sections}
|
||||
tasks={tasks}
|
||||
financeTransactions={financeTransactions}
|
||||
revisions={revisions}
|
||||
project={data.project}
|
||||
sections={data.sections}
|
||||
tasks={data.tasks}
|
||||
financeTransactions={data.financeTransactions}
|
||||
revisions={data.revisions}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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";
|
||||
}
|
||||
|
||||
@@ -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<T extends readonly string[]>(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<ReturnType<typeof getFileService>["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}`);
|
||||
}
|
||||
|
||||
@@ -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<string, { total: number; done: number }>();
|
||||
|
||||
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 <ProjectsClient projects={projects} clients={clients} />;
|
||||
}
|
||||
|
||||
async function createProjectImageUrls(
|
||||
paths: string[],
|
||||
) {
|
||||
const admin = createServiceRoleClient();
|
||||
const urls = new Map<string, string>();
|
||||
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<string, { total: number; done: number }>();
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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<AiProvider>("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() {
|
||||
<form action={handleProfileAction} className="space-y-6 max-w-xl">
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
{avatarUrl ? (
|
||||
<img src={avatarUrl} alt="Avatar" className="h-16 w-16 rounded-full border border-border object-cover" />
|
||||
<Image
|
||||
src={avatarUrl}
|
||||
alt="Avatar"
|
||||
width={64}
|
||||
height={64}
|
||||
unoptimized
|
||||
className="h-16 w-16 rounded-full border border-border object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full border border-border bg-muted/50">
|
||||
<User className="h-8 w-8 text-muted-foreground" />
|
||||
@@ -211,9 +174,13 @@ export default function SettingsPage() {
|
||||
<CardContent className="p-6 sm:p-8">
|
||||
<h2 className="text-xl font-bold mb-6 text-foreground">Şifre İşlemleri</h2>
|
||||
<form ref={formRef} action={handlePasswordAction} className="space-y-6 max-w-xl">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="currentPassword">Mevcut Şifre</Label>
|
||||
<Input id="currentPassword" name="currentPassword" type="password" required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Yeni Şifre</Label>
|
||||
<Input id="password" name="password" type="password" minLength={6} placeholder="En az 6 karakter" required />
|
||||
<Input id="password" name="password" type="password" minLength={8} placeholder="En az 8 karakter" required />
|
||||
</div>
|
||||
<div className="flex items-center gap-4 pt-4">
|
||||
<Button type="submit" className="gap-2">
|
||||
@@ -269,7 +236,7 @@ export default function SettingsPage() {
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
placeholder="sk-..."
|
||||
placeholder={hasApiKey ? "Kayıtlı anahtarı korumak için boş bırakın" : "sk-..."}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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<T extends readonly string[]>(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<typeof payload>,
|
||||
service: Awaited<ReturnType<typeof requireFreelancerBackend>>["service"],
|
||||
actor: Awaited<ReturnType<typeof requireFreelancerBackend>>["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);
|
||||
}
|
||||
|
||||
@@ -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 <TasksClient tasks={tasks} clients={clients} projects={projects} />;
|
||||
}
|
||||
|
||||
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";
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
</PortalShell>
|
||||
|
||||
+49
-95
@@ -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 (
|
||||
<div className="flex flex-col items-center justify-center h-[50vh] text-center gap-4">
|
||||
<h2 className="text-2xl font-semibold">Hesabınız Henüz Aktif Değil</h2>
|
||||
<p className="text-muted-foreground max-w-md">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div className="mx-auto flex max-w-7xl flex-col gap-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col gap-4 border-b border-border pb-5 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
@@ -53,24 +24,20 @@ export default async function PortalDashboardPage() {
|
||||
Genel Bakış
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-semibold tracking-normal text-foreground">
|
||||
Müşteri Paneli
|
||||
</h1>
|
||||
<h1 className="text-3xl font-semibold tracking-normal text-foreground">Müşteri Paneli</h1>
|
||||
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
|
||||
Hoş geldiniz, {clientData.name}. Aktif projelerinizi ve ilerlemeleri buradan takip edin.
|
||||
Hoş geldiniz, {client.name}. Aktif projelerinizi ve ilerlemeleri buradan takip edin.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KPI Cards */}
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<StatCard label="Aktif Projeler" value={activeProjects.length.toString()} icon={FolderKanban} tone="blue" />
|
||||
<StatCard label="Tamamlanan" value={completedProjects.length.toString()} icon={CheckCircle2} tone="green" />
|
||||
<StatCard label="Aktif Projeler" value={String(activeProjects.length)} icon={FolderKanban} tone="blue" />
|
||||
<StatCard label="Tamamlanan" value={String(completedProjects.length)} icon={CheckCircle2} tone="green" />
|
||||
<StatCard label="Ortalama İlerleme" value={`%${avgProgress}`} icon={BarChart} tone="amber" />
|
||||
</div>
|
||||
|
||||
{/* Projects */}
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-xl font-semibold">Tüm Projeleriniz</h2>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
@@ -78,64 +45,52 @@ export default async function PortalDashboardPage() {
|
||||
<div className="col-span-full py-10 text-center border rounded-lg border-dashed text-muted-foreground">
|
||||
Henüz size atanmış bir proje bulunmuyor.
|
||||
</div>
|
||||
) : (
|
||||
projects.map(project => (
|
||||
<Link key={project.id} href={`/portal/projects/${project.id}`}>
|
||||
<Card className="hover:border-primary/50 transition-colors h-full">
|
||||
<CardContent className="p-5 flex flex-col h-full justify-between gap-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`h-2 w-2 shrink-0 rounded-full ${project.status === 'completed' ? 'bg-emerald-500' : project.status === 'active' ? 'bg-blue-500' : 'bg-amber-500'}`} />
|
||||
<h3 className="font-semibold text-base line-clamp-2 leading-tight">{project.name}</h3>
|
||||
) : projects.map((project) => (
|
||||
<Link key={project.id} href={`/portal/projects/${project.id}`}>
|
||||
<Card className="hover:border-primary/50 transition-colors h-full">
|
||||
<CardContent className="p-5 flex flex-col h-full justify-between gap-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`h-2 w-2 shrink-0 rounded-full ${project.status === "completed" ? "bg-emerald-500" : project.status === "active" ? "bg-blue-500" : "bg-amber-500"}`} />
|
||||
<h3 className="font-semibold text-base line-clamp-2 leading-tight">{project.name}</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Badge variant={project.status === "completed" ? "secondary" : "default"} className="capitalize text-[10px] px-1.5 py-0">
|
||||
{project.status === "completed" ? "Tamamlandı" : project.status === "active" ? "Aktif" : "Beklemede"}
|
||||
</Badge>
|
||||
{project.dueDate && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
<span>Teslim: {format(new Date(project.dueDate), "d MMM yyyy", { locale: tr })}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Badge variant={project.status === 'completed' ? 'secondary' : 'default'} className="capitalize text-[10px] px-1.5 py-0">
|
||||
{project.status === 'completed' ? 'Tamamlandı' : project.status === 'active' ? 'Aktif' : 'Beklemede'}
|
||||
</Badge>
|
||||
{project.due_date && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
<span>Teslim: {format(new Date(project.due_date), 'd MMM yyyy', { locale: tr })}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 mt-2">
|
||||
<div className="flex items-center justify-between text-xs font-medium">
|
||||
<span className="text-muted-foreground">İlerleme</span>
|
||||
<span>%{project.progress}</span>
|
||||
</div>
|
||||
<div className="h-2 w-full bg-secondary rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary transition-all duration-500"
|
||||
style={{ width: `${project.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5 mt-2">
|
||||
<div className="flex items-center justify-between text-xs font-medium">
|
||||
<span className="text-muted-foreground">İlerleme</span>
|
||||
<span>%{project.progress}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))
|
||||
)}
|
||||
<div className="h-2 w-full bg-secondary rounded-full overflow-hidden">
|
||||
<div className="h-full bg-primary transition-all duration-500" style={{ width: `${project.progress}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-between gap-3 p-4">
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<PortalProjectClient
|
||||
project={project}
|
||||
sections={sectionsData || []}
|
||||
tasks={tasksData || []}
|
||||
revisions={revisionsData || []}
|
||||
clientId={clientData.id}
|
||||
project={data.project}
|
||||
sections={data.sections}
|
||||
tasks={data.tasks}
|
||||
revisions={data.revisions}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="mx-auto flex max-w-7xl flex-col gap-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||
@@ -64,8 +103,8 @@ export function PortalProjectClient({ project, sections, tasks, revisions, clien
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label>Lütfen yapılmasını istediğiniz değişiklikleri detaylıca açıklayın</Label>
|
||||
<Textarea name="description" required rows={5} placeholder="Şu kısmın rengi mavi olabilir mi? Ayrıca metinleri güncelleyelim..." />
|
||||
<Label htmlFor="revision-description">Lütfen yapılmasını istediğiniz değişiklikleri detaylıca açıklayın</Label>
|
||||
<Textarea id="revision-description" name="description" required rows={5} placeholder="Şu kısmın rengi mavi olabilir mi? Ayrıca metinleri güncelleyelim..." />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
@@ -138,15 +177,15 @@ export function PortalProjectClient({ project, sections, tasks, revisions, clien
|
||||
<p className="text-sm text-muted-foreground italic">Listelenecek görev bulunmuyor.</p>
|
||||
) : (
|
||||
<ul className="space-y-3 max-h-60 overflow-y-auto tiny-scrollbar pr-2">
|
||||
{tasks.map((task: any) => (
|
||||
{tasks.map((task) => (
|
||||
<li key={task.id} className="text-sm flex gap-3 p-2 rounded hover:bg-muted/30 transition-colors">
|
||||
{task.status === 'completed' || task.status === 'done' ? (
|
||||
{task.status === 'done' ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-emerald-500 shrink-0 mt-0.5" />
|
||||
) : (
|
||||
<div className="h-4 w-4 rounded-full border-2 border-muted-foreground/30 shrink-0 mt-0.5" />
|
||||
)}
|
||||
<div>
|
||||
<span className={task.status === 'completed' || task.status === 'done' ? "text-muted-foreground" : "text-foreground font-medium"}>
|
||||
<span className={task.status === 'done' ? "text-muted-foreground" : "text-foreground font-medium"}>
|
||||
{task.title}
|
||||
</span>
|
||||
{task.date && (
|
||||
@@ -171,7 +210,7 @@ export function PortalProjectClient({ project, sections, tasks, revisions, clien
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{sections.map((section: any) => (
|
||||
{sections.map((section) => (
|
||||
<Card key={section.id}>
|
||||
<CardContent className="p-5 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -205,7 +244,7 @@ export function PortalProjectClient({ project, sections, tasks, revisions, clien
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{revisions.map((rev: any) => (
|
||||
{revisions.map((rev) => (
|
||||
<Card key={rev.id} className="transition-colors hover:border-primary/30">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex justify-between items-start mb-3">
|
||||
|
||||
@@ -1,37 +1,13 @@
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
import { Card, CardContent, Badge } from "poyraz-ui/atoms";
|
||||
import { FolderKanban, Clock } 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 PortalProjectsPage() {
|
||||
const supabase = await createClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
const { data: clientData } = await supabase
|
||||
.from("clients")
|
||||
.select("id")
|
||||
.eq("client_auth_id", user.id)
|
||||
.single();
|
||||
|
||||
if (!clientData) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-[50vh] text-center gap-4">
|
||||
<h2 className="text-2xl font-semibold">Hesabınız Henüz Aktif Değil</h2>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { data: projectsData } = await supabase
|
||||
.from("projects")
|
||||
.select("id, name, status, progress, due_date")
|
||||
.eq("client_id", clientData.id)
|
||||
.order("created_at", { ascending: false });
|
||||
|
||||
const projects = projectsData || [];
|
||||
const { actor, service } = await requirePortalBackend();
|
||||
const projects = service.listProjects(actor);
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex max-w-7xl flex-col gap-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||
@@ -46,44 +22,37 @@ export default async function PortalProjectsPage() {
|
||||
<FolderKanban className="w-10 h-10 text-muted-foreground/50" />
|
||||
Henüz size atanmış bir proje bulunmuyor.
|
||||
</div>
|
||||
) : (
|
||||
projects.map(project => (
|
||||
<Link key={project.id} href={`/portal/projects/${project.id}`}>
|
||||
<Card className="hover:border-primary/50 transition-colors h-full">
|
||||
<CardContent className="p-5 flex flex-col h-full justify-between gap-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-start justify-between">
|
||||
<h3 className="font-semibold text-lg line-clamp-2">{project.name}</h3>
|
||||
<Badge variant={project.status === 'completed' ? 'secondary' : 'default'} className="capitalize shrink-0">
|
||||
{project.status}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{project.due_date && (
|
||||
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<Clock className="h-4 w-4" />
|
||||
<span>Son Teslim: {format(new Date(project.due_date), 'd MMM yyyy', { locale: tr })}</span>
|
||||
</div>
|
||||
)}
|
||||
) : projects.map((project) => (
|
||||
<Link key={project.id} href={`/portal/projects/${project.id}`}>
|
||||
<Card className="hover:border-primary/50 transition-colors h-full">
|
||||
<CardContent className="p-5 flex flex-col h-full justify-between gap-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-start justify-between">
|
||||
<h3 className="font-semibold text-lg line-clamp-2">{project.name}</h3>
|
||||
<Badge variant={project.status === "completed" ? "secondary" : "default"} className="capitalize shrink-0">
|
||||
{project.status}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between text-xs font-medium">
|
||||
<span>İlerleme</span>
|
||||
<span>%{project.progress}</span>
|
||||
</div>
|
||||
<div className="h-2 w-full bg-secondary rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary transition-all duration-500"
|
||||
style={{ width: `${project.progress}%` }}
|
||||
/>
|
||||
{project.dueDate && (
|
||||
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<Clock className="h-4 w-4" />
|
||||
<span>Son Teslim: {format(new Date(project.dueDate), "d MMM yyyy", { locale: tr })}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between text-xs font-medium">
|
||||
<span>İlerleme</span>
|
||||
<span>%{project.progress}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))
|
||||
)}
|
||||
<div className="h-2 w-full bg-secondary rounded-full overflow-hidden">
|
||||
<div className="h-full bg-primary transition-all duration-500" style={{ width: `${project.progress}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,56 +1,16 @@
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
import { Card, CardContent, Badge } from "poyraz-ui/atoms";
|
||||
import { Clock, MessageSquare } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { format } from "date-fns";
|
||||
import { tr } from "date-fns/locale";
|
||||
|
||||
type RevisionRow = {
|
||||
id: string;
|
||||
description: string;
|
||||
status: string;
|
||||
project_id: string;
|
||||
created_at: string;
|
||||
};
|
||||
import { requirePortalBackend } from "@/server/web/portal";
|
||||
|
||||
export default async function PortalRevisionsPage() {
|
||||
const supabase = await createClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
const { data: clientData } = await supabase
|
||||
.from("clients")
|
||||
.select("id")
|
||||
.eq("client_auth_id", user.id)
|
||||
.single();
|
||||
|
||||
if (!clientData) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-[50vh] text-center gap-4">
|
||||
<h2 className="text-2xl font-semibold">Hesabınız Henüz Aktif Değil</h2>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { data: projectsData } = await supabase
|
||||
.from("projects")
|
||||
.select("id, name")
|
||||
.eq("client_id", clientData.id);
|
||||
|
||||
const projectIds = projectsData?.map(p => p.id) || [];
|
||||
|
||||
let revisions: RevisionRow[] = [];
|
||||
if (projectIds.length > 0) {
|
||||
const { data: revisionsData } = await supabase
|
||||
.from("project_revisions")
|
||||
.select("id, description, status, project_id, created_at")
|
||||
.in("project_id", projectIds)
|
||||
.order("created_at", { ascending: false });
|
||||
revisions = revisionsData || [];
|
||||
}
|
||||
|
||||
const getProjectName = (id: string) => projectsData?.find(p => p.id === id)?.name || "Bilinmeyen Proje";
|
||||
const { actor, service } = await requirePortalBackend();
|
||||
const projects = service.listProjects(actor);
|
||||
const projectNames = new Map(projects.map((project) => [project.id, project.name]));
|
||||
const revisions = service.listPortalRevisions(actor)
|
||||
.filter((revision) => projectNames.has(revision.projectId));
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex max-w-7xl flex-col gap-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||
@@ -65,47 +25,38 @@ export default async function PortalRevisionsPage() {
|
||||
<MessageSquare className="w-10 h-10 text-muted-foreground/50" />
|
||||
Henüz bir revizyon talebinde bulunmadınız.
|
||||
</div>
|
||||
) : (
|
||||
revisions.map(rev => (
|
||||
<Card key={rev.id} className="h-full">
|
||||
<CardContent className="p-5 flex flex-col h-full justify-between gap-4">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start justify-between gap-2 border-b border-border pb-3">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Clock className="h-4 w-4" />
|
||||
{format(new Date(rev.created_at), "d MMM yyyy, HH:mm", { locale: tr })}
|
||||
</div>
|
||||
<Badge variant={
|
||||
rev.status === 'completed' ? 'default' :
|
||||
rev.status === 'rejected' ? 'destructive' : 'secondary'
|
||||
} className="capitalize shrink-0">
|
||||
{rev.status === 'pending' ? 'Bekliyor' :
|
||||
rev.status === 'in_progress' ? 'İşleniyor' :
|
||||
rev.status === 'completed' ? 'Tamamlandı' : 'Reddedildi'}
|
||||
</Badge>
|
||||
) : revisions.map((revision) => (
|
||||
<Card key={revision.id} className="h-full">
|
||||
<CardContent className="p-5 flex flex-col h-full justify-between gap-4">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start justify-between gap-2 border-b border-border pb-3">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Clock className="h-4 w-4" />
|
||||
{format(revision.createdAt, "d MMM yyyy, HH:mm", { locale: tr })}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xs font-medium uppercase text-muted-foreground">Proje:</span>
|
||||
<span className="text-sm font-semibold truncate bg-muted/30 p-2 rounded-md">
|
||||
{getProjectName(rev.project_id)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-foreground whitespace-pre-wrap leading-relaxed">
|
||||
{rev.description}
|
||||
</p>
|
||||
<Badge
|
||||
variant={revision.status === "completed" ? "default" : revision.status === "rejected" ? "destructive" : "secondary"}
|
||||
className="capitalize shrink-0"
|
||||
>
|
||||
{revision.status === "pending" ? "Bekliyor" : revision.status === "in_progress" ? "İşleniyor" : revision.status === "completed" ? "Tamamlandı" : "Reddedildi"}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end border-t border-border pt-4">
|
||||
<Link href={`/portal/projects/${rev.project_id}`} className="text-xs text-primary font-medium hover:underline">
|
||||
Projeye Git →
|
||||
</Link>
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xs font-medium uppercase text-muted-foreground">Proje:</span>
|
||||
<span className="text-sm font-semibold truncate bg-muted/30 p-2 rounded-md">
|
||||
{projectNames.get(revision.projectId)}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
<p className="text-sm text-foreground whitespace-pre-wrap leading-relaxed">{revision.description}</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-end border-t border-border pt-4">
|
||||
<Link href={`/portal/projects/${revision.projectId}`} className="text-xs text-primary font-medium hover:underline">
|
||||
Projeye Git →
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
+19
-66
@@ -1,59 +1,16 @@
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
import { Card, CardContent, Badge } from "poyraz-ui/atoms";
|
||||
import { CheckCircle2, Clock, CalendarDays, KanbanSquare } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { format } from "date-fns";
|
||||
import { tr } from "date-fns/locale";
|
||||
|
||||
type PortalTaskRow = {
|
||||
id: string;
|
||||
title: string;
|
||||
status: string;
|
||||
project_id: string;
|
||||
created_at: string;
|
||||
date: string | null;
|
||||
priority: string | null;
|
||||
};
|
||||
import { requirePortalBackend } from "@/server/web/portal";
|
||||
|
||||
export default async function PortalTasksPage() {
|
||||
const supabase = await createClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
const { data: clientData } = await supabase
|
||||
.from("clients")
|
||||
.select("id")
|
||||
.eq("client_auth_id", user.id)
|
||||
.single();
|
||||
|
||||
if (!clientData) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-[50vh] text-center gap-4">
|
||||
<h2 className="text-2xl font-semibold">Hesabınız Henüz Aktif Değil</h2>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { data: projectsData } = await supabase
|
||||
.from("projects")
|
||||
.select("id, name")
|
||||
.eq("client_id", clientData.id);
|
||||
|
||||
const projectIds = projectsData?.map(p => p.id) || [];
|
||||
|
||||
let tasks: PortalTaskRow[] = [];
|
||||
if (projectIds.length > 0) {
|
||||
const { data: tasksData } = await supabase
|
||||
.from("tasks")
|
||||
.select("id, title, status, project_id, created_at, date, priority")
|
||||
.in("project_id", projectIds)
|
||||
.eq("is_public_to_client", true)
|
||||
.order("created_at", { ascending: false });
|
||||
tasks = tasksData || [];
|
||||
}
|
||||
|
||||
const getProjectName = (id: string) => projectsData?.find(p => p.id === id)?.name || "Bilinmeyen Proje";
|
||||
const { actor, service } = await requirePortalBackend();
|
||||
const projects = service.listProjects(actor);
|
||||
const projectNames = new Map(projects.map((project) => [project.id, project.name]));
|
||||
const tasks = service.listTasks(actor)
|
||||
.filter((task) => task.projectId && projectNames.has(task.projectId) && task.status !== "cancelled")
|
||||
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex max-w-7xl flex-col gap-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||
@@ -68,40 +25,36 @@ export default async function PortalTasksPage() {
|
||||
<KanbanSquare className="w-10 h-10 text-muted-foreground/50" />
|
||||
Henüz sizinle paylaşılan bir görev bulunmuyor.
|
||||
</div>
|
||||
) : (
|
||||
tasks.map(task => (
|
||||
) : tasks.map((task) => {
|
||||
const isDone = task.status === "done";
|
||||
const date = task.dueAt?.toISOString() ?? task.scheduledDate;
|
||||
return (
|
||||
<Card key={task.id} className="h-full">
|
||||
<CardContent className="p-5 flex flex-col h-full justify-between gap-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h3 className={task.status === 'completed' || task.status === 'done' ? "font-semibold text-lg line-through text-muted-foreground line-clamp-2" : "font-semibold text-lg line-clamp-2"}>
|
||||
<h3 className={isDone ? "font-semibold text-lg line-through text-muted-foreground line-clamp-2" : "font-semibold text-lg line-clamp-2"}>
|
||||
{task.title}
|
||||
</h3>
|
||||
<Badge variant={task.status === 'completed' || task.status === 'done' ? 'secondary' : 'outline'} className="capitalize shrink-0">
|
||||
{task.status === 'todo' ? 'Bekliyor' : task.status === 'in_progress' ? 'İşleniyor' : 'Tamamlandı'}
|
||||
<Badge variant={isDone ? "secondary" : "outline"} className="capitalize shrink-0">
|
||||
{task.status === "todo" ? "Bekliyor" : task.status === "in_progress" ? "İşleniyor" : "Tamamlandı"}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground bg-muted/30 p-2 rounded-md">
|
||||
<span className="font-medium truncate">{getProjectName(task.project_id)}</span>
|
||||
<span className="font-medium truncate">{projectNames.get(task.projectId!)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground border-t border-border pt-4">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<CalendarDays className="h-4 w-4" />
|
||||
<span>{task.date ? format(new Date(task.date), 'd MMM yyyy', { locale: tr }) : 'Tarih yok'}</span>
|
||||
<span>{date ? format(new Date(date), "d MMM yyyy", { locale: tr }) : "Tarih yok"}</span>
|
||||
</div>
|
||||
{task.status === 'completed' || task.status === 'done' ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
|
||||
) : (
|
||||
<Clock className="h-4 w-4" />
|
||||
)}
|
||||
{isDone ? <CheckCircle2 className="h-4 w-4 text-emerald-500" /> : <Clock className="h-4 w-4" />}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
CREATE TABLE `user_ai_settings` (
|
||||
`owner_user_id` text PRIMARY KEY NOT NULL,
|
||||
`provider` text DEFAULT 'gemini' NOT NULL,
|
||||
`model` text,
|
||||
`encrypted_api_key` text,
|
||||
`created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
`updated_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
FOREIGN KEY (`owner_user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
CONSTRAINT "user_ai_settings_provider_check" CHECK("user_ai_settings"."provider" in ('gemini', 'openai', 'groq', 'ollama'))
|
||||
);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -36,6 +36,13 @@
|
||||
"when": 1784210311370,
|
||||
"tag": "0004_fancy_baron_zemo",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "6",
|
||||
"when": 1784234752708,
|
||||
"tag": "0005_brief_black_bolt",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from "./auth";
|
||||
export * from "./domain";
|
||||
export * from "./runtime";
|
||||
export * from "./settings";
|
||||
export * from "./storage";
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { check, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||
import { user } from "./auth";
|
||||
|
||||
export type AiProvider = "gemini" | "openai" | "groq" | "ollama";
|
||||
|
||||
export const userAiSettings = sqliteTable(
|
||||
"user_ai_settings",
|
||||
{
|
||||
ownerUserId: text("owner_user_id")
|
||||
.primaryKey()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
provider: text("provider").$type<AiProvider>().default("gemini").notNull(),
|
||||
model: text("model"),
|
||||
encryptedApiKey: text("encrypted_api_key"),
|
||||
createdAt: text("created_at").default(sql`CURRENT_TIMESTAMP`).notNull(),
|
||||
updatedAt: text("updated_at").default(sql`CURRENT_TIMESTAMP`).notNull(),
|
||||
},
|
||||
(table) => [
|
||||
check(
|
||||
"user_ai_settings_provider_check",
|
||||
sql`${table.provider} in ('gemini', 'openai', 'groq', 'ollama')`,
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -49,7 +49,17 @@ export const clientCreateSchema = z.object({
|
||||
nextFollowUpDate: optionalDate,
|
||||
notes: optionalText(10_000),
|
||||
});
|
||||
export const clientUpdateSchema = clientCreateSchema.omit({ id: true }).partial();
|
||||
export const clientUpdateSchema = z.object({
|
||||
name: z.string().trim().min(1).max(160).optional(),
|
||||
companyName: optionalText(160),
|
||||
email: z.email().nullable().optional(),
|
||||
phone: optionalText(40),
|
||||
website: z.url().nullable().optional(),
|
||||
status: z.enum(clientStatuses).optional(),
|
||||
pipelineStage: z.enum(clientPipelineStages).optional(),
|
||||
nextFollowUpDate: optionalDate,
|
||||
notes: optionalText(10_000),
|
||||
});
|
||||
|
||||
export const clientActivityCreateSchema = z.object({
|
||||
id: resourceIdSchema.optional(),
|
||||
@@ -77,7 +87,22 @@ export const projectCreateSchema = z.object({
|
||||
legacyCoverImagePath: optionalText(1_000),
|
||||
coverImageAlt: optionalText(500),
|
||||
});
|
||||
export const projectUpdateSchema = projectCreateSchema.omit({ id: true }).partial();
|
||||
export const projectUpdateSchema = z.object({
|
||||
clientId: optionalId,
|
||||
name: z.string().trim().min(1).max(200).optional(),
|
||||
type: z.enum(projectTypes).optional(),
|
||||
description: optionalText(20_000),
|
||||
status: z.enum(projectStatuses).optional(),
|
||||
startDate: optionalDate,
|
||||
dueDate: optionalDate,
|
||||
budgetAmountMinor: minorAmountSchema.nullable().optional(),
|
||||
currency: currencySchema.optional(),
|
||||
progress: z.number().int().min(0).max(100).optional(),
|
||||
progressType: z.enum(projectProgressTypes).optional(),
|
||||
revisionQuota: z.number().int().min(0).max(10_000).optional(),
|
||||
legacyCoverImagePath: optionalText(1_000),
|
||||
coverImageAlt: optionalText(500),
|
||||
});
|
||||
|
||||
export const taskCreateSchema = z.object({
|
||||
id: resourceIdSchema.optional(),
|
||||
@@ -95,7 +120,21 @@ export const taskCreateSchema = z.object({
|
||||
aiGenerated: z.boolean().default(false),
|
||||
isPublicToClient: z.boolean().default(false),
|
||||
});
|
||||
export const taskUpdateSchema = taskCreateSchema.omit({ id: true }).partial();
|
||||
export const taskUpdateSchema = z.object({
|
||||
clientId: optionalId,
|
||||
projectId: optionalId,
|
||||
sourceJournalEntryId: optionalId,
|
||||
title: z.string().trim().min(1).max(300).optional(),
|
||||
description: optionalText(20_000),
|
||||
status: z.enum(taskStatuses).optional(),
|
||||
priority: z.enum(taskPriorities).optional(),
|
||||
scheduledDate: optionalDate,
|
||||
dueAt: z.date().nullable().optional(),
|
||||
estimatedMinutes: z.number().int().min(0).nullable().optional(),
|
||||
actualMinutes: z.number().int().min(0).nullable().optional(),
|
||||
aiGenerated: z.boolean().optional(),
|
||||
isPublicToClient: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const calendarEventBaseSchema = z.object({
|
||||
id: resourceIdSchema.optional(),
|
||||
@@ -113,7 +152,16 @@ export const calendarEventCreateSchema = calendarEventBaseSchema
|
||||
message: "Bitiş zamanı başlangıç zamanından önce olamaz.",
|
||||
path: ["endsAt"],
|
||||
});
|
||||
export const calendarEventUpdateSchema = calendarEventBaseSchema.omit({ id: true }).partial();
|
||||
export const calendarEventUpdateSchema = z.object({
|
||||
clientId: optionalId,
|
||||
projectId: optionalId,
|
||||
taskId: optionalId,
|
||||
title: z.string().trim().min(1).max(300).optional(),
|
||||
description: optionalText(20_000),
|
||||
type: z.enum(calendarEventTypes).optional(),
|
||||
startsAt: z.date().optional(),
|
||||
endsAt: z.date().nullable().optional(),
|
||||
});
|
||||
|
||||
export const financeTransactionCreateSchema = z.object({
|
||||
id: resourceIdSchema.optional(),
|
||||
@@ -127,7 +175,17 @@ export const financeTransactionCreateSchema = z.object({
|
||||
paymentStatus: z.enum(paymentStatuses).default("planned"),
|
||||
description: optionalText(10_000),
|
||||
});
|
||||
export const financeTransactionUpdateSchema = financeTransactionCreateSchema.omit({ id: true }).partial();
|
||||
export const financeTransactionUpdateSchema = z.object({
|
||||
clientId: optionalId,
|
||||
projectId: optionalId,
|
||||
type: z.enum(financeTransactionTypes).optional(),
|
||||
amountMinor: minorAmountSchema.optional(),
|
||||
currency: currencySchema.optional(),
|
||||
transactionDate: businessDateSchema.optional(),
|
||||
category: optionalText(160),
|
||||
paymentStatus: z.enum(paymentStatuses).optional(),
|
||||
description: optionalText(10_000),
|
||||
});
|
||||
|
||||
export const journalEntrySchema = z.object({
|
||||
id: resourceIdSchema.optional(),
|
||||
@@ -149,7 +207,13 @@ export const planningSectionCreateSchema = z.object({
|
||||
metadata: z.record(z.string(), z.unknown()).default({}),
|
||||
sortOrder: z.number().int().min(0).default(0),
|
||||
});
|
||||
export const planningSectionUpdateSchema = planningSectionCreateSchema.omit({ id: true, projectId: true }).partial();
|
||||
export const planningSectionUpdateSchema = z.object({
|
||||
category: z.enum(planningSectionCategories).optional(),
|
||||
title: z.string().trim().min(1).max(300).optional(),
|
||||
content: optionalText(50_000),
|
||||
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||
sortOrder: z.number().int().min(0).optional(),
|
||||
});
|
||||
|
||||
export const revisionCreateSchema = z.object({
|
||||
id: resourceIdSchema.optional(),
|
||||
|
||||
@@ -176,6 +176,7 @@ export class FileService {
|
||||
const scope = requireClientScope(actor);
|
||||
if (file.kind === "avatar" && file.authUserId === scope.authUserId) return;
|
||||
if (file.kind === "project_asset" && file.visibility === "portal" && file.projectId) {
|
||||
this.getClientOwner(actor);
|
||||
const project = this.db
|
||||
.select({ id: projects.id })
|
||||
.from(projects)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, asc, count, desc, eq, ne, sql } from "drizzle-orm";
|
||||
import { and, asc, count, desc, eq, gte, lte, ne, sql } from "drizzle-orm";
|
||||
import {
|
||||
calendarEvents,
|
||||
chatMessages,
|
||||
@@ -24,6 +24,8 @@ export function createDomainRepositories(db: DomainDatabase) {
|
||||
clients: {
|
||||
list: (scope: OwnerScope) =>
|
||||
db.select().from(clients).where(eq(clients.ownerUserId, scope.ownerUserId)).orderBy(desc(clients.updatedAt)).all(),
|
||||
recent: (scope: OwnerScope, limit: number) =>
|
||||
db.select().from(clients).where(eq(clients.ownerUserId, scope.ownerUserId)).orderBy(desc(clients.createdAt)).limit(limit).all(),
|
||||
get: (scope: OwnerScope, id: string) =>
|
||||
db.select().from(clients).where(and(eq(clients.id, id), eq(clients.ownerUserId, scope.ownerUserId))).get(),
|
||||
getByPortalScope: (scope: ClientScope) =>
|
||||
@@ -36,18 +38,45 @@ export function createDomainRepositories(db: DomainDatabase) {
|
||||
db.delete(clients).where(and(eq(clients.id, id), eq(clients.ownerUserId, scope.ownerUserId))).returning().get(),
|
||||
listActivities: (scope: OwnerScope, clientId: string) =>
|
||||
db.select().from(clientActivities).where(and(eq(clientActivities.ownerUserId, scope.ownerUserId), eq(clientActivities.clientId, clientId))).orderBy(desc(clientActivities.activityDate)).all(),
|
||||
listAllActivities: (scope: OwnerScope) =>
|
||||
db.select().from(clientActivities).where(eq(clientActivities.ownerUserId, scope.ownerUserId)).orderBy(desc(clientActivities.activityDate)).all(),
|
||||
createActivity: (scope: OwnerScope, value: Omit<typeof clientActivities.$inferInsert, "ownerUserId">) =>
|
||||
db.insert(clientActivities).values({ ...value, ownerUserId: scope.ownerUserId }).returning().get(),
|
||||
},
|
||||
projects: {
|
||||
list: (scope: OwnerScope) =>
|
||||
db.select().from(projects).where(eq(projects.ownerUserId, scope.ownerUserId)).orderBy(desc(projects.updatedAt)).all(),
|
||||
recent: (scope: OwnerScope, limit: number) =>
|
||||
db.select().from(projects).where(eq(projects.ownerUserId, scope.ownerUserId)).orderBy(desc(projects.createdAt)).limit(limit).all(),
|
||||
get: (scope: OwnerScope, id: string) =>
|
||||
db.select().from(projects).where(and(eq(projects.id, id), eq(projects.ownerUserId, scope.ownerUserId))).get(),
|
||||
getForClient: (scope: ClientScope, id: string) =>
|
||||
db.select().from(projects).where(and(eq(projects.id, id), eq(projects.clientId, scope.clientId))).get(),
|
||||
db.select({ project: projects })
|
||||
.from(projects)
|
||||
.innerJoin(
|
||||
clients,
|
||||
and(
|
||||
eq(projects.clientId, clients.id),
|
||||
eq(clients.id, scope.clientId),
|
||||
eq(clients.authUserId, scope.authUserId),
|
||||
),
|
||||
)
|
||||
.where(eq(projects.id, id))
|
||||
.get()?.project,
|
||||
listForClient: (scope: ClientScope) =>
|
||||
db.select().from(projects).where(eq(projects.clientId, scope.clientId)).orderBy(desc(projects.updatedAt)).all(),
|
||||
db.select({ project: projects })
|
||||
.from(projects)
|
||||
.innerJoin(
|
||||
clients,
|
||||
and(
|
||||
eq(projects.clientId, clients.id),
|
||||
eq(clients.id, scope.clientId),
|
||||
eq(clients.authUserId, scope.authUserId),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(projects.updatedAt))
|
||||
.all()
|
||||
.map(({ project }) => project),
|
||||
create: (scope: OwnerScope, value: Omit<typeof projects.$inferInsert, "ownerUserId">) =>
|
||||
db.insert(projects).values({ ...value, ownerUserId: scope.ownerUserId }).returning().get(),
|
||||
update: (scope: OwnerScope, id: string, value: Partial<typeof projects.$inferInsert>) =>
|
||||
@@ -91,6 +120,8 @@ export function createDomainRepositories(db: DomainDatabase) {
|
||||
},
|
||||
finance: {
|
||||
list: (scope: OwnerScope) => db.select().from(financeTransactions).where(eq(financeTransactions.ownerUserId, scope.ownerUserId)).orderBy(desc(financeTransactions.transactionDate)).all(),
|
||||
listInRange: (scope: OwnerScope, startDate: string, endDate: string) =>
|
||||
db.select().from(financeTransactions).where(and(eq(financeTransactions.ownerUserId, scope.ownerUserId), gte(financeTransactions.transactionDate, startDate), lte(financeTransactions.transactionDate, endDate))).orderBy(asc(financeTransactions.transactionDate)).all(),
|
||||
get: (scope: OwnerScope, id: string) => db.select().from(financeTransactions).where(and(eq(financeTransactions.id, id), eq(financeTransactions.ownerUserId, scope.ownerUserId))).get(),
|
||||
create: (scope: OwnerScope, value: Omit<typeof financeTransactions.$inferInsert, "ownerUserId">) => db.insert(financeTransactions).values({ ...value, ownerUserId: scope.ownerUserId }).returning().get(),
|
||||
update: (scope: OwnerScope, id: string, value: Partial<typeof financeTransactions.$inferInsert>) => db.update(financeTransactions).set(value).where(and(eq(financeTransactions.id, id), eq(financeTransactions.ownerUserId, scope.ownerUserId))).returning().get(),
|
||||
@@ -98,16 +129,39 @@ export function createDomainRepositories(db: DomainDatabase) {
|
||||
},
|
||||
journal: {
|
||||
list: (scope: OwnerScope) => db.select().from(journalEntries).where(eq(journalEntries.ownerUserId, scope.ownerUserId)).orderBy(desc(journalEntries.entryDate)).all(),
|
||||
listInRange: (scope: OwnerScope, startDate: string, endDate: string) =>
|
||||
db.select().from(journalEntries).where(and(eq(journalEntries.ownerUserId, scope.ownerUserId), gte(journalEntries.entryDate, startDate), lte(journalEntries.entryDate, endDate))).orderBy(asc(journalEntries.entryDate)).all(),
|
||||
getByDate: (scope: OwnerScope, entryDate: string) => db.select().from(journalEntries).where(and(eq(journalEntries.ownerUserId, scope.ownerUserId), eq(journalEntries.entryDate, entryDate))).get(),
|
||||
get: (scope: OwnerScope, id: string) => db.select().from(journalEntries).where(and(eq(journalEntries.id, id), eq(journalEntries.ownerUserId, scope.ownerUserId))).get(),
|
||||
create: (scope: OwnerScope, value: Omit<typeof journalEntries.$inferInsert, "ownerUserId">) => db.insert(journalEntries).values({ ...value, ownerUserId: scope.ownerUserId }).returning().get(),
|
||||
updateByDate: (scope: OwnerScope, entryDate: string, value: Partial<typeof journalEntries.$inferInsert>) => db.update(journalEntries).set(value).where(and(eq(journalEntries.ownerUserId, scope.ownerUserId), eq(journalEntries.entryDate, entryDate))).returning().get(),
|
||||
update: (scope: OwnerScope, id: string, value: Partial<typeof journalEntries.$inferInsert>) => db.update(journalEntries).set(value).where(and(eq(journalEntries.id, id), eq(journalEntries.ownerUserId, scope.ownerUserId))).returning().get(),
|
||||
remove: (scope: OwnerScope, id: string) => db.delete(journalEntries).where(and(eq(journalEntries.id, id), eq(journalEntries.ownerUserId, scope.ownerUserId))).returning().get(),
|
||||
},
|
||||
revisions: {
|
||||
list: (scope: OwnerScope, projectId: string) => db.select().from(projectRevisions).where(and(eq(projectRevisions.ownerUserId, scope.ownerUserId), eq(projectRevisions.projectId, projectId))).orderBy(desc(projectRevisions.createdAt)).all(),
|
||||
updateStatus: (scope: OwnerScope, id: string, status: typeof projectRevisions.$inferInsert.status) => db.update(projectRevisions).set({ status }).where(and(eq(projectRevisions.id, id), eq(projectRevisions.ownerUserId, scope.ownerUserId))).returning().get(),
|
||||
listForClient: (scope: ClientScope, projectId: string) => db.select().from(projectRevisions).where(and(eq(projectRevisions.clientId, scope.clientId), eq(projectRevisions.projectId, projectId))).orderBy(desc(projectRevisions.createdAt)).all(),
|
||||
listAllForClient: (scope: ClientScope) => db.select({ revision: projectRevisions })
|
||||
.from(projectRevisions)
|
||||
.innerJoin(
|
||||
projects,
|
||||
and(
|
||||
eq(projectRevisions.projectId, projects.id),
|
||||
eq(projects.clientId, scope.clientId),
|
||||
),
|
||||
)
|
||||
.innerJoin(
|
||||
clients,
|
||||
and(
|
||||
eq(projects.clientId, clients.id),
|
||||
eq(clients.authUserId, scope.authUserId),
|
||||
),
|
||||
)
|
||||
.where(eq(projectRevisions.clientId, scope.clientId))
|
||||
.orderBy(desc(projectRevisions.createdAt))
|
||||
.all()
|
||||
.map(({ revision }) => revision),
|
||||
},
|
||||
chat: {
|
||||
listSessions: (scope: OwnerScope) => db.select().from(chatSessions).where(eq(chatSessions.ownerUserId, scope.ownerUserId)).orderBy(desc(chatSessions.updatedAt)).all(),
|
||||
@@ -131,6 +185,27 @@ export function createDomainRepositories(db: DomainDatabase) {
|
||||
}).from(financeTransactions).where(eq(financeTransactions.ownerUserId, scope.ownerUserId)).get(),
|
||||
projectStatusCounts: (scope: OwnerScope) => db.select({ status: projects.status, value: count() }).from(projects).where(eq(projects.ownerUserId, scope.ownerUserId)).groupBy(projects.status).all(),
|
||||
taskStatusCounts: (scope: OwnerScope) => db.select({ status: tasks.status, value: count() }).from(tasks).where(eq(tasks.ownerUserId, scope.ownerUserId)).groupBy(tasks.status).all(),
|
||||
taskStatusCountsInRange: (scope: OwnerScope, startDate: Date, endDate: Date) =>
|
||||
db.select({ status: tasks.status, value: count() }).from(tasks).where(and(eq(tasks.ownerUserId, scope.ownerUserId), gte(tasks.updatedAt, startDate), lte(tasks.updatedAt, endDate))).groupBy(tasks.status).all(),
|
||||
projectIncomeInRange: (scope: OwnerScope, startDate: string, endDate: string) =>
|
||||
db.select({
|
||||
projectId: projects.id,
|
||||
name: projects.name,
|
||||
amountMinor: sql<number>`coalesce(sum(${financeTransactions.amountMinor}), 0)`,
|
||||
})
|
||||
.from(financeTransactions)
|
||||
.innerJoin(projects, eq(financeTransactions.projectId, projects.id))
|
||||
.where(and(
|
||||
eq(financeTransactions.ownerUserId, scope.ownerUserId),
|
||||
eq(projects.ownerUserId, scope.ownerUserId),
|
||||
eq(financeTransactions.type, "income"),
|
||||
eq(financeTransactions.paymentStatus, "paid"),
|
||||
gte(financeTransactions.transactionDate, startDate),
|
||||
lte(financeTransactions.transactionDate, endDate),
|
||||
))
|
||||
.groupBy(projects.id, projects.name)
|
||||
.orderBy(desc(sql`sum(${financeTransactions.amountMinor})`))
|
||||
.all(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import "server-only";
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
export const dashboardRangeSchema = z.enum([
|
||||
"today",
|
||||
"this_week",
|
||||
"this_month",
|
||||
"this_year",
|
||||
]);
|
||||
|
||||
export type DashboardRange = z.infer<typeof dashboardRangeSchema>;
|
||||
|
||||
export function parseDashboardRange(
|
||||
value: string | string[] | undefined,
|
||||
fallback: DashboardRange = "this_month",
|
||||
): DashboardRange {
|
||||
const parsed = dashboardRangeSchema.safeParse(value);
|
||||
return parsed.success ? parsed.data : fallback;
|
||||
}
|
||||
|
||||
export function resolveDashboardRange(range: DashboardRange, now = new Date()) {
|
||||
let startAt: Date;
|
||||
let endAt: Date;
|
||||
|
||||
if (range === "today") {
|
||||
startAt = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
endAt = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59, 999);
|
||||
} else if (range === "this_week") {
|
||||
const mondayOffset = now.getDay() === 0 ? -6 : 1 - now.getDay();
|
||||
startAt = new Date(now.getFullYear(), now.getMonth(), now.getDate() + mondayOffset);
|
||||
endAt = new Date(startAt);
|
||||
endAt.setDate(endAt.getDate() + 6);
|
||||
endAt.setHours(23, 59, 59, 999);
|
||||
} else if (range === "this_year") {
|
||||
startAt = new Date(now.getFullYear(), 0, 1);
|
||||
endAt = new Date(now.getFullYear(), 11, 31, 23, 59, 59, 999);
|
||||
} else {
|
||||
startAt = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
endAt = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59, 999);
|
||||
}
|
||||
|
||||
return {
|
||||
startAt,
|
||||
endAt,
|
||||
startDate: toBusinessDate(startAt),
|
||||
endDate: toBusinessDate(endAt),
|
||||
};
|
||||
}
|
||||
|
||||
function toBusinessDate(date: Date): string {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
+145
-3
@@ -82,6 +82,16 @@ export class DomainService {
|
||||
return this.repositories.clients.createActivity(scope, { ...value, id: value.id ?? this.id() });
|
||||
}
|
||||
|
||||
listClientActivities(actor: DomainActor, clientId: string) {
|
||||
const scope = requireOwnerScope(actor);
|
||||
this.requireOwnedClient(scope, clientId);
|
||||
return this.repositories.clients.listActivities(scope, clientId);
|
||||
}
|
||||
|
||||
listAllClientActivities(actor: DomainActor) {
|
||||
return this.repositories.clients.listAllActivities(requireOwnerScope(actor));
|
||||
}
|
||||
|
||||
listProjects(actor: DomainActor) {
|
||||
if (actor.role === "client") {
|
||||
return this.repositories.projects.listForClient(requireClientScope(actor));
|
||||
@@ -108,7 +118,15 @@ export class DomainService {
|
||||
const current = this.repositories.projects.get(scope, projectId) ?? this.throwNotFound("Proje");
|
||||
const value = parseDomainInput(projectUpdateSchema, input);
|
||||
this.assertProjectClient(scope, value.type ?? current.type, value.clientId === undefined ? current.clientId : value.clientId);
|
||||
return this.repositories.projects.update(scope, projectId, value) ?? this.throwNotFound("Proje");
|
||||
const updated = this.repositories.projects.update(scope, projectId, value) ?? this.throwNotFound("Proje");
|
||||
if (
|
||||
updated.progressType === "auto"
|
||||
&& (value.progressType === "auto" || value.progress !== undefined)
|
||||
) {
|
||||
this.recalculateProjectProgress(scope, projectId);
|
||||
return this.repositories.projects.get(scope, projectId) ?? this.throwNotFound("Proje");
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
deleteProject(actor: DomainActor, id: string) {
|
||||
@@ -118,6 +136,7 @@ export class DomainService {
|
||||
listTasks(actor: DomainActor, projectId?: string) {
|
||||
if (actor.role === "client") {
|
||||
const scope = requireClientScope(actor);
|
||||
this.getClient(actor, scope.clientId);
|
||||
if (projectId) this.getProject(actor, projectId);
|
||||
return this.repositories.tasks.listPublicForClient(scope, projectId);
|
||||
}
|
||||
@@ -216,6 +235,16 @@ export class DomainService {
|
||||
return this.repositories.journal.list(requireOwnerScope(actor));
|
||||
}
|
||||
|
||||
updateJournalEntry(actor: DomainActor, entryId: string, input: unknown) {
|
||||
const scope = requireOwnerScope(actor);
|
||||
const value = parseDomainInput(journalEntrySchema.omit({ id: true }), input);
|
||||
const conflicting = this.repositories.journal.getByDate(scope, value.entryDate);
|
||||
if (conflicting && conflicting.id !== entryId) {
|
||||
throw conflict("Bu tarih için zaten bir günlük kaydı var.");
|
||||
}
|
||||
return this.repositories.journal.update(scope, entryId, value) ?? this.throwNotFound("Günlük kaydı");
|
||||
}
|
||||
|
||||
deleteJournalEntry(actor: DomainActor, entryId: string) {
|
||||
return this.repositories.journal.remove(requireOwnerScope(actor), entryId) ?? this.throwNotFound("Günlük kaydı");
|
||||
}
|
||||
@@ -251,6 +280,7 @@ export class DomainService {
|
||||
|
||||
requestRevision(actor: DomainActor, input: unknown) {
|
||||
const scope = requireClientScope(actor);
|
||||
this.getClient(actor, scope.clientId);
|
||||
const value = parseDomainInput(revisionCreateSchema, input);
|
||||
const revisionId = value.id ?? this.id();
|
||||
|
||||
@@ -273,9 +303,16 @@ export class DomainService {
|
||||
}, { behavior: "immediate" });
|
||||
}
|
||||
|
||||
updateRevisionStatus(actor: DomainActor, revisionId: string, statusInput: unknown) {
|
||||
updateRevisionStatus(actor: DomainActor, revisionId: string, statusInput: unknown, projectId?: string) {
|
||||
const scope = requireOwnerScope(actor);
|
||||
if (projectId) {
|
||||
this.requireOwnedProject(scope, projectId);
|
||||
if (!this.repositories.revisions.list(scope, projectId).some((revision) => revision.id === revisionId)) {
|
||||
throw notFound("Revizyon");
|
||||
}
|
||||
}
|
||||
const status = parseDomainInput(revisionStatusSchema, statusInput);
|
||||
return this.repositories.revisions.updateStatus(requireOwnerScope(actor), revisionId, status) ?? this.throwNotFound("Revizyon");
|
||||
return this.repositories.revisions.updateStatus(scope, revisionId, status) ?? this.throwNotFound("Revizyon");
|
||||
}
|
||||
|
||||
listRevisions(actor: DomainActor, projectId: string) {
|
||||
@@ -289,6 +326,29 @@ export class DomainService {
|
||||
return this.repositories.revisions.list(scope, projectId);
|
||||
}
|
||||
|
||||
listPortalRevisions(actor: DomainActor) {
|
||||
const scope = requireClientScope(actor);
|
||||
this.getClient(actor, scope.clientId);
|
||||
return this.repositories.revisions.listAllForClient(scope);
|
||||
}
|
||||
|
||||
getRevisionAllowance(actor: DomainActor, projectId: string) {
|
||||
const scope = requireClientScope(actor);
|
||||
const project = this.getProject(actor, projectId);
|
||||
const used = this.repositories.revisions
|
||||
.listForClient(scope, projectId)
|
||||
.filter((revision) => revision.status !== "rejected")
|
||||
.length;
|
||||
const remaining = Math.max(project.revisionQuota - used, 0);
|
||||
|
||||
return {
|
||||
quota: project.revisionQuota,
|
||||
used,
|
||||
remaining,
|
||||
canRequest: project.status === "active" && remaining > 0,
|
||||
};
|
||||
}
|
||||
|
||||
createChatSession(actor: DomainActor, input: unknown) {
|
||||
const scope = requireOwnerScope(actor);
|
||||
const value = parseDomainInput(chatSessionCreateSchema, input);
|
||||
@@ -318,6 +378,88 @@ export class DomainService {
|
||||
};
|
||||
}
|
||||
|
||||
getFreelancerDashboard(
|
||||
actor: DomainActor,
|
||||
range: { startDate: string; endDate: string; startAt: Date; endAt: Date },
|
||||
) {
|
||||
const scope = requireOwnerScope(actor);
|
||||
const finance = this.repositories.finance.listInRange(scope, range.startDate, range.endDate);
|
||||
const journal = this.repositories.journal.listInRange(scope, range.startDate, range.endDate);
|
||||
const projectsByStatus = this.repositories.analytics.projectStatusCounts(scope);
|
||||
const tasksByStatus = this.repositories.analytics.taskStatusCountsInRange(
|
||||
scope,
|
||||
range.startAt,
|
||||
range.endAt,
|
||||
);
|
||||
|
||||
const financeByDate = new Map<string, { income: number; expense: number }>();
|
||||
let incomeMinor = 0;
|
||||
let expenseMinor = 0;
|
||||
for (const transaction of finance) {
|
||||
if (transaction.paymentStatus !== "paid") continue;
|
||||
const current = financeByDate.get(transaction.transactionDate) ?? { income: 0, expense: 0 };
|
||||
if (transaction.type === "income") {
|
||||
current.income += transaction.amountMinor;
|
||||
incomeMinor += transaction.amountMinor;
|
||||
} else {
|
||||
current.expense += transaction.amountMinor;
|
||||
expenseMinor += transaction.amountMinor;
|
||||
}
|
||||
financeByDate.set(transaction.transactionDate, current);
|
||||
}
|
||||
|
||||
const moodValues = journal.flatMap((entry) =>
|
||||
entry.moodScore == null ? [] : [entry.moodScore],
|
||||
);
|
||||
|
||||
return {
|
||||
metrics: {
|
||||
netProfit: (incomeMinor - expenseMinor) / 100,
|
||||
activeProjectsCount:
|
||||
projectsByStatus.find((item) => item.status === "active")?.value ?? 0,
|
||||
completedTasksCount:
|
||||
tasksByStatus.find((item) => item.status === "done")?.value ?? 0,
|
||||
avgMood: moodValues.length
|
||||
? (moodValues.reduce((sum, value) => sum + value, 0) / moodValues.length).toFixed(1)
|
||||
: "0.0",
|
||||
financeTrend: Array.from(financeByDate, ([date, value]) => ({
|
||||
date,
|
||||
income: value.income / 100,
|
||||
expense: value.expense / 100,
|
||||
})),
|
||||
moodTrend: journal.map((entry) => ({
|
||||
date: entry.entryDate,
|
||||
mood: entry.moodScore ?? 0,
|
||||
energy: entry.energyScore ?? 0,
|
||||
})),
|
||||
},
|
||||
projects: this.repositories.projects.recent(scope, 5),
|
||||
clients: this.repositories.clients.recent(scope, 5),
|
||||
};
|
||||
}
|
||||
|
||||
getFreelancerAnalytics(
|
||||
actor: DomainActor,
|
||||
range: { startDate: string; endDate: string; startAt: Date; endAt: Date },
|
||||
) {
|
||||
const scope = requireOwnerScope(actor);
|
||||
const tasksByStatus = this.repositories.analytics.taskStatusCountsInRange(
|
||||
scope,
|
||||
range.startAt,
|
||||
range.endAt,
|
||||
);
|
||||
const taskCount = (status: string) =>
|
||||
tasksByStatus.find((item) => item.status === status)?.value ?? 0;
|
||||
|
||||
return {
|
||||
projectIncomeData: this.repositories.analytics
|
||||
.projectIncomeInRange(scope, range.startDate, range.endDate)
|
||||
.map((item) => ({ name: item.name, value: Number(item.amountMinor) / 100 })),
|
||||
completedTasks: taskCount("done"),
|
||||
activeTasks: taskCount("todo") + taskCount("in_progress"),
|
||||
};
|
||||
}
|
||||
|
||||
createProposal(actor: DomainActor, input: unknown) {
|
||||
const scope = requireOwnerScope(actor);
|
||||
const value = parseDomainInput(proposalCreateSchema, input);
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import "server-only";
|
||||
|
||||
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { getServerConfig } from "../config";
|
||||
import { getSqliteConnection } from "../db/client";
|
||||
import { userAiSettings, type AiProvider } from "../db/schema/settings";
|
||||
import { requireOwnerScope, type DomainActor } from "../domain/actor";
|
||||
import { DomainError } from "../domain/errors";
|
||||
|
||||
const inputSchema = z.object({
|
||||
provider: z.enum(["gemini", "openai", "groq", "ollama"]),
|
||||
apiKey: z.string().trim().max(4_096).optional(),
|
||||
});
|
||||
|
||||
export type PublicAiSettings = {
|
||||
provider: AiProvider;
|
||||
hasApiKey: boolean;
|
||||
};
|
||||
|
||||
export function getPublicAiSettings(actor: DomainActor): PublicAiSettings {
|
||||
const scope = requireOwnerScope(actor);
|
||||
const row = getSqliteConnection().db
|
||||
.select()
|
||||
.from(userAiSettings)
|
||||
.where(eq(userAiSettings.ownerUserId, scope.ownerUserId))
|
||||
.get();
|
||||
|
||||
return {
|
||||
provider: row?.provider ?? "gemini",
|
||||
hasApiKey: Boolean(row?.encryptedApiKey),
|
||||
};
|
||||
}
|
||||
|
||||
export function updateAiSettings(actor: DomainActor, input: unknown): PublicAiSettings {
|
||||
const scope = requireOwnerScope(actor);
|
||||
const parsed = inputSchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
throw new DomainError("VALIDATION_ERROR", "Yapay zeka ayarları geçersiz.");
|
||||
}
|
||||
|
||||
const { db } = getSqliteConnection();
|
||||
const current = db
|
||||
.select()
|
||||
.from(userAiSettings)
|
||||
.where(eq(userAiSettings.ownerUserId, scope.ownerUserId))
|
||||
.get();
|
||||
const encryptedApiKey = parsed.data.provider === "ollama"
|
||||
? null
|
||||
: parsed.data.apiKey
|
||||
? encryptSecret(parsed.data.apiKey)
|
||||
: current?.encryptedApiKey ?? null;
|
||||
|
||||
db.insert(userAiSettings)
|
||||
.values({
|
||||
ownerUserId: scope.ownerUserId,
|
||||
provider: parsed.data.provider,
|
||||
model: null,
|
||||
encryptedApiKey,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: userAiSettings.ownerUserId,
|
||||
set: {
|
||||
provider: parsed.data.provider,
|
||||
model: null,
|
||||
encryptedApiKey,
|
||||
updatedAt: sqlNow(),
|
||||
},
|
||||
})
|
||||
.run();
|
||||
|
||||
return { provider: parsed.data.provider, hasApiKey: Boolean(encryptedApiKey) };
|
||||
}
|
||||
|
||||
export function getAiRuntimeSettings(actor: DomainActor): {
|
||||
provider: AiProvider;
|
||||
model: string | null;
|
||||
apiKey: string | null;
|
||||
} {
|
||||
const scope = requireOwnerScope(actor);
|
||||
const row = getSqliteConnection().db
|
||||
.select()
|
||||
.from(userAiSettings)
|
||||
.where(eq(userAiSettings.ownerUserId, scope.ownerUserId))
|
||||
.get();
|
||||
|
||||
return {
|
||||
provider: row?.provider ?? "gemini",
|
||||
model: row?.model ?? null,
|
||||
apiKey: row?.encryptedApiKey ? decryptSecret(row.encryptedApiKey) : null,
|
||||
};
|
||||
}
|
||||
|
||||
function encryptionKey(): Buffer {
|
||||
const secret = getServerConfig().betterAuthSecret
|
||||
?? "neta-development-only-ai-settings-secret";
|
||||
return createHash("sha256").update(`neta:ai-settings:${secret}`).digest();
|
||||
}
|
||||
|
||||
function encryptSecret(value: string): string {
|
||||
const iv = randomBytes(12);
|
||||
const cipher = createCipheriv("aes-256-gcm", encryptionKey(), iv);
|
||||
const ciphertext = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
return `v1.${iv.toString("base64url")}.${tag.toString("base64url")}.${ciphertext.toString("base64url")}`;
|
||||
}
|
||||
|
||||
function decryptSecret(value: string): string {
|
||||
const [version, ivValue, tagValue, ciphertextValue] = value.split(".");
|
||||
if (version !== "v1" || !ivValue || !tagValue || !ciphertextValue) {
|
||||
throw new DomainError("INVARIANT_VIOLATION", "AI secret formatı geçersiz.");
|
||||
}
|
||||
try {
|
||||
const decipher = createDecipheriv(
|
||||
"aes-256-gcm",
|
||||
encryptionKey(),
|
||||
Buffer.from(ivValue, "base64url"),
|
||||
);
|
||||
decipher.setAuthTag(Buffer.from(tagValue, "base64url"));
|
||||
return Buffer.concat([
|
||||
decipher.update(Buffer.from(ciphertextValue, "base64url")),
|
||||
decipher.final(),
|
||||
]).toString("utf8");
|
||||
} catch {
|
||||
throw new DomainError("INVARIANT_VIOLATION", "AI secret çözülemedi.");
|
||||
}
|
||||
}
|
||||
|
||||
function sqlNow(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import "server-only";
|
||||
|
||||
import { DomainError } from "../domain/errors";
|
||||
|
||||
export function cleanText(value: FormDataEntryValue | null): string | null {
|
||||
const text = typeof value === "string" ? value.trim() : "";
|
||||
return text && text !== "__none" ? text : null;
|
||||
}
|
||||
|
||||
export function requiredText(
|
||||
value: FormDataEntryValue | null,
|
||||
message: string,
|
||||
): string {
|
||||
const text = cleanText(value);
|
||||
if (!text) throw new DomainError("VALIDATION_ERROR", message);
|
||||
return text;
|
||||
}
|
||||
|
||||
export function optionalDate(value: FormDataEntryValue | null): Date | null {
|
||||
const text = cleanText(value);
|
||||
if (!text) return null;
|
||||
const date = new Date(text);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
throw new DomainError("VALIDATION_ERROR", "Geçerli bir tarih girilmelidir.");
|
||||
}
|
||||
return date;
|
||||
}
|
||||
|
||||
export function decimalToMinor(value: FormDataEntryValue | null): number | null {
|
||||
const normalized = typeof value === "string" ? value.trim().replace(",", ".") : "";
|
||||
if (!normalized) return null;
|
||||
const amount = Number(normalized);
|
||||
if (!Number.isFinite(amount) || amount < 0) {
|
||||
throw new DomainError("VALIDATION_ERROR", "Tutar sıfır veya daha büyük olmalıdır.");
|
||||
}
|
||||
return Math.round((amount + Number.EPSILON) * 100);
|
||||
}
|
||||
|
||||
export function minorToDecimal(value: number | null | undefined): number | null {
|
||||
return value == null ? null : value / 100;
|
||||
}
|
||||
|
||||
export function dateToIso(value: Date | null | undefined): string | null {
|
||||
return value ? value.toISOString() : null;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import "server-only";
|
||||
|
||||
import { domainActorFromSession } from "../auth/domain-actor";
|
||||
import { requireFreelancer } from "../auth/session";
|
||||
import { getDomainService } from "../services/runtime";
|
||||
|
||||
export async function requireFreelancerBackend() {
|
||||
const context = await requireFreelancer();
|
||||
|
||||
return {
|
||||
context,
|
||||
actor: domainActorFromSession(context),
|
||||
service: getDomainService(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import "server-only";
|
||||
|
||||
import { domainActorFromSession } from "../auth/domain-actor";
|
||||
import { requireClientUser } from "../auth/session";
|
||||
import { getDomainService } from "../services/runtime";
|
||||
|
||||
export async function requirePortalBackend() {
|
||||
const context = await requireClientUser();
|
||||
|
||||
return {
|
||||
context,
|
||||
actor: domainActorFromSession(context),
|
||||
service: getDomainService(),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user