fix: clean dashboard client type issues

This commit is contained in:
poyrazavsever
2026-07-18 20:18:39 +03:00
parent 100b624061
commit 1d318e1ea0
11 changed files with 131 additions and 92 deletions
@@ -120,7 +120,7 @@ export function AnalyticsClient({ data }: AnalyticsClientProps) {
<div className="bg-background border border-border rounded-xl p-3 shadow-lg shadow-black/5"> <div className="bg-background border border-border rounded-xl p-3 shadow-lg shadow-black/5">
<p className="font-medium text-foreground mb-2 text-sm">{label}</p> <p className="font-medium text-foreground mb-2 text-sm">{label}</p>
<div className="space-y-1.5"> <div className="space-y-1.5">
{payload.map((entry: any, index: number) => ( {payload.map((entry, index) => (
<div key={index} className="flex items-center justify-between gap-6 text-xs"> <div key={index} className="flex items-center justify-between gap-6 text-xs">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: entry.color }} /> <div className="w-2 h-2 rounded-full" style={{ backgroundColor: entry.color }} />
@@ -3,7 +3,7 @@
import { useState } from "react"; import { useState } from "react";
import { format } from "date-fns"; import { format } from "date-fns";
import { tr } from "date-fns/locale"; import { tr } from "date-fns/locale";
import { Receipt, Plus, MoreHorizontal, FileEdit, Trash2, Send, Download, CheckCircle2 } from "lucide-react"; import { Plus, MoreHorizontal, FileEdit, Trash2, Send, Download, CheckCircle2 } from "lucide-react";
import { Button, Card, CardContent, Badge } from "poyraz-ui/atoms"; import { Button, Card, CardContent, Badge } from "poyraz-ui/atoms";
import { import {
DropdownMenu, DropdownMenu,
@@ -3,7 +3,7 @@
import { useState } from "react"; import { useState } from "react";
import { format } from "date-fns"; import { format } from "date-fns";
import { tr } from "date-fns/locale"; import { tr } from "date-fns/locale";
import { FileText, Plus, MoreHorizontal, FileEdit, Trash2, Mail, CheckCircle2, XCircle } from "lucide-react"; import { Plus, MoreHorizontal, FileEdit, Trash2, Mail, CheckCircle2, XCircle } from "lucide-react";
import { Button, Card, CardContent, Badge } from "poyraz-ui/atoms"; import { Button, Card, CardContent, Badge } from "poyraz-ui/atoms";
import { import {
DropdownMenu, DropdownMenu,
+21 -32
View File
@@ -1,7 +1,6 @@
"use client"; "use client";
import { import {
archiveClientRecord,
createClientRecord, createClientRecord,
updateClientRecord, updateClientRecord,
updateClientPipelineStage, updateClientPipelineStage,
@@ -28,10 +27,7 @@ import {
toast, toast,
} from "poyraz-ui/molecules"; } from "poyraz-ui/molecules";
import { import {
Archive,
ExternalLink,
Mail, Mail,
PauseCircle,
Pencil, Pencil,
Phone, Phone,
Plus, Plus,
@@ -45,7 +41,6 @@ import Link from "next/link";
import { useState } from "react"; import { useState } from "react";
import { format, isPast, isToday } from "date-fns"; import { format, isPast, isToday } from "date-fns";
import { tr } from "date-fns/locale"; import { tr } from "date-fns/locale";
import { useEffect } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { StatCard } from "@/components/system/stat-card"; import { StatCard } from "@/components/system/stat-card";
@@ -68,19 +63,13 @@ export type ClientListItem = {
client_value_score: number; client_value_score: number;
}; };
const statusLabels = { type ClientPipelineStage = ClientListItem["pipeline_stage"];
active: "Aktif",
paused: "Duraklatıldı",
archived: "Arşivlendi",
};
const statusClasses = { const pipelineStages: Array<{
active: "border-emerald-200 bg-emerald-50 text-emerald-700", id: ClientPipelineStage;
paused: "border-amber-200 bg-amber-50 text-amber-700", label: string;
archived: "border-zinc-200 bg-zinc-50 text-zinc-600", color: string;
}; }> = [
const pipelineStages = [
{ id: "lead", label: "Potansiyel (Lead)", color: "border-slate-200 bg-slate-50 text-slate-700" }, { id: "lead", label: "Potansiyel (Lead)", color: "border-slate-200 bg-slate-50 text-slate-700" },
{ id: "contacted", label: "İletişime Geçildi", color: "border-blue-200 bg-blue-50 text-blue-700" }, { id: "contacted", label: "İletişime Geçildi", color: "border-blue-200 bg-blue-50 text-blue-700" },
{ id: "proposal_sent", label: "Teklif İletildi", color: "border-amber-200 bg-amber-50 text-amber-700" }, { id: "proposal_sent", label: "Teklif İletildi", color: "border-amber-200 bg-amber-50 text-amber-700" },
@@ -92,26 +81,24 @@ type ClientsClientProps = {
clients: ClientListItem[]; clients: ClientListItem[];
totalRevenue: number; totalRevenue: number;
activeCount: number; activeCount: number;
pausedCount: number;
archivedCount: number;
}; };
export function ClientsClient({ export function ClientsClient({
clients, clients,
totalRevenue, totalRevenue,
activeCount, activeCount,
pausedCount,
archivedCount,
}: ClientsClientProps) { }: ClientsClientProps) {
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const normalizedQuery = query.trim().toLowerCase(); const normalizedQuery = query.trim().toLowerCase();
const [draggedClientId, setDraggedClientId] = useState<string | null>(null); const [draggedClientId, setDraggedClientId] = useState<string | null>(null);
const [localClients, setLocalClients] = useState(clients); const [pipelineOverrides, setPipelineOverrides] = useState<
Partial<Record<string, ClientPipelineStage>>
useEffect(() => { >({});
setLocalClients(clients); const localClients = clients.map((client) => ({
}, [clients]); ...client,
pipeline_stage: pipelineOverrides[client.id] ?? client.pipeline_stage,
}));
function handleDragStart(event: React.DragEvent<HTMLDivElement>, clientId: string) { function handleDragStart(event: React.DragEvent<HTMLDivElement>, clientId: string) {
setDraggedClientId(clientId); setDraggedClientId(clientId);
@@ -119,7 +106,7 @@ export function ClientsClient({
event.dataTransfer.setData("text/plain", clientId); event.dataTransfer.setData("text/plain", clientId);
} }
async function handleDrop(newStage: string) { async function handleDrop(newStage: ClientPipelineStage) {
if (!draggedClientId) return; if (!draggedClientId) return;
const clientId = draggedClientId; const clientId = draggedClientId;
@@ -128,15 +115,17 @@ export function ClientsClient({
const client = localClients.find(c => c.id === clientId); const client = localClients.find(c => c.id === clientId);
if (!client || client.pipeline_stage === newStage) return; if (!client || client.pipeline_stage === newStage) return;
setLocalClients(prev => const previousStage = client.pipeline_stage;
prev.map(c => c.id === clientId ? { ...c, pipeline_stage: newStage as any } : c) setPipelineOverrides((current) => ({ ...current, [clientId]: newStage }));
);
try { try {
await updateClientPipelineStage(clientId, newStage as any); await updateClientPipelineStage(clientId, newStage);
toast.success("Müşteri aşaması güncellendi."); toast.success("Müşteri aşaması güncellendi.");
} catch (error) { } catch (error) {
setLocalClients(clients); setPipelineOverrides((current) => ({
...current,
[clientId]: previousStage,
}));
toast.error( toast.error(
error instanceof Error error instanceof Error
? error.message ? error.message
-2
View File
@@ -57,8 +57,6 @@ export default async function ClientsPage() {
clients={clients} clients={clients}
totalRevenue={clients.reduce((sum, client) => sum + client.revenueTotal, 0)} totalRevenue={clients.reduce((sum, client) => sum + client.revenueTotal, 0)}
activeCount={clients.filter((client) => client.status === "active").length} activeCount={clients.filter((client) => client.status === "active").length}
pausedCount={clients.filter((client) => client.status === "paused").length}
archivedCount={clients.filter((client) => client.status === "archived").length}
/> />
); );
} }
+2 -2
View File
@@ -125,14 +125,14 @@ export function DashboardClient({ data }: DashboardClientProps) {
<div className="bg-background border border-border rounded-xl p-3 shadow-lg shadow-black/5"> <div className="bg-background border border-border rounded-xl p-3 shadow-lg shadow-black/5">
<p className="font-medium text-foreground mb-2 text-sm">{label}</p> <p className="font-medium text-foreground mb-2 text-sm">{label}</p>
<div className="space-y-1.5"> <div className="space-y-1.5">
{payload.map((entry: any, index: number) => ( {payload.map((entry, index) => (
<div key={index} className="flex items-center justify-between gap-6 text-xs"> <div key={index} className="flex items-center justify-between gap-6 text-xs">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: entry.color }} /> <div className="w-2 h-2 rounded-full" style={{ backgroundColor: entry.color }} />
<span className="text-muted-foreground">{entry.name === 'income' ? 'Gelir' : 'Gider'}</span> <span className="text-muted-foreground">{entry.name === 'income' ? 'Gelir' : 'Gider'}</span>
</div> </div>
<span className="font-semibold text-foreground"> <span className="font-semibold text-foreground">
{formatCurrency(entry.value)} {formatCurrency(Number(entry.value ?? 0))}
</span> </span>
</div> </div>
))} ))}
+4 -2
View File
@@ -644,8 +644,10 @@ function AIFinanceDialog() {
throw new Error(data.error || "Bilinmeyen bir hata oluştu."); throw new Error(data.error || "Bilinmeyen bir hata oluştu.");
} }
setResult(data.text); setResult(data.text);
} catch (err: any) { } catch (error) {
setResult("Hata: " + err.message); setResult(
`Hata: ${error instanceof Error ? error.message : "Bilinmeyen bir hata oluştu."}`,
);
} finally { } finally {
setLoading(false); setLoading(false);
} }
+2 -1
View File
@@ -5,6 +5,7 @@ import {
type ProjectDetailTaskItem, type ProjectDetailTaskItem,
type ProjectFinanceItem, type ProjectFinanceItem,
type ProjectPlanningSectionItem, type ProjectPlanningSectionItem,
type ProjectRevisionItem,
} from "@/app/(dashboard)/projects/[id]/project-detail-client"; } from "@/app/(dashboard)/projects/[id]/project-detail-client";
import { DomainError } from "@/server/domain/errors"; import { DomainError } from "@/server/domain/errors";
import { requireFreelancerBackend } from "@/server/web/freelancer"; import { requireFreelancerBackend } from "@/server/web/freelancer";
@@ -18,7 +19,7 @@ export default async function ProjectDetailPage({ params }: { params: Promise<{
sections: ProjectPlanningSectionItem[]; sections: ProjectPlanningSectionItem[];
tasks: ProjectDetailTaskItem[]; tasks: ProjectDetailTaskItem[];
financeTransactions: ProjectFinanceItem[]; financeTransactions: ProjectFinanceItem[];
revisions: Array<Record<string, unknown>>; revisions: ProjectRevisionItem[];
}; };
try { try {
const row = service.getProject(actor, id); const row = service.getProject(actor, id);
@@ -46,7 +46,8 @@ import {
Trash2, Trash2,
Wallet, Wallet,
} from "lucide-react"; } from "lucide-react";
import { useEffect, useState, useTransition, type DragEvent } from "react"; import Image from "next/image";
import { useState, useTransition, type DragEvent } from "react";
export type ProjectDetail = { export type ProjectDetail = {
id: string; id: string;
@@ -105,12 +106,20 @@ export type ProjectFinanceItem = {
category: string | null; category: string | null;
}; };
export type ProjectRevisionItem = {
id: string;
description: string;
status: "pending" | "in_progress" | "completed" | "rejected";
created_at: string;
requested_by: string;
};
type ProjectDetailClientProps = { type ProjectDetailClientProps = {
project: ProjectDetail; project: ProjectDetail;
sections: ProjectPlanningSectionItem[]; sections: ProjectPlanningSectionItem[];
tasks: ProjectDetailTaskItem[]; tasks: ProjectDetailTaskItem[];
financeTransactions: ProjectFinanceItem[]; financeTransactions: ProjectFinanceItem[];
revisions: any[]; revisions: ProjectRevisionItem[];
}; };
const typeLabels = { const typeLabels = {
@@ -239,11 +248,14 @@ export function ProjectDetailClient({
<Card> <Card>
<CardContent className="p-0"> <CardContent className="p-0">
{project.coverImageUrl ? ( {project.coverImageUrl ? (
<div className="aspect-[16/7] overflow-hidden rounded-t-sm border-b border-border bg-muted"> <div className="relative aspect-[16/7] overflow-hidden rounded-t-sm border-b border-border bg-muted">
<img <Image
src={project.coverImageUrl} src={project.coverImageUrl}
alt={project.cover_image_alt || project.name} alt={project.cover_image_alt || project.name}
className="h-full w-full object-cover" fill
sizes="(min-width: 1024px) 60vw, 100vw"
unoptimized
className="object-cover"
/> />
</div> </div>
) : ( ) : (
@@ -339,16 +351,29 @@ export function ProjectDetailClient({
); );
} }
function RevisionsPanel({ projectId, revisions }: { projectId: string; revisions: any[] }) { function RevisionsPanel({
projectId,
revisions,
}: {
projectId: string;
revisions: ProjectRevisionItem[];
}) {
const [isUpdating, setIsUpdating] = useState(false); const [isUpdating, setIsUpdating] = useState(false);
async function handleStatusChange(id: string, status: string) { async function handleStatusChange(
id: string,
status: ProjectRevisionItem["status"],
) {
setIsUpdating(true); setIsUpdating(true);
try { try {
const { updateRevisionStatus } = await import("@/app/(dashboard)/projects/actions"); const { updateRevisionStatus } = await import("@/app/(dashboard)/projects/actions");
await updateRevisionStatus(id, projectId, status); await updateRevisionStatus(id, projectId, status);
} catch (err: any) { } catch (error) {
console.error(err); toast.error(
error instanceof Error
? error.message
: "Revizyon durumu güncellenemedi.",
);
} finally { } finally {
setIsUpdating(false); setIsUpdating(false);
} }
@@ -370,7 +395,12 @@ function RevisionsPanel({ projectId, revisions }: { projectId: string; revisions
</div> </div>
<Select <Select
defaultValue={rev.status} defaultValue={rev.status}
onValueChange={(val) => handleStatusChange(rev.id, val)} onValueChange={(value) =>
handleStatusChange(
rev.id,
value as ProjectRevisionItem["status"],
)
}
disabled={isUpdating} disabled={isUpdating}
> >
<SelectTrigger className="w-40 h-8 text-xs"> <SelectTrigger className="w-40 h-8 text-xs">
@@ -591,26 +621,31 @@ function TaskPanel({
tasks: ProjectDetailTaskItem[]; tasks: ProjectDetailTaskItem[];
}) { }) {
const [view, setView] = useState<"list" | "kanban">("list"); const [view, setView] = useState<"list" | "kanban">("list");
const [localTasks, setLocalTasks] = useState(tasks); const [statusOverrides, setStatusOverrides] = useState<
Partial<Record<string, ProjectDetailTaskItem["status"]>>
>({});
const [pendingTaskIds, setPendingTaskIds] = useState<Set<string>>(new Set()); const [pendingTaskIds, setPendingTaskIds] = useState<Set<string>>(new Set());
const [, startTransition] = useTransition(); const [, startTransition] = useTransition();
const localTasks = tasks.map((task) => ({
useEffect(() => { ...task,
setLocalTasks(tasks); status: statusOverrides[task.id] ?? task.status,
}, [tasks]); }));
function handleTaskStatusChange(taskId: string, status: ProjectDetailTaskItem["status"]) { function handleTaskStatusChange(taskId: string, status: ProjectDetailTaskItem["status"]) {
const previousTasks = localTasks; const previousStatus = localTasks.find((task) => task.id === taskId)?.status;
setPendingTask(taskId, true); setPendingTask(taskId, true);
setLocalTasks((currentTasks) => setStatusOverrides((current) => ({ ...current, [taskId]: status }));
currentTasks.map((task) => (task.id === taskId ? { ...task, status } : task)),
);
startTransition(() => { startTransition(() => {
void updateTaskStatusRecord(taskId, status, projectId) void updateTaskStatusRecord(taskId, status, projectId)
.catch((error) => { .catch((error) => {
setLocalTasks(previousTasks); setStatusOverrides((current) => {
const next = { ...current };
if (previousStatus) next[taskId] = previousStatus;
else delete next[taskId];
return next;
});
toast.error( toast.error(
error instanceof Error error instanceof Error
? error.message ? error.message
@@ -924,7 +959,7 @@ function ProjectSettingsDialog({ project }: { project: ProjectDetail }) {
</div> </div>
)} )}
{progressType === "auto" && ( {progressType === "auto" && (
<p className="text-xs text-muted-foreground">İlerleme yüzdesi "Görevler" sekmesindeki görevlerin tamamlanma durumuna göre otomatik hesaplanacaktır.</p> <p className="text-xs text-muted-foreground">İlerleme yüzdesi &quot;Görevler&quot; sekmesindeki görevlerin tamamlanma durumuna göre otomatik hesaplanacaktır.</p>
)} )}
<div className="grid gap-2"> <div className="grid gap-2">
+18 -15
View File
@@ -38,8 +38,9 @@ import {
Brain, Brain,
Loader2, Loader2,
} from "lucide-react"; } from "lucide-react";
import { usePathname, useRouter } from "next/navigation"; import Image from "next/image";
import { useEffect, useState, type ChangeEvent } from "react"; import { useRouter } from "next/navigation";
import { useEffect, useState, useTransition, type ChangeEvent } from "react";
import { StatCard } from "@/components/system/stat-card"; import { StatCard } from "@/components/system/stat-card";
export type ProjectClientOption = { export type ProjectClientOption = {
@@ -215,17 +216,13 @@ function ProjectCard({
clients: ProjectClientOption[]; clients: ProjectClientOption[];
}) { }) {
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const [isNavigating, startNavigation] = useTransition();
const [isNavigating, setIsNavigating] = useState(false);
const detailHref = `/projects/${project.id}`; const detailHref = `/projects/${project.id}`;
useEffect(() => {
setIsNavigating(false);
}, [pathname]);
function goToProjectDetail() { function goToProjectDetail() {
setIsNavigating(true); startNavigation(() => {
router.push(detailHref); router.push(detailHref);
});
} }
function prefetchProjectDetail() { function prefetchProjectDetail() {
@@ -287,11 +284,14 @@ function ProjectCard({
function ProjectCover({ project }: { project: ProjectListItem }) { function ProjectCover({ project }: { project: ProjectListItem }) {
if (project.coverImageUrl) { if (project.coverImageUrl) {
return ( return (
<div className="aspect-video overflow-hidden rounded-sm border border-border bg-muted"> <div className="relative aspect-video overflow-hidden rounded-sm border border-border bg-muted">
<img <Image
src={project.coverImageUrl} src={project.coverImageUrl}
alt={project.cover_image_alt || project.name} alt={project.cover_image_alt || project.name}
className="h-full w-full object-cover" fill
sizes="(min-width: 1280px) 30vw, (min-width: 768px) 45vw, 100vw"
unoptimized
className="object-cover"
/> />
</div> </div>
); );
@@ -515,10 +515,13 @@ function CoverImageInput({ project }: { project?: ProjectListItem }) {
className="group relative flex aspect-16/7 cursor-pointer items-center justify-center overflow-hidden rounded-sm border border-dashed border-border bg-muted/20 transition-colors hover:border-primary/50 hover:bg-primary/5" className="group relative flex aspect-16/7 cursor-pointer items-center justify-center overflow-hidden rounded-sm border border-dashed border-border bg-muted/20 transition-colors hover:border-primary/50 hover:bg-primary/5"
> >
{previewUrl ? ( {previewUrl ? (
<img <Image
src={previewUrl} src={previewUrl}
alt={project?.cover_image_alt || project?.name || "Proje kapak görseli önizlemesi"} alt={project?.cover_image_alt || project?.name || "Proje kapak görseli önizlemesi"}
className="h-full w-full object-cover" fill
sizes="(min-width: 640px) 640px, 100vw"
unoptimized
className="object-cover"
/> />
) : ( ) : (
<div className="flex flex-col items-center gap-3 text-muted-foreground transition-colors group-hover:text-primary"> <div className="flex flex-col items-center gap-3 text-muted-foreground transition-colors group-hover:text-primary">
+25 -14
View File
@@ -31,7 +31,7 @@ import {
Plus, Plus,
Trash2, Trash2,
} from "lucide-react"; } from "lucide-react";
import { useEffect, useState, useTransition, type DragEvent } from "react"; import { useState, useTransition, type DragEvent } from "react";
export type TaskRelationOption = { export type TaskRelationOption = {
id: string; id: string;
@@ -82,29 +82,37 @@ type TasksClientProps = {
}; };
export function TasksClient({ tasks, clients, projects }: TasksClientProps) { export function TasksClient({ tasks, clients, projects }: TasksClientProps) {
const [localTasks, setLocalTasks] = useState(tasks); const [statusOverrides, setStatusOverrides] = useState<
Partial<Record<string, TaskListItem["status"]>>
>({});
const [deletedTaskIds, setDeletedTaskIds] = useState<Set<string>>(new Set());
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [projectFilter, setProjectFilter] = useState("__all"); const [projectFilter, setProjectFilter] = useState("__all");
const [view, setView] = useState<"list" | "kanban">("list"); const [view, setView] = useState<"list" | "kanban">("list");
const [pendingTaskIds, setPendingTaskIds] = useState<Set<string>>(new Set()); const [pendingTaskIds, setPendingTaskIds] = useState<Set<string>>(new Set());
const [, startTransition] = useTransition(); const [, startTransition] = useTransition();
const localTasks = tasks
useEffect(() => { .filter((task) => !deletedTaskIds.has(task.id))
setLocalTasks(tasks); .map((task) => ({
}, [tasks]); ...task,
status: statusOverrides[task.id] ?? task.status,
}));
function handleTaskStatusChange(taskId: string, status: TaskListItem["status"]) { function handleTaskStatusChange(taskId: string, status: TaskListItem["status"]) {
const previousTasks = localTasks; const previousStatus = localTasks.find((task) => task.id === taskId)?.status;
setPendingTask(taskId, true); setPendingTask(taskId, true);
setLocalTasks((currentTasks) => setStatusOverrides((current) => ({ ...current, [taskId]: status }));
currentTasks.map((task) => (task.id === taskId ? { ...task, status } : task)),
);
startTransition(() => { startTransition(() => {
void updateTaskStatusRecord(taskId, status) void updateTaskStatusRecord(taskId, status)
.catch((error) => { .catch((error) => {
setLocalTasks(previousTasks); setStatusOverrides((current) => {
const next = { ...current };
if (previousStatus) next[taskId] = previousStatus;
else delete next[taskId];
return next;
});
toast.error( toast.error(
error instanceof Error error instanceof Error
? error.message ? error.message
@@ -118,7 +126,6 @@ export function TasksClient({ tasks, clients, projects }: TasksClientProps) {
} }
function handleTaskDelete(taskId: string) { function handleTaskDelete(taskId: string) {
const previousTasks = localTasks;
const task = localTasks.find((item) => item.id === taskId); const task = localTasks.find((item) => item.id === taskId);
const formData = new FormData(); const formData = new FormData();
formData.set("id", taskId); formData.set("id", taskId);
@@ -128,12 +135,16 @@ export function TasksClient({ tasks, clients, projects }: TasksClientProps) {
} }
setPendingTask(taskId, true); setPendingTask(taskId, true);
setLocalTasks((currentTasks) => currentTasks.filter((item) => item.id !== taskId)); setDeletedTaskIds((current) => new Set(current).add(taskId));
startTransition(() => { startTransition(() => {
void deleteTaskRecord(formData) void deleteTaskRecord(formData)
.catch((error) => { .catch((error) => {
setLocalTasks(previousTasks); setDeletedTaskIds((current) => {
const next = new Set(current);
next.delete(taskId);
return next;
});
toast.error( toast.error(
error instanceof Error ? error.message : "Görev silinemedi.", error instanceof Error ? error.message : "Görev silinemedi.",
); );