feat(tasks): implement task management actions and UI components
- Added actions for creating, updating, completing, and deleting tasks in `actions.ts`. - Refactored `TasksPage` to fetch tasks from Supabase and display them using the new `TasksClient` component. - Created `tasks-client.tsx` to handle task listing and Kanban view with filtering capabilities. - Introduced a dialog for task creation and editing with form validation. - Enhanced task display with status and priority badges, and added overdue checks.
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
"use server";
|
||||
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
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 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) {
|
||||
return {
|
||||
title: cleanText(formData.get("title")),
|
||||
description: cleanText(formData.get("description")),
|
||||
status: readStatus(formData.get("status")),
|
||||
priority: readPriority(formData.get("priority")),
|
||||
client_id: cleanText(formData.get("client_id")),
|
||||
project_id: cleanText(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")),
|
||||
};
|
||||
}
|
||||
|
||||
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}`);
|
||||
}
|
||||
|
||||
revalidatePath("/tasks");
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
export async function completeTaskRecord(formData: FormData) {
|
||||
const { supabase, userId } = await getCurrentUserId();
|
||||
const id = cleanText(formData.get("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");
|
||||
}
|
||||
|
||||
export async function deleteTaskRecord(formData: FormData) {
|
||||
const { supabase, userId } = await getCurrentUserId();
|
||||
const id = cleanText(formData.get("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");
|
||||
}
|
||||
+84
-271
@@ -1,280 +1,93 @@
|
||||
"use client";
|
||||
import {
|
||||
TasksClient,
|
||||
type TaskListItem,
|
||||
type TaskRelationOption,
|
||||
} from "@/app/(dashboard)/tasks/tasks-client";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Plus, Search, Filter, Brain, CheckSquare2, Clock, AlertCircle,
|
||||
MoreHorizontal, MessageSquare, ArrowUpRight, CheckCircle2,
|
||||
GripVertical, List, Layout, X, Calendar, User, Tag
|
||||
} from "lucide-react";
|
||||
import { motion, AnimatePresence, Reorder } from "framer-motion";
|
||||
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;
|
||||
};
|
||||
|
||||
// Mock Data
|
||||
const initialTasks = [
|
||||
{ id: "1", title: "Finalize Q3 Marketing Assets", project: "Marketing Site", priority: "High", status: "In Progress", assignee: "Sarah J.", aiPredict: "2 days", dueDate: "May 15" },
|
||||
{ id: "2", title: "Database Migration Script", project: "Infrastructure", priority: "Critical", status: "Review", assignee: "Alex R.", aiPredict: "4 hours", dueDate: "May 12" },
|
||||
{ id: "3", title: "Design System Tokens", project: "Cognis Mobile", priority: "Medium", status: "To Do", assignee: "Mike C.", aiPredict: "3 days", dueDate: "May 20" },
|
||||
{ id: "4", title: "Client Interview Synthesis", project: "Research", priority: "Low", status: "Done", assignee: "Emma W.", aiPredict: "Completed", dueDate: "May 08" },
|
||||
{ id: "5", title: "Optimize Webpack Config", project: "Infrastructure", priority: "Medium", status: "To Do", assignee: "Alex R.", aiPredict: "1 day", dueDate: "May 18" }
|
||||
];
|
||||
export default async function TasksPage() {
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
const columns = ["To Do", "In Progress", "Review", "Done"];
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function TasksPage() {
|
||||
const [viewMode, setViewMode] = useState<"list" | "kanban">("list");
|
||||
const [tasks, setTasks] = useState(initialTasks);
|
||||
const [isSorting, setIsSorting] = useState(false);
|
||||
const [selectedTask, setSelectedTask] = useState<any>(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 handleAutoSort = () => {
|
||||
setIsSorting(true);
|
||||
setTimeout(() => {
|
||||
const sorted = [...tasks].sort((a, b) => {
|
||||
const priorityOrder: any = { Critical: 0, High: 1, Medium: 2, Low: 3 };
|
||||
return priorityOrder[a.priority] - priorityOrder[b.priority];
|
||||
});
|
||||
setTasks(sorted);
|
||||
setIsSorting(false);
|
||||
}, 1500);
|
||||
};
|
||||
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,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl animate-in fade-in slide-in-from-bottom-4 duration-500 h-full flex flex-col text-foreground font-sans space-y-6 pb-12 relative">
|
||||
|
||||
{/* Top Header */}
|
||||
<div className="flex items-center justify-between pb-4 border-b border-white/5 mt-4 shrink-0">
|
||||
<h1 className="text-lg font-medium text-muted-foreground">
|
||||
<span className="text-foreground">Management</span> / Tasks
|
||||
</h1>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-[#150F1D] border border-white/5 rounded-sm px-3 py-1.5 flex items-center gap-2">
|
||||
<Search className="h-4 w-4 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search tasks..."
|
||||
className="bg-transparent border-none outline-none text-xs w-48 placeholder:text-muted-foreground/50 text-foreground"
|
||||
/>
|
||||
</div>
|
||||
<button className="bg-primary hover:bg-primary/90 text-primary-foreground border border-primary/20 px-4 py-1.5 rounded-sm text-xs font-semibold flex items-center gap-2 transition-all active:scale-95 shadow-lg shadow-primary/20">
|
||||
<Plus className="h-4 w-4" />
|
||||
NEW TASK
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AI Task Prioritization Card */}
|
||||
<div className="rounded-sm border border-primary/20 bg-primary/5 p-6 flex flex-col md:flex-row items-start md:items-center justify-between gap-6 relative overflow-hidden group">
|
||||
<div className="absolute -right-4 -bottom-4 opacity-5 rotate-12 transition-transform group-hover:scale-110">
|
||||
<Brain className="h-24 w-24 text-primary" />
|
||||
</div>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="p-3 bg-primary/10 rounded-sm shrink-0">
|
||||
<Brain className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-primary mb-1 flex items-center gap-2">
|
||||
AI Priority Engine
|
||||
</h3>
|
||||
<p className="text-xs text-foreground/80 leading-relaxed max-w-2xl">
|
||||
Based on deadlines and team velocity, I've identified 3 tasks that need immediate focus. Auto-sort will reorder your backlog for maximum strategic impact.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleAutoSort}
|
||||
disabled={isSorting}
|
||||
className={`min-w-[140px] px-4 py-2 rounded-sm text-xs font-bold uppercase tracking-widest transition-all shadow-lg ${isSorting ? 'bg-emerald-500/20 text-emerald-400 border border-emerald-500/30' : 'bg-primary/10 text-primary border border-primary/30 hover:bg-primary/20'}`}
|
||||
>
|
||||
{isSorting ? "SORTING..." : "AUTO-SORT BACKLOG"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* View Toggle */}
|
||||
<div className="flex items-center justify-between shrink-0">
|
||||
<div className="flex items-center gap-2 bg-[#150F1D] border border-white/5 rounded-sm p-0.5">
|
||||
<button
|
||||
onClick={() => setViewMode("list")}
|
||||
className={`px-3 py-1.5 text-[10px] font-bold uppercase tracking-widest rounded-sm transition-all flex items-center gap-2 ${viewMode === 'list' ? 'bg-[#2B2538] text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'}`}
|
||||
>
|
||||
<List className="h-3 w-3" /> List View
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode("kanban")}
|
||||
className={`px-3 py-1.5 text-[10px] font-bold uppercase tracking-widest rounded-sm transition-all flex items-center gap-2 ${viewMode === 'kanban' ? 'bg-[#2B2538] text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'}`}
|
||||
>
|
||||
<Layout className="h-3 w-3" /> Kanban Board
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{tasks.length} Active Tasks
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
{viewMode === "list" ? (
|
||||
<div className="bg-[#0A0710] rounded-sm border border-white/5 overflow-hidden flex flex-col h-full">
|
||||
<div className="grid grid-cols-12 gap-4 p-4 border-b border-white/5 bg-[#0F0B15]/50 text-[10px] font-bold uppercase tracking-widest text-muted-foreground shrink-0">
|
||||
<div className="col-span-5">Task Details</div>
|
||||
<div className="col-span-2">Status</div>
|
||||
<div className="col-span-2">Assignee</div>
|
||||
<div className="col-span-2">AI Time Est.</div>
|
||||
<div className="col-span-1 text-right">Actions</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto tiny-scrollbar">
|
||||
<Reorder.Group axis="y" values={tasks} onReorder={setTasks} className="divide-y divide-white/5">
|
||||
{tasks.map((task) => (
|
||||
<Reorder.Item
|
||||
key={task.id}
|
||||
value={task}
|
||||
onClick={() => setSelectedTask(task)}
|
||||
className="grid grid-cols-12 gap-4 p-4 items-center hover:bg-white/[0.02] transition-colors group cursor-pointer"
|
||||
>
|
||||
<div className="col-span-5 flex items-start gap-3">
|
||||
<GripVertical className="h-4 w-4 text-muted-foreground/30 mt-1 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={`text-sm font-medium mb-1 truncate ${task.status === 'Done' ? 'text-muted-foreground line-through' : 'text-foreground'}`}>
|
||||
{task.title}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] text-muted-foreground font-bold bg-[#150F1D] px-2 py-0.5 rounded-sm border border-white/5">{task.project}</span>
|
||||
<span className={`text-[10px] uppercase font-black tracking-widest flex items-center gap-1 ${task.priority === 'Critical' ? 'text-red-400' : task.priority === 'High' ? 'text-orange-400' : 'text-blue-400'}`}>
|
||||
{task.priority}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<span className={`text-[9px] uppercase font-black px-2 py-1 rounded-sm border inline-block tracking-widest ${task.status === 'In Progress' ? 'bg-blue-500/10 text-blue-400 border-blue-500/20' : task.status === 'Review' ? 'bg-orange-500/10 text-orange-400 border-orange-500/20' : task.status === 'Done' ? 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20' : 'bg-white/5 text-muted-foreground border-white/10'}`}>
|
||||
{task.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="col-span-2 flex items-center gap-2">
|
||||
<div className="h-6 w-6 rounded-sm bg-[#1F172B] border border-primary/20 flex items-center justify-center text-[10px] font-black text-primary uppercase">
|
||||
{task.assignee.split(' ').map(n => n[0]).join('')}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground truncate">{task.assignee}</span>
|
||||
</div>
|
||||
<div className="col-span-2 flex items-center gap-2">
|
||||
<Clock className="h-3.5 w-3.5 text-primary opacity-50" />
|
||||
<span className="text-xs text-muted-foreground">{task.aiPredict}</span>
|
||||
</div>
|
||||
<div className="col-span-1 flex justify-end gap-1">
|
||||
<button className="p-1.5 hover:bg-white/10 rounded-sm text-muted-foreground transition-colors"><MoreHorizontal className="h-4 w-4" /></button>
|
||||
</div>
|
||||
</Reorder.Item>
|
||||
))}
|
||||
</Reorder.Group>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-4 gap-6 h-full overflow-x-auto pb-4 tiny-scrollbar">
|
||||
{columns.map((col) => (
|
||||
<div key={col} className="flex flex-col gap-4 min-w-[280px]">
|
||||
<div className="flex items-center justify-between px-2">
|
||||
<h3 className="text-[10px] font-black uppercase tracking-[0.2em] text-muted-foreground">{col}</h3>
|
||||
<span className="text-[10px] font-bold bg-white/5 px-2 py-0.5 rounded-sm">{tasks.filter(t => t.status === col).length}</span>
|
||||
</div>
|
||||
<div className="flex-1 bg-white/[0.02] border border-white/5 rounded-sm p-3 space-y-3 overflow-y-auto tiny-scrollbar">
|
||||
{tasks.filter(t => t.status === col).map(task => (
|
||||
<motion.div
|
||||
key={task.id}
|
||||
layoutId={task.id}
|
||||
onClick={() => setSelectedTask(task)}
|
||||
className="bg-[#0A0710] border border-white/5 rounded-sm p-4 hover:border-primary/30 transition-all cursor-pointer group shadow-xl"
|
||||
>
|
||||
<div className="text-xs font-bold mb-3 group-hover:text-primary transition-colors leading-relaxed">{task.title}</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className={`text-[8px] font-black uppercase tracking-widest px-1.5 py-0.5 rounded-sm border ${task.priority === 'Critical' ? 'bg-red-500/10 text-red-400 border-red-500/20' : 'bg-white/5 text-muted-foreground border-white/10'}`}>
|
||||
{task.priority}
|
||||
</span>
|
||||
<div className="h-5 w-5 rounded-sm bg-[#1F172B] border border-white/10 flex items-center justify-center text-[8px] font-black text-primary">
|
||||
{task.assignee.split(' ').map(n => n[0]).join('')}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
<button className="w-full py-2 border border-dashed border-white/10 rounded-sm text-[10px] font-bold text-muted-foreground hover:bg-white/5 hover:border-white/20 transition-all">
|
||||
+ ADD TASK
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Task Detail Sheet */}
|
||||
<AnimatePresence>
|
||||
{selectedTask && (
|
||||
<>
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} onClick={() => setSelectedTask(null)} className="fixed inset-0 bg-black/60 backdrop-blur-sm z-[100]" />
|
||||
<motion.div initial={{ x: "100%" }} animate={{ x: 0 }} exit={{ x: "100%" }} transition={{ type: "spring", damping: 25, stiffness: 200 }} className="fixed top-0 right-0 h-full w-full max-w-lg bg-[#0A0710] border-l border-white/5 z-[101] shadow-2xl flex flex-col">
|
||||
<div className="p-8 border-b border-white/5 flex items-center justify-between bg-[#0F0B15]/50">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] font-black uppercase tracking-widest bg-primary/20 text-primary px-2 py-0.5 rounded-sm">{selectedTask.project}</span>
|
||||
<span className="text-[10px] font-bold text-muted-foreground">ID: #TSK-{selectedTask.id}</span>
|
||||
</div>
|
||||
<h2 className="text-xl font-black tracking-tight">{selectedTask.title}</h2>
|
||||
</div>
|
||||
<button onClick={() => setSelectedTask(null)} className="p-2 hover:bg-white/5 rounded-sm text-muted-foreground transition-colors"><X className="h-5 w-5" /></button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-8 space-y-10 tiny-scrollbar">
|
||||
<div className="grid grid-cols-2 gap-8">
|
||||
<DetailItem label="ASSIGNEE" icon={User} value={selectedTask.assignee} />
|
||||
<DetailItem label="DUE DATE" icon={Calendar} value={selectedTask.dueDate} />
|
||||
<DetailItem label="PRIORITY" icon={AlertCircle} value={selectedTask.priority} />
|
||||
<DetailItem label="AI ESTIMATE" icon={Brain} value={selectedTask.aiPredict} color="text-primary" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-[10px] font-black uppercase tracking-[0.2em] text-primary">Description</h3>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
This task is part of the strategic {selectedTask.project} roadmap. Please ensure all design tokens are verified against the core Cognis design system before final review.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 pt-8 border-t border-white/5">
|
||||
<h3 className="text-[10px] font-black uppercase tracking-[0.2em] text-primary">Subtasks</h3>
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3].map(i => (
|
||||
<div key={i} className="flex items-center gap-3 p-3 rounded-sm bg-[#150F1D] border border-white/5 hover:border-white/10 transition-colors">
|
||||
<div className="w-4 h-4 rounded-sm border border-white/20 flex items-center justify-center cursor-pointer hover:border-primary transition-colors">
|
||||
<CheckSquare2 className="h-3 w-3 text-transparent hover:text-muted-foreground transition-colors" />
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">Verification step {i} for the migration script.</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-8 border-t border-white/5 bg-[#0F0B15]/50 flex gap-4">
|
||||
<button className="flex-1 bg-primary hover:bg-primary/90 text-primary-foreground py-3 rounded-sm text-[10px] font-black uppercase tracking-widest shadow-lg shadow-primary/20 transition-all active:scale-[0.98]">
|
||||
MARK AS DONE
|
||||
</button>
|
||||
<button className="p-3 border border-white/10 rounded-sm text-muted-foreground hover:bg-white/5 transition-colors">
|
||||
<MessageSquare className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
</div>
|
||||
);
|
||||
return <TasksClient tasks={tasks} clients={clients} projects={projects} />;
|
||||
}
|
||||
|
||||
function DetailItem({ label, icon: Icon, value, color = "text-muted-foreground" }: any) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="text-[10px] font-black uppercase tracking-widest text-muted-foreground/50">{label}</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className={`h-4 w-4 ${color}`} />
|
||||
<span className={`text-sm font-bold ${color === 'text-primary' ? 'text-primary' : 'text-foreground'}`}>{value}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
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";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,567 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
completeTaskRecord,
|
||||
createTaskRecord,
|
||||
deleteTaskRecord,
|
||||
updateTaskRecord,
|
||||
} from "@/app/(dashboard)/tasks/actions";
|
||||
import { Badge, Button, Card, CardContent, Input, Label, Textarea } from "poyraz-ui/atoms";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "poyraz-ui/molecules";
|
||||
import {
|
||||
CalendarDays,
|
||||
CheckCircle2,
|
||||
KanbanSquare,
|
||||
LayoutList,
|
||||
Pencil,
|
||||
Plus,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
export type TaskRelationOption = {
|
||||
id: string;
|
||||
name: string;
|
||||
client_id?: string | null;
|
||||
};
|
||||
|
||||
export type TaskListItem = {
|
||||
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;
|
||||
clientName: string | null;
|
||||
project_id: string | null;
|
||||
projectName: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
const statusLabels = {
|
||||
todo: "Yapılacak",
|
||||
in_progress: "Devam ediyor",
|
||||
done: "Tamamlandı",
|
||||
};
|
||||
|
||||
const priorityLabels = {
|
||||
low: "Düşük",
|
||||
medium: "Orta",
|
||||
high: "Yüksek",
|
||||
urgent: "Acil",
|
||||
};
|
||||
|
||||
const priorityClasses = {
|
||||
low: "border-zinc-200 bg-zinc-50 text-zinc-700",
|
||||
medium: "border-blue-200 bg-blue-50 text-blue-700",
|
||||
high: "border-amber-200 bg-amber-50 text-amber-700",
|
||||
urgent: "border-rose-200 bg-rose-50 text-rose-700",
|
||||
};
|
||||
|
||||
type TasksClientProps = {
|
||||
tasks: TaskListItem[];
|
||||
clients: TaskRelationOption[];
|
||||
projects: TaskRelationOption[];
|
||||
};
|
||||
|
||||
export function TasksClient({ tasks, clients, projects }: TasksClientProps) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [view, setView] = useState<"list" | "kanban">("list");
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
const filteredTasks = normalizedQuery
|
||||
? tasks.filter((task) =>
|
||||
[task.title, task.description, task.clientName, task.projectName]
|
||||
.filter(Boolean)
|
||||
.some((value) => value!.toLowerCase().includes(normalizedQuery)),
|
||||
)
|
||||
: tasks;
|
||||
|
||||
const doneCount = tasks.filter((task) => task.status === "done").length;
|
||||
const overdueCount = tasks.filter((task) => isOverdue(task)).length;
|
||||
const urgentCount = tasks.filter((task) => task.priority === "urgent").length;
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex max-w-7xl flex-col gap-6">
|
||||
<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">
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
Günlük operasyon
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-semibold tracking-normal text-foreground">
|
||||
Görevler
|
||||
</h1>
|
||||
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
|
||||
Proje ve müşteri bağlantılı işleri liste veya basit kanban ile takip et.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TaskDialog mode="create" clients={clients} projects={projects} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-4">
|
||||
<StatCard label="Toplam görev" value={tasks.length.toString()} />
|
||||
<StatCard label="Tamamlanan" value={doneCount.toString()} />
|
||||
<StatCard label="Geciken" value={overdueCount.toString()} />
|
||||
<StatCard label="Acil" value={urgentCount.toString()} />
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="space-y-4 p-4">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-foreground">Görev listesi</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{filteredTasks.length} kayıt görüntüleniyor.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Görev, proje veya müşteri ara"
|
||||
className="sm:w-80"
|
||||
/>
|
||||
<div className="flex rounded-sm border border-border p-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant={view === "list" ? "default" : "ghost"}
|
||||
className="h-8 gap-2 px-3"
|
||||
onClick={() => setView("list")}
|
||||
>
|
||||
<LayoutList className="h-4 w-4" />
|
||||
Liste
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={view === "kanban" ? "default" : "ghost"}
|
||||
className="h-8 gap-2 px-3"
|
||||
onClick={() => setView("kanban")}
|
||||
>
|
||||
<KanbanSquare className="h-4 w-4" />
|
||||
Kanban
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filteredTasks.length > 0 ? (
|
||||
view === "list" ? (
|
||||
<TaskList tasks={filteredTasks} clients={clients} projects={projects} />
|
||||
) : (
|
||||
<TaskKanban tasks={filteredTasks} clients={clients} projects={projects} />
|
||||
)
|
||||
) : (
|
||||
<EmptyState hasQuery={Boolean(normalizedQuery)} />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskList({
|
||||
tasks,
|
||||
clients,
|
||||
projects,
|
||||
}: {
|
||||
tasks: TaskListItem[];
|
||||
clients: TaskRelationOption[];
|
||||
projects: TaskRelationOption[];
|
||||
}) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-sm border border-border">
|
||||
<div className="hidden grid-cols-[1.5fr_1fr_1fr_0.8fr_1fr] gap-4 border-b border-border bg-muted/40 px-4 py-3 text-xs font-medium uppercase text-muted-foreground lg:grid">
|
||||
<span>Görev</span>
|
||||
<span>Bağlantı</span>
|
||||
<span>Öncelik</span>
|
||||
<span>Son tarih</span>
|
||||
<span className="text-right">İşlem</span>
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
{tasks.map((task) => (
|
||||
<TaskRow key={task.id} task={task} clients={clients} projects={projects} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskRow({
|
||||
task,
|
||||
clients,
|
||||
projects,
|
||||
}: {
|
||||
task: TaskListItem;
|
||||
clients: TaskRelationOption[];
|
||||
projects: TaskRelationOption[];
|
||||
}) {
|
||||
return (
|
||||
<div className="grid gap-4 px-4 py-4 lg:grid-cols-[1.5fr_1fr_1fr_0.8fr_1fr] lg:items-center">
|
||||
<div className="min-w-0">
|
||||
<div className={task.status === "done" ? "font-medium text-muted-foreground line-through" : "font-medium text-foreground"}>
|
||||
{task.title}
|
||||
</div>
|
||||
<div className="truncate text-sm text-muted-foreground">
|
||||
{statusLabels[task.status]}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<div>{task.projectName || "Proje yok"}</div>
|
||||
<div>{task.clientName || "Müşteri yok"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<Badge className={priorityClasses[task.priority]}>
|
||||
{priorityLabels[task.priority]}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className={isOverdue(task) ? "text-sm font-medium text-rose-600" : "text-sm text-muted-foreground"}>
|
||||
{task.due_at ? formatDateTime(task.due_at) : "Yok"}
|
||||
</div>
|
||||
<TaskActions task={task} clients={clients} projects={projects} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskKanban({
|
||||
tasks,
|
||||
clients,
|
||||
projects,
|
||||
}: {
|
||||
tasks: TaskListItem[];
|
||||
clients: TaskRelationOption[];
|
||||
projects: TaskRelationOption[];
|
||||
}) {
|
||||
const columns = ["todo", "in_progress", "done"] as const;
|
||||
|
||||
return (
|
||||
<div className="tiny-scrollbar grid gap-4 overflow-x-auto pb-2 lg:grid-cols-3">
|
||||
{columns.map((status) => {
|
||||
const columnTasks = tasks.filter((task) => task.status === status);
|
||||
|
||||
return (
|
||||
<div key={status} className="min-w-72 rounded-sm border border-border bg-muted/20 p-3">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-foreground">{statusLabels[status]}</h3>
|
||||
<Badge>{columnTasks.length}</Badge>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{columnTasks.map((task) => (
|
||||
<Card key={task.id}>
|
||||
<CardContent className="space-y-3 p-3">
|
||||
<div>
|
||||
<div className="font-medium text-foreground">{task.title}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{task.projectName || task.clientName || "Bağlantı yok"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Badge className={priorityClasses[task.priority]}>
|
||||
{priorityLabels[task.priority]}
|
||||
</Badge>
|
||||
<TaskActions task={task} clients={clients} projects={projects} compact />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskActions({
|
||||
task,
|
||||
clients,
|
||||
projects,
|
||||
compact = false,
|
||||
}: {
|
||||
task: TaskListItem;
|
||||
clients: TaskRelationOption[];
|
||||
projects: TaskRelationOption[];
|
||||
compact?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className={compact ? "flex justify-end gap-1" : "flex justify-start gap-2 lg:justify-end"}>
|
||||
<TaskDialog mode="edit" task={task} clients={clients} projects={projects} />
|
||||
{task.status !== "done" ? (
|
||||
<form action={completeTaskRecord}>
|
||||
<input type="hidden" name="id" value={task.id} />
|
||||
<Button type="submit" variant="outline" className="h-9 min-w-24 gap-2 px-3">
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
{!compact ? "Tamamla" : null}
|
||||
</Button>
|
||||
</form>
|
||||
) : null}
|
||||
<form action={deleteTaskRecord}>
|
||||
<input type="hidden" name="id" value={task.id} />
|
||||
<Button type="submit" variant="outline" className="h-9 gap-2 px-3 text-rose-600">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskDialog({
|
||||
mode,
|
||||
task,
|
||||
clients,
|
||||
projects,
|
||||
}: {
|
||||
mode: "create" | "edit";
|
||||
task?: TaskListItem;
|
||||
clients: TaskRelationOption[];
|
||||
projects: TaskRelationOption[];
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const action = mode === "create" ? createTaskRecord : updateTaskRecord;
|
||||
|
||||
async function handleSubmit(formData: FormData) {
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
await action(formData);
|
||||
setOpen(false);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant={mode === "create" ? "default" : "outline"}
|
||||
className="h-9 min-w-24 gap-2 px-3"
|
||||
>
|
||||
{mode === "create" ? <Plus className="h-4 w-4" /> : <Pencil className="h-4 w-4" />}
|
||||
{mode === "create" ? "Görev ekle" : "Düzenle"}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-[min(680px,calc(100dvh-6rem))] overflow-hidden sm:max-w-xl data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95">
|
||||
<form action={handleSubmit} className="flex max-h-[min(640px,calc(100dvh-9rem))] flex-col">
|
||||
{task ? <input type="hidden" name="id" value={task.id} /> : null}
|
||||
<DialogHeader className="shrink-0 pb-5">
|
||||
<DialogTitle>{mode === "create" ? "Yeni görev" : "Görevi düzenle"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Görevi proje, müşteri, öncelik ve son tarih bilgileriyle kaydet.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="tiny-scrollbar min-h-0 flex-1 overflow-y-auto pr-2">
|
||||
<TaskFormFields task={task} clients={clients} projects={projects} />
|
||||
</div>
|
||||
|
||||
<DialogFooter className="shrink-0 border-t border-border pt-5">
|
||||
<Button type="submit" disabled={isSubmitting} className="gap-2">
|
||||
{mode === "create" ? <Plus className="h-4 w-4" /> : <Pencil className="h-4 w-4" />}
|
||||
{isSubmitting
|
||||
? "Kaydediliyor"
|
||||
: mode === "create"
|
||||
? "Görevi ekle"
|
||||
: "Değişiklikleri kaydet"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskFormFields({
|
||||
task,
|
||||
clients,
|
||||
projects,
|
||||
}: {
|
||||
task?: TaskListItem;
|
||||
clients: TaskRelationOption[];
|
||||
projects: TaskRelationOption[];
|
||||
}) {
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor={`title-${task?.id || "new"}`}>Başlık</Label>
|
||||
<Input
|
||||
id={`title-${task?.id || "new"}`}
|
||||
name="title"
|
||||
defaultValue={task?.title || ""}
|
||||
required
|
||||
placeholder="Örn. Ana sayfa wireframe revizyonu"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor={`description-${task?.id || "new"}`}>Açıklama</Label>
|
||||
<Textarea
|
||||
id={`description-${task?.id || "new"}`}
|
||||
name="description"
|
||||
defaultValue={task?.description || ""}
|
||||
rows={3}
|
||||
placeholder="Kapsam, not veya teslim kriterleri..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<SelectField name="status" label="Durum" defaultValue={task?.status || "todo"}>
|
||||
<SelectItem value="todo">Yapılacak</SelectItem>
|
||||
<SelectItem value="in_progress">Devam ediyor</SelectItem>
|
||||
<SelectItem value="done">Tamamlandı</SelectItem>
|
||||
</SelectField>
|
||||
<SelectField name="priority" label="Öncelik" defaultValue={task?.priority || "medium"}>
|
||||
<SelectItem value="low">Düşük</SelectItem>
|
||||
<SelectItem value="medium">Orta</SelectItem>
|
||||
<SelectItem value="high">Yüksek</SelectItem>
|
||||
<SelectItem value="urgent">Acil</SelectItem>
|
||||
</SelectField>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<SelectField name="client_id" label="Müşteri" defaultValue={task?.client_id || ""}>
|
||||
{clients.map((client) => (
|
||||
<SelectItem key={client.id} value={client.id}>
|
||||
{client.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectField>
|
||||
<SelectField name="project_id" label="Proje" defaultValue={task?.project_id || ""}>
|
||||
{projects.map((project) => (
|
||||
<SelectItem key={project.id} value={project.id}>
|
||||
{project.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectField>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor={`due-${task?.id || "new"}`}>Son tarih</Label>
|
||||
<Input
|
||||
id={`due-${task?.id || "new"}`}
|
||||
name="due_at"
|
||||
type="datetime-local"
|
||||
defaultValue={task?.due_at ? toDateTimeLocal(task.due_at) : ""}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor={`estimated-${task?.id || "new"}`}>Tahmini süre</Label>
|
||||
<Input
|
||||
id={`estimated-${task?.id || "new"}`}
|
||||
name="estimated_minutes"
|
||||
type="number"
|
||||
min="0"
|
||||
defaultValue={task?.estimated_minutes ?? ""}
|
||||
placeholder="Dakika"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor={`actual-${task?.id || "new"}`}>Gerçekleşen süre</Label>
|
||||
<Input
|
||||
id={`actual-${task?.id || "new"}`}
|
||||
name="actual_minutes"
|
||||
type="number"
|
||||
min="0"
|
||||
defaultValue={task?.actual_minutes ?? ""}
|
||||
placeholder="Dakika"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectField({
|
||||
name,
|
||||
label,
|
||||
defaultValue,
|
||||
children,
|
||||
}: {
|
||||
name: string;
|
||||
label: string;
|
||||
defaultValue: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
<Label>{label}</Label>
|
||||
<Select name={name} defaultValue={defaultValue}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={`${label} seç`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>{children}</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-between gap-3 p-4">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">{label}</p>
|
||||
<p className="mt-1 text-2xl font-semibold text-foreground">{value}</p>
|
||||
</div>
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-sm bg-primary/10 text-primary">
|
||||
<CheckCircle2 className="h-5 w-5" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({ hasQuery }: { hasQuery: boolean }) {
|
||||
return (
|
||||
<div className="flex min-h-72 flex-col items-center justify-center rounded-sm border border-dashed border-border bg-muted/20 p-8 text-center">
|
||||
<CheckCircle2 className="h-10 w-10 text-muted-foreground" />
|
||||
<h3 className="mt-4 text-lg font-semibold text-foreground">
|
||||
{hasQuery ? "Aramana uygun görev yok" : "Henüz görev eklenmedi"}
|
||||
</h3>
|
||||
<p className="mt-2 max-w-md text-sm text-muted-foreground">
|
||||
{hasQuery
|
||||
? "Arama metnini sadeleştirerek tekrar deneyebilirsin."
|
||||
: "İlk görevini ekleyerek proje ve müşteri operasyonunu takip etmeye başlayabilirsin."}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function isOverdue(task: TaskListItem) {
|
||||
return Boolean(task.due_at && task.status !== "done" && new Date(task.due_at) < new Date());
|
||||
}
|
||||
|
||||
function formatDateTime(value: string) {
|
||||
return new Intl.DateTimeFormat("tr-TR", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
function toDateTimeLocal(value: string) {
|
||||
const date = new Date(value);
|
||||
const offsetDate = new Date(date.getTime() - date.getTimezoneOffset() * 60_000);
|
||||
return offsetDate.toISOString().slice(0, 16);
|
||||
}
|
||||
Reference in New Issue
Block a user