ERP-node/frontend/app/(main)/design/my-work/page.tsx

1898 lines
86 KiB
TypeScript
Raw Normal View History

"use client";
import React, { useState, useMemo, useCallback, useEffect } from "react";
import {
BarChart3,
Circle,
CheckCircle2,
AlertCircle,
Clock,
LayoutGrid,
List,
Timer,
ChevronLeft,
ChevronRight,
ChevronDown,
ChevronUp,
Plus,
Trash2,
Paperclip,
ShoppingCart,
Handshake,
FileText,
Image as ImageIcon,
Ruler,
Box,
FolderOpen,
Upload,
X,
Pencil,
Calendar,
Search,
RotateCcw,
ClipboardList,
FileEdit,
Inbox,
Loader2,
PointerIcon,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Progress } from "@/components/ui/progress";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Slider } from "@/components/ui/slider";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import {
ResizablePanelGroup,
ResizablePanel,
ResizableHandle,
} from "@/components/ui/resizable";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { toast } from "sonner";
import { useAuth } from "@/hooks/useAuth";
import {
getMyWork,
updateTask,
createWorkLog,
deleteWorkLog,
createSubItem,
updateSubItem,
deleteSubItem,
createPurchaseReq,
createCoopReq,
addCoopResponse,
} from "@/lib/api/design";
// ========== 타입 ==========
interface SubItem {
id: number | string;
_id?: string;
name: string;
weight: number;
progress: number;
status: string;
}
interface Attachment {
id: number;
name: string;
type: string;
size: string;
}
interface PurchaseReq {
id: number | string;
_id?: string;
item: string;
qty: number;
unit: string;
reason: string;
status: string;
}
interface CoopResponse {
date: string;
user: string;
content: string;
}
interface CoopReq {
id: string;
_id?: string;
toUser: string;
toDept: string;
title: string;
desc: string;
status: string;
dueDate: string;
responses: CoopResponse[];
}
interface WorkLog {
_id?: string;
startDt: string;
endDt: string;
hours: number;
desc: string;
subItemId: number | string | null;
attachments: Attachment[];
purchaseReqs: PurchaseReq[];
coopReqs: CoopReq[];
}
interface Task {
_id?: string;
name: string;
category: string;
assignee: string;
start: string;
end: string;
status: string;
progress: number;
priority: string;
subItems: SubItem[];
workLogs: WorkLog[];
}
interface Project {
id: string;
project_id?: string;
name: string;
customer: string;
status: string;
tasks: Task[];
}
interface MyTask extends Task {
projectId: string;
projectName: string;
}
// ========== 유틸 ==========
const USER_INFO: Record<string, { role: string; color: string }> = {
: { role: "기구설계 파트", color: "bg-blue-500" },
: { role: "기구설계 파트", color: "bg-emerald-500" },
: { role: "전장설계 파트", color: "bg-amber-500" },
: { role: "기구설계 파트", color: "bg-violet-500" },
SW: { role: "SW개발 파트", color: "bg-pink-500" },
: { role: "조립/시운전 파트", color: "bg-red-500" },
: { role: "구매/조달 파트", color: "bg-teal-500" },
: { role: "설계팀 팀장", color: "bg-indigo-500" },
};
const USERS = Object.keys(USER_INFO);
function calcHours(s: string, e: string): number {
if (!s || !e) return 0;
const d = (new Date(e).getTime() - new Date(s).getTime()) / 3600000;
return d > 0 ? Math.round(d * 10) / 10 : 0;
}
function calcAutoProgress(task: Task): number {
if (!task.subItems?.length) return task.progress;
const tw = task.subItems.reduce((s, i) => s + i.weight, 0);
if (!tw) return 0;
return Math.round(
task.subItems.reduce((s, i) => s + (i.progress * i.weight) / tw, 0)
);
}
function fmtDtShort(dt: string) {
if (!dt) return "";
return dt.substring(5, 10) + " " + dt.substring(11, 16);
}
function getWeekStart(d: Date): Date {
const r = new Date(d);
const day = r.getDay();
r.setDate(r.getDate() - day + (day === 0 ? -6 : 1));
r.setHours(0, 0, 0, 0);
return r;
}
function getFileIcon(type: string) {
switch (type) {
case "image":
return <ImageIcon className="h-4 w-4" />;
case "drawing":
return <Ruler className="h-4 w-4" />;
case "3d":
return <Box className="h-4 w-4" />;
case "document":
return <FileText className="h-4 w-4" />;
default:
return <FolderOpen className="h-4 w-4" />;
}
}
function getFileType(name: string): string {
const ext = (name.split(".").pop() || "").toLowerCase();
if (["jpg", "jpeg", "png", "gif", "bmp", "webp"].includes(ext))
return "image";
if (["dwg", "dxf"].includes(ext)) return "drawing";
if (["step", "stp", "igs", "stl"].includes(ext)) return "3d";
if (
["pdf", "doc", "docx", "xlsx", "xls", "pptx", "txt", "csv"].includes(ext)
)
return "document";
return "other";
}
function getProgressColor(p: number) {
if (p >= 80) return "text-emerald-500";
if (p >= 40) return "text-blue-500";
if (p > 0) return "text-amber-500";
return "text-muted-foreground";
}
function getProgressBg(p: number) {
if (p >= 80) return "bg-emerald-500";
if (p >= 40) return "bg-blue-500";
if (p > 0) return "bg-amber-500";
return "bg-muted";
}
const STATUS_STYLES: Record<string, string> = {
: "bg-muted text-foreground",
: "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300",
:
"bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300",
: "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-300",
: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300",
};
const PRIORITY_STYLES: Record<string, string> = {
: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300",
: "bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300",
: "bg-muted text-foreground",
: "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-300",
};
const PR_STATUS_STYLES: Record<string, string> = {
: "bg-red-100 text-red-800",
: "bg-amber-100 text-amber-800",
: "bg-blue-100 text-blue-800",
: "bg-emerald-100 text-emerald-800",
};
const CR_STATUS_STYLES: Record<string, string> = {
: "bg-amber-100 text-amber-800",
: "bg-amber-100 text-amber-800",
: "bg-blue-100 text-blue-800",
: "bg-emerald-100 text-emerald-800",
};
const CR_NEXT: Record<string, string> = {
: "접수",
: "진행중",
: "완료",
};
// ========== API 응답 매핑 ==========
function mapApiTaskToTask(api: any): Task {
const subItems: SubItem[] = (api.sub_items || []).map((s: any) => ({
id: s.id,
_id: String(s.id),
name: s.name || "",
weight: Number(s.weight) || 0,
progress: Number(s.progress) || 0,
status: s.status || "대기",
}));
const workLogs: WorkLog[] = (api.work_logs || []).map((w: any) => {
const attachments: Attachment[] = (w.attachments || []).map((a: any) => ({
id: a.id ?? a.file_name?.length ?? 0,
name: a.file_name || a.name || "",
type: a.file_type || getFileType(a.file_name || ""),
size: a.file_size || a.size || "",
}));
const purchaseReqs: PurchaseReq[] = (w.purchase_reqs || []).map((pr: any) => ({
id: pr.id,
_id: pr.id ? String(pr.id) : undefined,
item: pr.item || "",
qty: Number(pr.qty) || 1,
unit: pr.unit || "EA",
reason: pr.reason || "",
status: pr.status || "요청",
}));
const coopReqs: CoopReq[] = (w.coop_reqs || []).map((c: any) => ({
id: c.id || `CR-${String(c.id)}`,
_id: c.id ? String(c.id) : undefined,
toUser: c.to_user || "",
toDept: c.to_dept || "",
title: c.title || "",
desc: c.description || c.desc || "",
status: c.status || "요청",
dueDate: c.due_date || "",
responses: (c.responses || []).map((r: any) => ({
date: r.response_date || r.date || "",
user: r.user_name || r.user || "",
content: r.content || "",
})),
}));
return {
_id: w.id ? String(w.id) : undefined,
startDt: w.start_dt || "",
endDt: w.end_dt || "",
hours: Number(w.hours) || 0,
desc: w.description || w.desc || "",
subItemId: w.sub_item_id ?? null,
attachments,
purchaseReqs,
coopReqs,
};
});
return {
_id: api.id ? String(api.id) : undefined,
name: api.name || "",
category: api.category || "",
assignee: api.assignee || "",
start: api.start_date || api.start || "",
end: api.end_date || api.end || "",
status: api.status || "대기",
progress: Number(api.progress) || 0,
priority: api.priority || "보통",
subItems,
workLogs,
};
}
function mapApiTasksToProjects(apiTasks: any[]): Project[] {
const projectMap = new Map<string, Project>();
for (const api of apiTasks) {
const pId = api.project_id || api.project_no;
const pNo = api.project_no || api.project_id || "";
if (!projectMap.has(pId)) {
projectMap.set(pId, {
id: pNo,
project_id: pId,
name: api.project_name || "",
customer: api.project_customer || "",
status: api.project_status || "진행중",
tasks: [],
});
}
projectMap.get(pId)!.tasks.push(mapApiTaskToTask(api));
}
return Array.from(projectMap.values());
}
// ========== 메인 컴포넌트 ==========
export default function MyWorkPage() {
const { userName } = useAuth();
const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(true);
const [currentUser, setCurrentUser] = useState(userName || "최기구");
const [viewMode, setViewMode] = useState<"kanban" | "list" | "timesheet">("kanban");
const [timesheetWeekOffset, setTimesheetWeekOffset] = useState(0);
// 필터
const [filterProject, setFilterProject] = useState("");
const [filterStatus, setFilterStatus] = useState("");
const [filterPriority, setFilterPriority] = useState("");
const [filterCategory, setFilterCategory] = useState("");
const [filterKeyword, setFilterKeyword] = useState("");
// 선택된 업무
const [selectedTaskKey, setSelectedTaskKey] = useState<string | null>(null);
const [selectedSubItemId, setSelectedSubItemId] = useState<number | string>("__unassigned__");
// 편집 상태
const [editingLogIdx, setEditingLogIdx] = useState<number>(-1);
const [editForm, setEditForm] = useState({ startDt: "", endDt: "", desc: "" });
// 수행항목 추가
const [newSiName, setNewSiName] = useState("");
const [newSiWeight, setNewSiWeight] = useState("");
// 모달
const [attModalOpen, setAttModalOpen] = useState(false);
const [prModalOpen, setPrModalOpen] = useState(false);
const [crModalOpen, setCrModalOpen] = useState(false);
const [modalLogIdx, setModalLogIdx] = useState(-1);
const [modalAttachments, setModalAttachments] = useState<Attachment[]>([]);
const [modalPurchaseReqs, setModalPurchaseReqs] = useState<PurchaseReq[]>([]);
const [modalCoopReqs, setModalCoopReqs] = useState<CoopReq[]>([]);
// 구매요청 폼
const [prForm, setPrForm] = useState({ item: "", qty: "1", unit: "EA", reason: "", status: "요청" });
// 협조요청 폼
const [crForm, setCrForm] = useState({ title: "", toUser: "", desc: "", dueDate: "" });
const today = useMemo(() => new Date(), []);
const todayStr = useMemo(() => {
const dn = ["일", "월", "화", "수", "목", "금", "토"];
return `${today.getFullYear()}.${String(today.getMonth() + 1).padStart(2, "0")}.${String(today.getDate()).padStart(2, "0")} (${dn[today.getDay()]})`;
}, [today]);
const projectsRef = React.useRef<Project[]>([]);
projectsRef.current = projects;
const fetchMyWork = useCallback(async () => {
setLoading(true);
try {
const params: { status?: string; project_id?: string } = {};
if (filterStatus && filterStatus !== "__all__") params.status = filterStatus;
if (filterProject && filterProject !== "__all__") {
const proj = projectsRef.current.find((p) => p.id === filterProject || p.project_id === filterProject);
if (proj?.project_id) params.project_id = proj.project_id;
else params.project_id = filterProject;
}
const res = await getMyWork(params);
if (res.success && res.data) {
setProjects(mapApiTasksToProjects(res.data));
} else {
setProjects([]);
}
} catch {
toast.error("업무 목록을 불러오는데 실패했습니다.");
setProjects([]);
} finally {
setLoading(false);
}
}, [filterStatus, filterProject]);
useEffect(() => {
if (userName) setCurrentUser(userName);
}, [userName]);
useEffect(() => {
fetchMyWork();
}, [fetchMyWork]);
const projectList = useMemo(() => {
const ids = new Set<string>();
projects.forEach((p) => ids.add(`${p.id}|${p.name}`));
return Array.from(ids).map((v) => {
const [id, name] = v.split("|");
return { id, name };
});
}, [projects]);
// 내 업무 목록
const myTasks = useMemo<MyTask[]>(() => {
const tasks: MyTask[] = [];
projects.forEach((p) =>
p.tasks.forEach((t) => {
if (t.assignee === currentUser)
tasks.push({ ...t, projectId: p.id, projectName: p.name });
})
);
return tasks;
}, [projects, currentUser]);
// 필터링된 업무
const filteredTasks = useMemo(() => {
return myTasks.filter((t) => {
if (filterProject && filterProject !== "__all__" && t.projectId !== filterProject) return false;
if (filterStatus && filterStatus !== "__all__" && t.status !== filterStatus) return false;
if (filterPriority && filterPriority !== "__all__" && (t.priority || "보통") !== filterPriority) return false;
if (filterCategory && filterCategory !== "__all__" && t.category !== filterCategory) return false;
if (filterKeyword && !t.name.toLowerCase().includes(filterKeyword.toLowerCase()) && !t.projectName.toLowerCase().includes(filterKeyword.toLowerCase())) return false;
return true;
});
}, [myTasks, filterProject, filterStatus, filterPriority, filterCategory, filterKeyword]);
// 수신된 협조요청
const receivedCoops = useMemo(() => {
const coops: (CoopReq & { projectId: string; projectName: string; taskName: string; fromUser: string })[] = [];
projects.forEach((p) =>
p.tasks.forEach((t) =>
(t.workLogs || []).forEach((wl) =>
(wl.coopReqs || []).forEach((cr) => {
if (cr.toUser === currentUser && cr.status !== "완료")
coops.push({ ...cr, projectId: p.id, projectName: p.name, taskName: t.name, fromUser: t.assignee });
})
)
)
);
return coops;
}, [projects, currentUser]);
// 선택된 업무 참조
const selectedTask = useMemo(() => {
if (!selectedTaskKey) return null;
const [pid, tname] = selectedTaskKey.split("||");
for (const p of projects) {
if (p.id === pid) {
const t = p.tasks.find((task) => task.name === tname);
if (t) return { task: t, projectId: p.id, projectName: p.name };
}
}
return null;
}, [selectedTaskKey, projects]);
// 통계
const stats = useMemo(() => {
const total = myTasks.length + receivedCoops.length;
const progress = myTasks.filter((t) => t.status === "진행중").length;
const done = myTasks.filter((t) => t.status === "완료").length;
const delay = myTasks.filter((t) => t.status !== "완료" && new Date(t.end) < today).length;
const ws = getWeekStart(today);
const we = new Date(ws);
we.setDate(we.getDate() + 6);
let wh = 0;
myTasks.forEach((t) =>
(t.workLogs || []).forEach((l) => {
const ld = new Date(l.startDt);
if (ld >= ws && ld <= we) wh += l.hours;
})
);
return { total, progress, done, delay, weekHours: wh };
}, [myTasks, receivedCoops, today]);
const handleSelectTask = useCallback((pid: string, tname: string) => {
setSelectedTaskKey(`${pid}||${tname}`);
setEditingLogIdx(-1);
setSelectedSubItemId("__unassigned__");
}, []);
const handleResetFilter = useCallback(() => {
setFilterProject("");
setFilterStatus("");
setFilterPriority("");
setFilterCategory("");
setFilterKeyword("");
}, []);
// 업무 데이터 변경 헬퍼
const updateProjects = useCallback((updater: (draft: Project[]) => void) => {
setProjects((prev) => {
const copy = JSON.parse(JSON.stringify(prev));
updater(copy);
return copy;
});
}, []);
const getTaskRef = useCallback(
(projectId: string, taskName: string) => {
for (const p of projects) {
if (p.id === projectId) {
return p.tasks.find((t) => t.name === taskName) || null;
}
}
return null;
},
[projects]
);
// 수행항목 추가
const handleAddSubItem = useCallback(async () => {
if (!selectedTask || !newSiName.trim()) return;
const w = parseInt(newSiWeight) || 0;
if (w <= 0) return;
const taskId = selectedTask.task._id;
if (!taskId) return;
const res = await createSubItem(taskId, { name: newSiName.trim(), weight: w, progress: 0, status: "대기" });
if (res.success) {
toast.success("수행항목이 추가되었습니다.");
setNewSiName("");
setNewSiWeight("");
fetchMyWork();
} else {
toast.error(res.message || "수행항목 추가에 실패했습니다.");
}
}, [selectedTask, newSiName, newSiWeight, fetchMyWork]);
// 수행항목 삭제
const handleDeleteSubItem = useCallback(
async (idx: number) => {
if (!selectedTask) return;
const si = selectedTask.task.subItems[idx];
const siId = si?._id ?? si?.id;
if (siId) {
const res = await deleteSubItem(String(siId));
if (res.success) {
toast.success("수행항목이 삭제되었습니다.");
fetchMyWork();
} else {
toast.error(res.message || "수행항목 삭제에 실패했습니다.");
}
} else {
updateProjects((draft) => {
for (const p of draft) {
if (p.id === selectedTask.projectId) {
const t = p.tasks.find((x) => x.name === selectedTask.task.name);
if (t) {
t.subItems.splice(idx, 1);
t.progress = calcAutoProgress(t);
}
}
}
});
}
},
[selectedTask, updateProjects, fetchMyWork]
);
// 수행항목 체크 토글
const handleToggleSubCheck = useCallback(
async (idx: number) => {
if (!selectedTask) return;
const si = selectedTask.task.subItems[idx];
const siId = si?._id ?? si?.id;
const newProgress = (si?.progress ?? 0) >= 100 ? 0 : 100;
const newStatus = newProgress >= 100 ? "완료" : "대기";
if (siId) {
const res = await updateSubItem(String(siId), { progress: newProgress, status: newStatus });
if (res.success) {
toast.success("수행항목이 업데이트되었습니다.");
fetchMyWork();
} else {
toast.error(res.message || "수행항목 업데이트에 실패했습니다.");
}
} else {
updateProjects((draft) => {
for (const p of draft) {
if (p.id === selectedTask.projectId) {
const t = p.tasks.find((x) => x.name === selectedTask.task.name);
if (t && t.subItems[idx]) {
t.subItems[idx].progress = newProgress;
t.subItems[idx].status = newStatus;
t.progress = calcAutoProgress(t);
}
}
}
});
}
},
[selectedTask, updateProjects, fetchMyWork]
);
// 수행항목 진행률 변경
const handleUpdateSubProgress = useCallback(
async (siId: number | string, val: number) => {
if (!selectedTask) return;
const si = selectedTask.task.subItems.find((s) => s.id === siId || s._id === String(siId));
const apiId = si?._id ?? si?.id;
if (apiId) {
const res = await updateSubItem(String(apiId), {
progress: val,
status: val >= 100 ? "완료" : val > 0 ? "진행중" : "대기",
});
if (res.success) {
fetchMyWork();
} else {
toast.error(res.message || "진행률 업데이트에 실패했습니다.");
}
} else {
updateProjects((draft) => {
for (const p of draft) {
if (p.id === selectedTask.projectId) {
const t = p.tasks.find((x) => x.name === selectedTask.task.name);
if (t) {
const subSi = t.subItems.find((s) => s.id === siId || s._id === String(siId));
if (subSi) {
subSi.progress = val;
subSi.status = val >= 100 ? "완료" : val > 0 ? "진행중" : "대기";
t.progress = calcAutoProgress(t);
}
}
}
}
});
}
},
[selectedTask, updateProjects, fetchMyWork]
);
// 수행기록 편집 시작
const handleStartEdit = useCallback(
(idx: number) => {
if (!selectedTask) return;
const log = selectedTask.task.workLogs[idx];
if (log) {
setEditForm({ startDt: log.startDt, endDt: log.endDt, desc: log.desc });
}
setEditingLogIdx(idx);
},
[selectedTask]
);
// 새 기록 추가 시작
const handleStartNewLog = useCallback(
(siId: number | string | null) => {
const now = new Date();
const ds = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
setEditForm({ startDt: `${ds}T08:00`, endDt: `${ds}T17:00`, desc: "" });
setEditingLogIdx(-2);
if (siId === null) setSelectedSubItemId("__unassigned__");
else setSelectedSubItemId(siId);
},
[]
);
// 기록 저장
const handleSaveLog = useCallback(
async (logIdx: number, siId: number | string | null) => {
if (!selectedTask) return;
if (!editForm.startDt || !editForm.endDt || !editForm.desc.trim()) return;
const hours = calcHours(editForm.startDt, editForm.endDt);
if (hours <= 0) return;
const taskId = selectedTask.task._id;
if (!taskId) return;
if (logIdx < 0) {
const res = await createWorkLog(taskId, {
start_dt: editForm.startDt,
end_dt: editForm.endDt,
hours,
description: editForm.desc.trim(),
sub_item_id: siId ?? null,
});
if (res.success) {
toast.success("작업일지가 등록되었습니다.");
setEditingLogIdx(-1);
fetchMyWork();
} else {
toast.error(res.message || "작업일지 등록에 실패했습니다.");
}
} else {
updateProjects((draft) => {
for (const p of draft) {
if (p.id === selectedTask.projectId) {
const t = p.tasks.find((x) => x.name === selectedTask.task.name);
if (t) {
t.workLogs[logIdx] = { ...t.workLogs[logIdx], startDt: editForm.startDt, endDt: editForm.endDt, hours, desc: editForm.desc.trim(), subItemId: siId, attachments: t.workLogs[logIdx].attachments || [], purchaseReqs: t.workLogs[logIdx].purchaseReqs || [], coopReqs: t.workLogs[logIdx].coopReqs || [] };
}
}
}
});
setEditingLogIdx(-1);
}
},
[selectedTask, editForm, updateProjects, fetchMyWork]
);
// 기록 삭제
const handleDeleteLog = useCallback(
async (idx: number) => {
if (!selectedTask) return;
const log = selectedTask.task.workLogs[idx];
const workLogId = log?._id;
if (workLogId) {
const res = await deleteWorkLog(String(workLogId));
if (res.success) {
toast.success("작업일지가 삭제되었습니다.");
setEditingLogIdx(-1);
fetchMyWork();
} else {
toast.error(res.message || "작업일지 삭제에 실패했습니다.");
}
} else {
updateProjects((draft) => {
for (const p of draft) {
if (p.id === selectedTask.projectId) {
const t = p.tasks.find((x) => x.name === selectedTask.task.name);
if (t) t.workLogs.splice(idx, 1);
}
}
});
setEditingLogIdx(-1);
}
},
[selectedTask, updateProjects, fetchMyWork]
);
// 모달: 첨부파일
const openAttModal = useCallback(
(logIdx: number) => {
if (!selectedTask) return;
setModalLogIdx(logIdx);
const atts = logIdx >= 0 ? [...(selectedTask.task.workLogs[logIdx]?.attachments || [])] : [];
setModalAttachments(atts);
setAttModalOpen(true);
},
[selectedTask]
);
const saveAttModal = useCallback(() => {
if (!selectedTask || modalLogIdx < 0) return;
updateProjects((draft) => {
for (const p of draft) {
if (p.id === selectedTask.projectId) {
const t = p.tasks.find((x) => x.name === selectedTask.task.name);
if (t && t.workLogs[modalLogIdx]) t.workLogs[modalLogIdx].attachments = [...modalAttachments];
}
}
});
setAttModalOpen(false);
}, [selectedTask, modalLogIdx, modalAttachments, updateProjects]);
// 모달: 구매요청
const openPrModal = useCallback(
(logIdx: number) => {
if (!selectedTask) return;
setModalLogIdx(logIdx);
const prs = logIdx >= 0 ? [...(selectedTask.task.workLogs[logIdx]?.purchaseReqs || []).map((p) => ({ ...p }))] : [];
setModalPurchaseReqs(prs);
setPrModalOpen(true);
},
[selectedTask]
);
const savePrModal = useCallback(async () => {
if (!selectedTask || modalLogIdx < 0) return;
const workLog = selectedTask.task.workLogs[modalLogIdx];
const workLogId = workLog?._id;
if (workLogId) {
const newItems = modalPurchaseReqs.filter((pr) => !pr._id);
for (const pr of newItems) {
const res = await createPurchaseReq(String(workLogId), {
item: pr.item,
qty: pr.qty,
unit: pr.unit,
reason: pr.reason,
status: pr.status,
});
if (!res.success) {
toast.error(res.message || "구매요청 등록에 실패했습니다.");
return;
}
}
if (newItems.length > 0) toast.success("구매요청이 등록되었습니다.");
}
updateProjects((draft) => {
for (const p of draft) {
if (p.id === selectedTask.projectId) {
const t = p.tasks.find((x) => x.name === selectedTask.task.name);
if (t && t.workLogs[modalLogIdx]) t.workLogs[modalLogIdx].purchaseReqs = [...modalPurchaseReqs];
}
}
});
setPrModalOpen(false);
if (workLogId && newItems.length > 0) fetchMyWork();
}, [selectedTask, modalLogIdx, modalPurchaseReqs, updateProjects, fetchMyWork]);
// 모달: 협조요청
const openCrModal = useCallback(
(logIdx: number) => {
if (!selectedTask) return;
setModalLogIdx(logIdx);
const crs = logIdx >= 0 ? [...(selectedTask.task.workLogs[logIdx]?.coopReqs || []).map((c) => ({ ...c, responses: [...(c.responses || [])] }))] : [];
setModalCoopReqs(crs);
setCrModalOpen(true);
},
[selectedTask]
);
const saveCrModal = useCallback(async () => {
if (!selectedTask || modalLogIdx < 0) return;
const workLog = selectedTask.task.workLogs[modalLogIdx];
const workLogId = workLog?._id;
if (workLogId) {
const newItems = modalCoopReqs.filter((cr) => !cr._id);
for (const cr of newItems) {
const res = await createCoopReq(String(workLogId), {
to_user: cr.toUser,
to_dept: cr.toDept,
title: cr.title,
description: cr.desc,
due_date: cr.dueDate,
});
if (!res.success) {
toast.error(res.message || "협조요청 등록에 실패했습니다.");
return;
}
}
if (newItems.length > 0) toast.success("협조요청이 등록되었습니다.");
}
updateProjects((draft) => {
for (const p of draft) {
if (p.id === selectedTask.projectId) {
const t = p.tasks.find((x) => x.name === selectedTask.task.name);
if (t && t.workLogs[modalLogIdx]) t.workLogs[modalLogIdx].coopReqs = [...modalCoopReqs];
}
}
});
setCrModalOpen(false);
if (workLogId && modalCoopReqs.filter((cr) => !cr._id).length > 0) fetchMyWork();
}, [selectedTask, modalLogIdx, modalCoopReqs, updateProjects, fetchMyWork]);
// ===== 칸반 보드 =====
const kanbanCols = useMemo(() => {
const cols: Record<string, MyTask[]> = { : [], : [], : [], : [] };
filteredTasks.forEach((t) => {
if (cols[t.status]) cols[t.status].push(t);
});
return cols;
}, [filteredTasks]);
const kanbanConfig = [
{ key: "대기", icon: <Circle className="h-3.5 w-3.5" />, color: "border-muted-foreground/30", titleColor: "text-muted-foreground" },
{ key: "진행중", icon: <Circle className="h-3.5 w-3.5 text-blue-500" />, color: "border-blue-500", titleColor: "text-blue-500" },
{ key: "검토중", icon: <Circle className="h-3.5 w-3.5 text-amber-500" />, color: "border-amber-500", titleColor: "text-amber-500" },
{ key: "완료", icon: <CheckCircle2 className="h-3.5 w-3.5 text-emerald-500" />, color: "border-emerald-500", titleColor: "text-emerald-500" },
];
// ===== 타임시트 =====
const timesheetData = useMemo(() => {
const ws = getWeekStart(today);
ws.setDate(ws.getDate() + timesheetWeekOffset * 7);
const we = new Date(ws);
we.setDate(we.getDate() + 6);
const dn = ["월", "화", "수", "목", "금", "토", "일"];
const wds: Date[] = [];
for (let i = 0; i < 7; i++) {
const d = new Date(ws);
d.setDate(d.getDate() + i);
wds.push(d);
}
const ph: Record<string, { id: string; name: string; days: number[]; total: number; tasks: Record<string, number[]> }> = {};
myTasks.forEach((t) => {
const k = `${t.projectId}|${t.projectName}`;
if (!ph[k]) ph[k] = { id: t.projectId, name: t.projectName, days: [0, 0, 0, 0, 0, 0, 0], total: 0, tasks: {} };
(t.workLogs || []).forEach((l) => {
const ld = new Date(l.startDt);
const di = wds.findIndex((d) => d.toDateString() === ld.toDateString());
if (di >= 0) {
ph[k].days[di] += l.hours;
ph[k].total += l.hours;
if (!ph[k].tasks[t.name]) ph[k].tasks[t.name] = [0, 0, 0, 0, 0, 0, 0];
ph[k].tasks[t.name][di] += l.hours;
}
});
});
const dt = [0, 0, 0, 0, 0, 0, 0];
Object.values(ph).forEach((p) => p.days.forEach((h, i) => (dt[i] += h)));
const gt = dt.reduce((s, h) => s + h, 0);
return { ws, we, dn, wds, ph, dt, gt };
}, [myTasks, today, timesheetWeekOffset]);
// ========== 렌더링 ==========
if (loading && projects.length === 0) {
return (
<div className="flex h-full flex-col items-center justify-center gap-2 p-3">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<span className="text-sm text-muted-foreground"> ...</span>
</div>
);
}
return (
<div className="flex h-full flex-col gap-2 p-3">
{/* 헤더 */}
<div className="flex shrink-0 items-center justify-between">
<div className="flex items-center gap-3">
<h1 className="text-xl font-bold">
<BarChart3 className="mr-1.5 inline h-5 w-5" />
</h1>
<div className="flex items-center gap-2 rounded-lg border bg-card px-2.5 py-1.5">
<Avatar className="h-7 w-7">
<AvatarFallback className={cn("text-xs font-bold text-white", USER_INFO[currentUser]?.color || "bg-primary")}>
{currentUser.charAt(0)}
</AvatarFallback>
</Avatar>
<div className="leading-tight">
<div className="text-xs font-semibold">{currentUser}</div>
<div className="text-[10px] text-muted-foreground">{USER_INFO[currentUser]?.role}</div>
</div>
<Select value={currentUser} onValueChange={(v) => { setCurrentUser(v); setSelectedTaskKey(null); }}>
<SelectTrigger className="h-6 w-20 border-0 bg-transparent text-xs text-primary" size="xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{USERS.map((u) => (
<SelectItem key={u} value={u}>{u}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex items-center gap-1.5 rounded-lg border bg-card px-2.5 py-1.5 text-xs text-muted-foreground">
<Calendar className="h-3.5 w-3.5" />
{todayStr}
</div>
</div>
{/* 검색 필터 */}
<div className="flex shrink-0 flex-wrap items-center gap-2 rounded-lg border bg-card px-2.5 py-1.5">
<Select value={filterProject} onValueChange={setFilterProject}>
<SelectTrigger className="h-7 w-[130px] text-xs" size="xs"><SelectValue placeholder="전체 프로젝트" /></SelectTrigger>
<SelectContent>
<SelectItem value="__all__"> </SelectItem>
{projectList.map((p) => (
<SelectItem key={p.id} value={p.id}>{p.id} {p.name}</SelectItem>
))}
</SelectContent>
</Select>
<Select value={filterStatus} onValueChange={setFilterStatus}>
<SelectTrigger className="h-7 w-[100px] text-xs" size="xs"><SelectValue placeholder="전체 상태" /></SelectTrigger>
<SelectContent>
<SelectItem value="__all__"> </SelectItem>
{["대기", "진행중", "검토중", "완료"].map((s) => (<SelectItem key={s} value={s}>{s}</SelectItem>))}
</SelectContent>
</Select>
<Select value={filterPriority} onValueChange={setFilterPriority}>
<SelectTrigger className="h-7 w-[110px] text-xs" size="xs"><SelectValue placeholder="전체 우선순위" /></SelectTrigger>
<SelectContent>
<SelectItem value="__all__"> </SelectItem>
{["긴급", "높음", "보통", "낮음"].map((s) => (<SelectItem key={s} value={s}>{s}</SelectItem>))}
</SelectContent>
</Select>
<Select value={filterCategory} onValueChange={setFilterCategory}>
<SelectTrigger className="h-7 w-[110px] text-xs" size="xs"><SelectValue placeholder="전체 유형" /></SelectTrigger>
<SelectContent>
<SelectItem value="__all__"> </SelectItem>
{["기구설계", "전장설계", "SW개발", "구매/조달", "조립/시운전", "검토/승인"].map((s) => (<SelectItem key={s} value={s}>{s}</SelectItem>))}
</SelectContent>
</Select>
<div className="relative">
<Search className="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={filterKeyword}
onChange={(e) => setFilterKeyword(e.target.value)}
placeholder="업무명 검색..."
className="h-7 w-[120px] pl-7 text-xs"
/>
</div>
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={handleResetFilter}>
<RotateCcw className="mr-1 h-3 w-3" />
</Button>
</div>
{/* 통계 카드 */}
<div className="grid shrink-0 grid-cols-5 gap-2">
{[
{ icon: <ClipboardList className="h-4 w-4 text-blue-500" />, value: stats.total, label: "전체 업무", bg: "bg-blue-50 dark:bg-blue-900/20" },
{ icon: <Circle className="h-4 w-4 text-amber-500" />, value: stats.progress, label: "진행중", bg: "bg-amber-50 dark:bg-amber-900/20" },
{ icon: <CheckCircle2 className="h-4 w-4 text-emerald-500" />, value: stats.done, label: "완료", bg: "bg-emerald-50 dark:bg-emerald-900/20" },
{ icon: <AlertCircle className="h-4 w-4 text-red-500" />, value: stats.delay, label: "지연", bg: "bg-red-50 dark:bg-red-900/20" },
{ icon: <Clock className="h-4 w-4 text-violet-500" />, value: `${stats.weekHours}h`, label: "이번주 투입", bg: "bg-violet-50 dark:bg-violet-900/20" },
].map((s, i) => (
<Card key={i} className="flex flex-row items-center gap-2 rounded-lg border px-3 py-2 shadow-none">
<div className={cn("flex h-8 w-8 items-center justify-center rounded-lg", s.bg)}>{s.icon}</div>
<div>
<div className="text-lg font-bold">{s.value}</div>
<div className="text-[11px] text-muted-foreground">{s.label}</div>
</div>
</Card>
))}
</div>
{/* 메인 영역 */}
<ResizablePanelGroup direction="horizontal" className="min-h-0 flex-1 rounded-lg">
{/* 왼쪽: 업무 현황 */}
<ResizablePanel defaultSize={45} minSize={25}>
<div className="flex h-full flex-col overflow-hidden rounded-lg border bg-card">
<div className="flex shrink-0 items-center justify-between border-b bg-muted/30 px-3 py-1.5">
<span className="text-sm font-bold">
<ClipboardList className="mr-1 inline h-4 w-4" />
</span>
<Tabs value={viewMode} onValueChange={(v) => setViewMode(v as any)}>
<TabsList className="h-7">
<TabsTrigger value="kanban" className="h-5 gap-1 px-2 text-[11px]">
<LayoutGrid className="h-3 w-3" />
</TabsTrigger>
<TabsTrigger value="list" className="h-5 gap-1 px-2 text-[11px]">
<List className="h-3 w-3" />
</TabsTrigger>
<TabsTrigger value="timesheet" className="h-5 gap-1 px-2 text-[11px]">
<Timer className="h-3 w-3" />
</TabsTrigger>
</TabsList>
</Tabs>
</div>
<ScrollArea className="flex-1">
<div className="p-2">
{viewMode === "kanban" && (
<div className="grid grid-cols-4 gap-2">
{kanbanConfig.map((col) => {
const tasks = kanbanCols[col.key] || [];
const extraCount = col.key === "대기" ? receivedCoops.length : 0;
return (
<div key={col.key} className="flex flex-col rounded-lg bg-muted/50 p-2">
<div className={cn("mb-2 flex items-center justify-between border-b-2 pb-1.5", col.color)}>
<span className={cn("flex items-center gap-1 text-xs font-bold", col.titleColor)}>
{col.icon}{col.key}
</span>
<Badge variant="outline" className="h-5 px-1.5 text-[10px]">{tasks.length + extraCount}</Badge>
</div>
<div className="flex flex-1 flex-col gap-1.5 overflow-y-auto">
{col.key === "대기" && receivedCoops.map((cr, i) => (
<div key={`coop-${i}`} className="rounded-md border border-amber-200 bg-amber-50 p-2 dark:border-amber-900/50 dark:bg-amber-900/10">
<div className="text-[10px] text-amber-600">
<Handshake className="mr-0.5 inline h-3 w-3" /> · {cr.fromUser}
</div>
<div className="text-xs font-semibold">{cr.title}</div>
<div className="mt-0.5 text-[10px] text-muted-foreground">{cr.projectName}</div>
<div className={cn("mt-0.5 text-[10px]", new Date(cr.dueDate) < today ? "font-semibold text-destructive" : "text-emerald-600")}>
<Calendar className="mr-0.5 inline h-3 w-3" />{cr.dueDate}
</div>
</div>
))}
{tasks.map((t) => {
const isDelay = t.status !== "완료" && new Date(t.end) < today;
const dd = Math.ceil((new Date(t.end).getTime() - today.getTime()) / 86400000);
const dueText = isDelay ? `${Math.abs(dd)}일 초과` : dd === 0 ? "오늘" : dd <= 3 ? `D-${dd}` : t.end;
const attCount = (t.workLogs || []).reduce((n, wl) => n + (wl.attachments || []).length, 0);
const coopCount = (t.workLogs || []).reduce((n, wl) => n + (wl.coopReqs || []).filter((c) => c.status !== "완료").length, 0);
const isSelected = selectedTaskKey === `${t.projectId}||${t.name}`;
return (
<div
key={`${t.projectId}-${t.name}`}
className={cn(
"cursor-pointer rounded-md border bg-card p-2 transition-all hover:shadow-sm",
isSelected && "border-primary ring-2 ring-primary/20"
)}
onClick={() => handleSelectTask(t.projectId, t.name)}
>
<div className="text-[10px] text-muted-foreground">{t.projectId} · {t.projectName}</div>
<div className="text-xs font-semibold">{t.name}</div>
<div className="mt-1 flex items-center justify-between">
<Badge className={cn("h-4 px-1.5 text-[9px]", PRIORITY_STYLES[t.priority || "보통"])}>{t.priority || "보통"}</Badge>
<span className="text-[10px] text-muted-foreground">{t.category}</span>
</div>
<div className="mt-1 flex items-center gap-1.5">
<div className="h-1 flex-1 overflow-hidden rounded-full bg-muted">
<div className={cn("h-full rounded-full", getProgressBg(t.progress))} style={{ width: `${t.progress}%` }} />
</div>
<span className={cn("text-[10px] font-semibold", getProgressColor(t.progress))}>{t.progress}%</span>
</div>
<div className={cn("mt-0.5 text-[10px]", isDelay ? "font-semibold text-destructive" : dd <= 3 ? "text-amber-500" : "text-emerald-600")}>
<Calendar className="mr-0.5 inline h-3 w-3" />{dueText}
</div>
{(coopCount > 0 || attCount > 0) && (
<div className="mt-1 flex gap-1">
{coopCount > 0 && <Badge variant="outline" className="h-4 gap-0.5 px-1 text-[9px]"><Handshake className="h-2.5 w-2.5" />{coopCount}</Badge>}
{attCount > 0 && <Badge variant="outline" className="h-4 gap-0.5 px-1 text-[9px]"><Paperclip className="h-2.5 w-2.5" />{attCount}</Badge>}
</div>
)}
</div>
);
})}
{tasks.length === 0 && extraCount === 0 && (
<div className="py-3 text-center text-[11px] text-muted-foreground"></div>
)}
</div>
</div>
);
})}
</div>
)}
{viewMode === "list" && (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[90px] text-[11px]"></TableHead>
<TableHead className="text-[11px]"></TableHead>
<TableHead className="w-[65px] text-[11px]"></TableHead>
<TableHead className="w-[55px] text-center text-[11px]"></TableHead>
<TableHead className="w-[80px] text-[11px]"></TableHead>
<TableHead className="w-[70px] text-[11px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredTasks
.sort((a, b) => {
const ad = a.status !== "완료" && new Date(a.end) < today;
const bd = b.status !== "완료" && new Date(b.end) < today;
if (ad && !bd) return -1;
if (!ad && bd) return 1;
const ord: Record<string, number> = { 진행중: 0, 대기: 1, 검토중: 2, 완료: 3 };
return (ord[a.status] ?? 9) - (ord[b.status] ?? 9);
})
.map((t) => {
const isDelay = t.status !== "완료" && new Date(t.end) < today;
const displayStatus = isDelay ? "지연" : t.status;
const isSelected = selectedTaskKey === `${t.projectId}||${t.name}`;
return (
<TableRow
key={`${t.projectId}-${t.name}`}
className={cn("cursor-pointer", isSelected && "bg-accent")}
onClick={() => handleSelectTask(t.projectId, t.name)}
>
<TableCell className="text-[10px]">
<span className="font-semibold text-primary">{t.projectId}</span>
<br />
<span className="text-muted-foreground">{t.projectName}</span>
</TableCell>
<TableCell className="text-xs font-medium">{t.name}</TableCell>
<TableCell className="text-[11px]">{t.category}</TableCell>
<TableCell className="text-center">
<Badge className={cn("text-[10px]", STATUS_STYLES[displayStatus])}>{displayStatus}</Badge>
</TableCell>
<TableCell className={cn("text-[11px]", isDelay && "font-semibold text-destructive")}>{t.end}</TableCell>
<TableCell>
<div className="flex items-center gap-1.5">
<div className="h-1 w-12 overflow-hidden rounded-full bg-muted">
<div className={cn("h-full rounded-full", getProgressBg(t.progress))} style={{ width: `${t.progress}%` }} />
</div>
<span className="text-[10px]">{t.progress}%</span>
</div>
</TableCell>
</TableRow>
);
})}
{filteredTasks.length === 0 && (
<TableRow>
<TableCell colSpan={6} className="py-8 text-center text-xs text-muted-foreground"> </TableCell>
</TableRow>
)}
</TableBody>
</Table>
)}
{viewMode === "timesheet" && (
<div>
<div className="mb-2 flex items-center gap-2">
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={() => setTimesheetWeekOffset((p) => p - 1)}>
<ChevronLeft className="h-3 w-3" />
</Button>
<span className="text-sm font-semibold">
<Calendar className="mr-1 inline h-3.5 w-3.5" />
{timesheetData.ws.getMonth() + 1}/{timesheetData.ws.getDate()} ~ {timesheetData.we.getMonth() + 1}/{timesheetData.we.getDate()}
</span>
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={() => setTimesheetWeekOffset((p) => p + 1)}>
<ChevronRight className="h-3 w-3" />
</Button>
{timesheetWeekOffset !== 0 && (
<Button variant="outline" size="sm" className="h-7 text-xs text-primary" onClick={() => setTimesheetWeekOffset(0)}></Button>
)}
<span className="ml-auto text-xs text-muted-foreground">
<strong className="text-primary">{timesheetData.gt}h</strong>
</span>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead className="min-w-[120px] text-[11px]">/</TableHead>
{timesheetData.wds.map((d, i) => (
<TableHead
key={i}
className={cn(
"text-center text-[10px]",
i >= 5 && "bg-red-50 dark:bg-red-900/10",
d.toDateString() === today.toDateString() && "bg-blue-50 text-primary dark:bg-blue-900/10"
)}
>
{timesheetData.dn[i]}
<br />
<span className="text-[9px]">{d.getMonth() + 1}/{d.getDate()}</span>
</TableHead>
))}
<TableHead className="text-center text-[11px] font-bold"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{Object.keys(timesheetData.ph).length === 0 && (
<TableRow>
<TableCell colSpan={9} className="py-8 text-center text-xs text-muted-foreground"> </TableCell>
</TableRow>
)}
{Object.values(timesheetData.ph).map((p) => (
<React.Fragment key={p.id}>
<TableRow className="bg-muted/30">
<TableCell className="text-[10px] font-bold text-primary">{p.id} {p.name}</TableCell>
{p.days.map((v, i) => (
<TableCell key={i} className={cn("text-center text-[11px] font-semibold", i >= 5 && "bg-red-50 dark:bg-red-900/10", v > 0 ? "" : "text-muted-foreground/30")}>
{v > 0 ? `${v}h` : "-"}
</TableCell>
))}
<TableCell className="text-center text-xs font-bold text-primary">{p.total}h</TableCell>
</TableRow>
{Object.entries(p.tasks).map(([tn, days]) => {
const tt = days.reduce((s, v) => s + v, 0);
return (
<TableRow key={tn}>
<TableCell className="pl-4 text-[10px] text-muted-foreground"> {tn}</TableCell>
{days.map((v, i) => (
<TableCell key={i} className={cn("text-center text-[10px]", i >= 5 && "bg-red-50 dark:bg-red-900/10", v > 0 ? "" : "text-muted-foreground/30")}>
{v > 0 ? `${v}h` : "-"}
</TableCell>
))}
<TableCell className="text-center text-[10px] font-semibold">{tt}h</TableCell>
</TableRow>
);
})}
</React.Fragment>
))}
<TableRow className="bg-blue-50 font-bold dark:bg-blue-900/10">
<TableCell className="text-[11px]"></TableCell>
{timesheetData.dt.map((v, i) => (
<TableCell key={i} className={cn("text-center text-[11px]", i >= 5 && "bg-red-50 dark:bg-red-900/10", v > 0 ? "text-primary" : "text-muted-foreground/30")}>
{v > 0 ? `${v}h` : "-"}
</TableCell>
))}
<TableCell className="text-center text-sm font-bold text-primary">{timesheetData.gt}h</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
)}
</div>
</ScrollArea>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* 오른쪽: 상세 패널 */}
<ResizablePanel defaultSize={55} minSize={30}>
<div className="flex h-full flex-col overflow-hidden rounded-lg border bg-card">
{!selectedTask ? (
<div className="flex h-full flex-col items-center justify-center gap-2 text-muted-foreground">
<PointerIcon className="h-8 w-8" />
<span className="text-sm"> </span>
</div>
) : (
<>
{/* 상세 헤더 */}
<div className="flex shrink-0 items-center justify-between border-b bg-muted/30 px-3 py-2">
<span className="text-sm font-bold">{selectedTask.task.name}</span>
<Button variant="ghost" size="sm" className="h-6 w-6 p-0" onClick={() => setSelectedTaskKey(null)}>
<X className="h-3.5 w-3.5" />
</Button>
</div>
{/* 업무 정보 바 */}
<div className="shrink-0 border-b bg-muted/10 px-3 py-2">
<div className="grid grid-cols-[auto_1fr_auto_1fr] gap-x-3 gap-y-0.5 text-xs">
<span className="text-muted-foreground"></span>
<span className="font-semibold text-primary">{selectedTask.projectId} {selectedTask.projectName}</span>
<span className="text-muted-foreground"></span>
<span className="flex items-center gap-1.5">
<Badge className={cn("text-[10px]", STATUS_STYLES[selectedTask.task.status])}>{selectedTask.task.status}</Badge>
{selectedTask.task.subItems.length > 0 && <Badge variant="outline" className="text-[9px]"></Badge>}
</span>
<span className="text-muted-foreground"></span>
<span className="font-semibold">{selectedTask.task.category}</span>
<span className="text-muted-foreground"></span>
<span className="font-semibold">{selectedTask.task.start} ~ {selectedTask.task.end}</span>
</div>
<div className="mt-1.5 flex items-center gap-2">
<Progress value={selectedTask.task.subItems.length > 0 ? calcAutoProgress(selectedTask.task) : selectedTask.task.progress} className="h-1.5 flex-1" />
<span className={cn("text-sm font-bold", getProgressColor(selectedTask.task.subItems.length > 0 ? calcAutoProgress(selectedTask.task) : selectedTask.task.progress))}>
{selectedTask.task.subItems.length > 0 ? calcAutoProgress(selectedTask.task) : selectedTask.task.progress}%
</span>
</div>
</div>
{/* 수행항목 + 기록 분할 */}
<ResizablePanelGroup direction="horizontal" className="min-h-0 flex-1">
{/* 수행항목 */}
<ResizablePanel defaultSize={40} minSize={20}>
<div className="flex h-full flex-col">
<div className="shrink-0 border-b bg-muted/30 px-3 py-1.5 text-xs font-bold">
<ClipboardList className="mr-1 inline h-3.5 w-3.5" />
</div>
<ScrollArea className="flex-1">
{/* 추가 입력 */}
<div className="flex gap-1 border-b bg-muted/10 p-2">
<Input value={newSiName} onChange={(e) => setNewSiName(e.target.value)} placeholder="수행항목 추가..." className="h-6 flex-1 text-xs" />
<Input value={newSiWeight} onChange={(e) => setNewSiWeight(e.target.value)} placeholder="%" type="number" className="h-6 w-10 text-xs" min={1} max={100} />
<Button size="sm" className="h-6 px-2 text-[10px]" onClick={handleAddSubItem}>
<Plus className="mr-0.5 h-3 w-3" />
</Button>
</div>
{/* 수행항목 리스트 */}
{selectedTask.task.subItems.map((si, idx) => {
const done = si.progress >= 100;
const siLogs = selectedTask.task.workLogs.filter((wl) => wl.subItemId == si.id || String(wl.subItemId) === String(si.id));
const isSelected = selectedSubItemId === si.id;
return (
<div
key={si.id}
className={cn(
"flex cursor-pointer items-center gap-1.5 border-b px-2.5 py-1.5 transition-colors hover:bg-accent/50",
isSelected && "border-l-2 border-l-primary bg-accent"
)}
onClick={() => { setSelectedSubItemId(si.id); setEditingLogIdx(-1); }}
>
<Checkbox
checked={done}
onCheckedChange={() => handleToggleSubCheck(idx)}
onClick={(e) => e.stopPropagation()}
className="h-4 w-4"
/>
<span className={cn("min-w-0 flex-1 truncate text-xs font-semibold", done && "text-muted-foreground line-through")}>
{si.name}
</span>
<Badge variant="outline" className="h-4 px-1 text-[9px]">{si.weight}%</Badge>
<div className="h-1 w-10 overflow-hidden rounded-full bg-muted">
<div className={cn("h-full rounded-full", getProgressBg(si.progress))} style={{ width: `${si.progress}%` }} />
</div>
<span className={cn("min-w-[28px] text-right text-[10px] font-bold", getProgressColor(si.progress))}>{si.progress}%</span>
<span className="min-w-[16px] text-center text-[10px] text-muted-foreground">{siLogs.length}</span>
<Button variant="ghost" size="sm" className="h-5 w-5 p-0 opacity-20 hover:opacity-100" onClick={(e) => { e.stopPropagation(); handleDeleteSubItem(idx); }}>
<Trash2 className="h-3 w-3" />
</Button>
</div>
);
})}
{/* 요약 */}
{selectedTask.task.subItems.length > 0 && (
<div className="flex items-center justify-between border-b bg-blue-50 px-2.5 py-1 text-[11px] dark:bg-blue-900/10">
<span>
: <strong className="text-primary">{calcAutoProgress(selectedTask.task)}%</strong>
{selectedTask.task.subItems.reduce((s, i) => s + i.weight, 0) !== 100 && (
<span className="text-destructive"> (:{selectedTask.task.subItems.reduce((s, i) => s + i.weight, 0)}%)</span>
)}
</span>
<span className="text-muted-foreground">
{selectedTask.task.subItems.filter((i) => i.progress >= 100).length}/{selectedTask.task.subItems.length}
</span>
</div>
)}
{/* 일반 수행기록 */}
<div
className={cn(
"flex cursor-pointer items-center gap-1.5 border-t px-2.5 py-1.5 text-xs font-bold text-muted-foreground transition-colors hover:bg-accent/50",
selectedSubItemId === "__unassigned__" && "border-l-2 border-l-primary bg-accent"
)}
onClick={() => { setSelectedSubItemId("__unassigned__"); setEditingLogIdx(-1); }}
>
<FileEdit className="h-3.5 w-3.5" />
<span className="ml-1 text-[10px] font-normal text-muted-foreground">
({selectedTask.task.workLogs.filter((wl) => !wl.subItemId).length})
</span>
</div>
</ScrollArea>
</div>
</ResizablePanel>
<ResizableHandle />
{/* 수행기록 */}
<ResizablePanel defaultSize={60} minSize={30}>
<div className="flex h-full flex-col">
<div className="shrink-0 border-b bg-muted/30 px-3 py-1.5 text-xs font-bold">
<FileEdit className="mr-1 inline h-3.5 w-3.5" />
</div>
<ScrollArea className="flex-1">
{selectedSubItemId === "__unassigned__" ? (
<RenderLogs
title="일반 수행기록"
logs={selectedTask.task.workLogs
.map((wl, i) => ({ ...wl, _idx: i }))
.filter((wl) => !wl.subItemId)
.sort((a, b) => b.startDt.localeCompare(a.startDt))}
editingLogIdx={editingLogIdx}
editForm={editForm}
setEditForm={setEditForm}
onStartEdit={handleStartEdit}
onStartNew={() => handleStartNewLog(null)}
onSave={(logIdx) => handleSaveLog(logIdx, null)}
onCancel={() => setEditingLogIdx(-1)}
onDelete={handleDeleteLog}
onOpenAtt={openAttModal}
onOpenPr={openPrModal}
onOpenCr={openCrModal}
/>
) : (
(() => {
const si = selectedTask.task.subItems.find((s) => s.id === selectedSubItemId);
if (!si) return (
<div className="flex h-full flex-col items-center justify-center gap-1 text-muted-foreground">
<PointerIcon className="h-6 w-6" />
<span className="text-xs"> </span>
</div>
);
const siLogs = selectedTask.task.workLogs
.map((wl, i) => ({ ...wl, _idx: i }))
.filter((wl) => wl.subItemId == si.id || String(wl.subItemId) === String(si.id))
.sort((a, b) => b.startDt.localeCompare(a.startDt));
return (
<>
<div className="border-b bg-muted/10 p-2.5">
<div className="flex items-center gap-2">
<span className="text-sm font-bold">{si.name}</span>
<Badge variant="outline" className="text-[9px]">{si.weight}%</Badge>
</div>
<div className="mt-1.5 flex items-center gap-2">
<Slider
className="flex-1"
value={[si.progress]}
min={0}
max={100}
step={5}
onValueChange={([v]) => handleUpdateSubProgress(si.id, v)}
/>
<span className={cn("min-w-[32px] text-right text-xs font-bold", getProgressColor(si.progress))}>{si.progress}%</span>
</div>
<div className="mt-0.5 text-[10px] text-muted-foreground">{siLogs.length} </div>
</div>
<RenderLogs
title={si.name}
logs={siLogs}
editingLogIdx={editingLogIdx}
editForm={editForm}
setEditForm={setEditForm}
onStartEdit={handleStartEdit}
onStartNew={() => handleStartNewLog(si.id)}
onSave={(logIdx) => handleSaveLog(logIdx, si.id)}
onCancel={() => setEditingLogIdx(-1)}
onDelete={handleDeleteLog}
onOpenAtt={openAttModal}
onOpenPr={openPrModal}
onOpenCr={openCrModal}
/>
</>
);
})()
)}
</ScrollArea>
</div>
</ResizablePanel>
</ResizablePanelGroup>
</>
)}
</div>
</ResizablePanel>
</ResizablePanelGroup>
{/* 첨부파일 모달 */}
<Dialog open={attModalOpen} onOpenChange={setAttModalOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[600px]">
<DialogHeader>
<DialogTitle className="text-base"><Paperclip className="mr-1.5 inline h-4 w-4" /> ({modalAttachments.length})</DialogTitle>
<DialogDescription className="text-xs"> .</DialogDescription>
</DialogHeader>
<div className="space-y-2">
{modalAttachments.map((f, i) => (
<div key={f.id} className="flex items-center gap-2 rounded-md border bg-muted/30 p-2">
{getFileIcon(f.type)}
<div className="flex-1">
<div className="text-xs font-semibold">{f.name}</div>
<div className="text-[10px] text-muted-foreground">{f.type} · {f.size}</div>
</div>
<Button variant="outline" size="sm" className="h-6 text-[10px] text-destructive" onClick={() => setModalAttachments((p) => p.filter((_, idx) => idx !== i))}></Button>
</div>
))}
<div
className="cursor-pointer rounded-lg border-2 border-dashed p-4 text-center transition-colors hover:border-primary hover:bg-accent/50"
onClick={() => {
const name = prompt("파일명 (예: photo.jpg, drawing.dwg)");
if (!name) return;
setModalAttachments((p) => [...p, { id: Date.now(), name, type: getFileType(name), size: `${(Math.random() * 10 + 0.5).toFixed(1)}MB` }]);
}}
>
<Upload className="mx-auto h-5 w-5 text-muted-foreground" />
<div className="mt-1 text-xs text-muted-foreground"> </div>
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="outline" onClick={() => setAttModalOpen(false)} className="h-8 text-xs"></Button>
<Button onClick={saveAttModal} className="h-8 text-xs"></Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 구매요청 모달 */}
<Dialog open={prModalOpen} onOpenChange={setPrModalOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[600px]">
<DialogHeader>
<DialogTitle className="text-base"><ShoppingCart className="mr-1.5 inline h-4 w-4" /> </DialogTitle>
<DialogDescription className="text-xs"> .</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="text-xs"> *</Label>
<Input value={prForm.item} onChange={(e) => setPrForm((p) => ({ ...p, item: e.target.value }))} placeholder="예: LM가이드 HSR15-R" className="h-7 text-xs" />
</div>
<div className="grid grid-cols-2 gap-1">
<div>
<Label className="text-xs"></Label>
<Input value={prForm.qty} onChange={(e) => setPrForm((p) => ({ ...p, qty: e.target.value }))} type="number" className="h-7 text-xs" min={1} />
</div>
<div>
<Label className="text-xs"></Label>
<Select value={prForm.unit} onValueChange={(v) => setPrForm((p) => ({ ...p, unit: v }))}>
<SelectTrigger className="h-7 text-xs" size="xs"><SelectValue /></SelectTrigger>
<SelectContent>
{["EA", "SET", "BOX", "M", "KG"].map((u) => (<SelectItem key={u} value={u}>{u}</SelectItem>))}
</SelectContent>
</Select>
</div>
</div>
</div>
<div>
<Label className="text-xs"></Label>
<Input value={prForm.reason} onChange={(e) => setPrForm((p) => ({ ...p, reason: e.target.value }))} placeholder="구매 사유 입력" className="h-7 text-xs" />
</div>
<div className="flex items-end gap-2">
<div className="flex-1">
<Label className="text-xs"></Label>
<Select value={prForm.status} onValueChange={(v) => setPrForm((p) => ({ ...p, status: v }))}>
<SelectTrigger className="h-7 text-xs" size="xs"><SelectValue /></SelectTrigger>
<SelectContent>
{["요청", "견적중", "발주완료", "입고완료"].map((s) => (<SelectItem key={s} value={s}>{s}</SelectItem>))}
</SelectContent>
</Select>
</div>
<Button size="sm" className="h-7 text-xs" onClick={() => {
if (!prForm.item.trim()) return;
setModalPurchaseReqs((p) => [...p, { id: Date.now(), item: prForm.item.trim(), qty: parseInt(prForm.qty) || 1, unit: prForm.unit, reason: prForm.reason, status: prForm.status }]);
setPrForm((p) => ({ ...p, item: "", reason: "" }));
}}>
<Plus className="mr-0.5 h-3 w-3" />
</Button>
</div>
<div className="text-xs font-semibold"> ({modalPurchaseReqs.length})</div>
{modalPurchaseReqs.length === 0 && <div className="py-3 text-center text-xs text-muted-foreground"> .</div>}
{modalPurchaseReqs.map((pr, i) => (
<div key={pr.id} className="flex items-center gap-2 rounded-md border bg-muted/30 p-2">
<ShoppingCart className="h-3.5 w-3.5" />
<strong className="text-xs">{pr.item}</strong>
<span className="text-[10px] text-muted-foreground">{pr.qty} {pr.unit}</span>
{pr.reason && <span className="text-[10px] text-muted-foreground">{pr.reason}</span>}
<Badge className={cn("text-[9px]", PR_STATUS_STYLES[pr.status])}>{pr.status}</Badge>
<Button variant="ghost" size="sm" className="ml-auto h-5 w-5 p-0 opacity-30 hover:opacity-100" onClick={() => setModalPurchaseReqs((p) => p.filter((_, idx) => idx !== i))}>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="outline" onClick={() => setPrModalOpen(false)} className="h-8 text-xs"></Button>
<Button onClick={savePrModal} className="h-8 text-xs"></Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 협조요청 모달 */}
<Dialog open={crModalOpen} onOpenChange={setCrModalOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[600px]">
<DialogHeader>
<DialogTitle className="text-base"><Handshake className="mr-1.5 inline h-4 w-4" /> </DialogTitle>
<DialogDescription className="text-xs"> .</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div>
<Label className="text-xs"> *</Label>
<Input value={crForm.title} onChange={(e) => setCrForm((p) => ({ ...p, title: e.target.value }))} placeholder="협조 요청 제목" className="h-7 text-xs" />
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<Label className="text-xs"> *</Label>
<Select value={crForm.toUser} onValueChange={(v) => setCrForm((p) => ({ ...p, toUser: v }))}>
<SelectTrigger className="h-7 text-xs" size="xs"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{USERS.filter((u) => u !== currentUser).map((u) => (<SelectItem key={u} value={u}>{u} ({USER_INFO[u].role})</SelectItem>))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs"></Label>
<Input type="date" value={crForm.dueDate} onChange={(e) => setCrForm((p) => ({ ...p, dueDate: e.target.value }))} className="h-7 text-xs" />
</div>
</div>
<div>
<Label className="text-xs"> </Label>
<Textarea value={crForm.desc} onChange={(e) => setCrForm((p) => ({ ...p, desc: e.target.value }))} placeholder="상세 요청 내용" className="min-h-[50px] text-xs" rows={2} />
</div>
<div className="text-right">
<Button size="sm" className="h-7 text-xs" onClick={() => {
if (!crForm.title.trim() || !crForm.toUser) return;
setModalCoopReqs((p) => [...p, { id: `CR-${Date.now()}`, toUser: crForm.toUser, toDept: USER_INFO[crForm.toUser]?.role || "", title: crForm.title.trim(), desc: crForm.desc, status: "요청", dueDate: crForm.dueDate, responses: [] }]);
setCrForm((p) => ({ ...p, title: "", desc: "" }));
}}>
<Plus className="mr-0.5 h-3 w-3" />
</Button>
</div>
<div className="text-xs font-semibold"> ({modalCoopReqs.length})</div>
{modalCoopReqs.length === 0 && <div className="py-3 text-center text-xs text-muted-foreground"> .</div>}
{modalCoopReqs.map((cr, i) => (
<div key={cr.id} className="space-y-1 rounded-md border bg-muted/30 p-2">
<div className="flex items-center gap-2">
<Handshake className="h-3.5 w-3.5" />
<strong className="flex-1 text-xs">{cr.title}</strong>
<span className="text-[10px] text-muted-foreground">{cr.toUser} ({cr.toDept})</span>
<Badge className={cn("text-[9px]", CR_STATUS_STYLES[cr.status])}>{cr.status}</Badge>
<Button variant="ghost" size="sm" className="h-5 w-5 p-0 opacity-30 hover:opacity-100" onClick={() => setModalCoopReqs((p) => p.filter((_, idx) => idx !== i))}>
<Trash2 className="h-3 w-3" />
</Button>
</div>
{cr.desc && <div className="pl-5 text-[11px] text-muted-foreground">{cr.desc}</div>}
<div className="pl-5 text-[10px] text-muted-foreground">: {cr.dueDate}</div>
{(cr.responses || []).map((r, ri) => (
<div key={ri} className="pl-5 text-[10px] text-muted-foreground">
<strong className="text-primary">{r.user}</strong>: {r.content} <span className="text-muted-foreground">({r.date})</span>
</div>
))}
{cr.toUser === currentUser && cr.status !== "완료" && CR_NEXT[cr.status] && (
<div className="pl-5">
<Button size="sm" className="h-5 px-2 text-[10px]" onClick={() => {
setModalCoopReqs((p) => p.map((c, idx) => idx === i ? { ...c, status: CR_NEXT[c.status] || c.status } : c));
}}>
{CR_NEXT[cr.status]}
</Button>
</div>
)}
</div>
))}
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="outline" onClick={() => setCrModalOpen(false)} className="h-8 text-xs"></Button>
<Button onClick={saveCrModal} className="h-8 text-xs"></Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
// ========== 수행기록 렌더 서브컴포넌트 ==========
interface RenderLogsProps {
title: string;
logs: (WorkLog & { _idx: number })[];
editingLogIdx: number;
editForm: { startDt: string; endDt: string; desc: string };
setEditForm: React.Dispatch<React.SetStateAction<{ startDt: string; endDt: string; desc: string }>>;
onStartEdit: (idx: number) => void;
onStartNew: () => void;
onSave: (logIdx: number) => void;
onCancel: () => void;
onDelete: (idx: number) => void;
onOpenAtt: (idx: number) => void;
onOpenPr: (idx: number) => void;
onOpenCr: (idx: number) => void;
}
function RenderLogs({ title, logs, editingLogIdx, editForm, setEditForm, onStartEdit, onStartNew, onSave, onCancel, onDelete, onOpenAtt, onOpenPr, onOpenCr }: RenderLogsProps) {
return (
<div>
{logs.length === 0 && editingLogIdx !== -2 && (
<div className="flex flex-col items-center justify-center gap-1 py-8 text-muted-foreground">
<FileEdit className="h-6 w-6" />
<span className="text-xs"> </span>
</div>
)}
{logs.map((log) => {
const idx = log._idx;
if (editingLogIdx === idx) {
return (
<EditLogRow
key={idx}
logIdx={idx}
editForm={editForm}
setEditForm={setEditForm}
onSave={onSave}
onCancel={onCancel}
onOpenAtt={onOpenAtt}
onOpenPr={onOpenPr}
onOpenCr={onOpenCr}
attCount={(log.attachments || []).length}
prCount={(log.purchaseReqs || []).length}
crCount={(log.coopReqs || []).length}
/>
);
}
const attC = (log.attachments || []).length;
const prC = (log.purchaseReqs || []).length;
const crC = (log.coopReqs || []).length;
return (
<div
key={idx}
className="flex cursor-pointer items-center gap-1.5 border-b px-2.5 py-1.5 text-xs transition-colors hover:bg-accent/50"
onClick={() => onStartEdit(idx)}
title="클릭하여 수정"
>
<span className="min-w-[75px] text-[11px] text-muted-foreground">{fmtDtShort(log.startDt)}~{log.endDt.substring(11, 16)}</span>
<Badge variant="outline" className="h-4 min-w-[26px] justify-center px-1 text-[10px] font-bold text-primary">{log.hours}h</Badge>
<span className="min-w-0 flex-1 truncate">{log.desc}</span>
<div className="flex gap-1">
{attC > 0 && (
<Badge variant="outline" className="h-4 cursor-pointer gap-0.5 px-1 text-[9px] hover:border-primary" onClick={(e) => { e.stopPropagation(); onOpenAtt(idx); }}>
<Paperclip className="h-2.5 w-2.5" />{attC}
</Badge>
)}
{prC > 0 && (
<Badge className="h-4 cursor-pointer gap-0.5 bg-amber-50 px-1 text-[9px] text-amber-800 hover:bg-amber-100 dark:bg-amber-900/20" onClick={(e) => { e.stopPropagation(); onOpenPr(idx); }}>
<ShoppingCart className="h-2.5 w-2.5" />{prC}
</Badge>
)}
{crC > 0 && (
<Badge className="h-4 cursor-pointer gap-0.5 bg-pink-50 px-1 text-[9px] text-pink-800 hover:bg-pink-100 dark:bg-pink-900/20" onClick={(e) => { e.stopPropagation(); onOpenCr(idx); }}>
<Handshake className="h-2.5 w-2.5" />{crC}
</Badge>
)}
</div>
<Button variant="ghost" size="sm" className="h-5 w-5 p-0 opacity-20 hover:opacity-100" onClick={(e) => { e.stopPropagation(); onDelete(idx); }}>
<Trash2 className="h-3 w-3" />
</Button>
</div>
);
})}
{editingLogIdx === -2 && (
<EditLogRow
logIdx={-1}
editForm={editForm}
setEditForm={setEditForm}
onSave={onSave}
onCancel={onCancel}
onOpenAtt={onOpenAtt}
onOpenPr={onOpenPr}
onOpenCr={onOpenCr}
attCount={0}
prCount={0}
crCount={0}
/>
)}
<Button variant="outline" size="sm" className="m-2 h-6 border-dashed text-[10px] text-muted-foreground" onClick={onStartNew}>
<Plus className="mr-0.5 h-3 w-3" />
</Button>
</div>
);
}
// ========== 편집 행 ==========
interface EditLogRowProps {
logIdx: number;
editForm: { startDt: string; endDt: string; desc: string };
setEditForm: React.Dispatch<React.SetStateAction<{ startDt: string; endDt: string; desc: string }>>;
onSave: (logIdx: number) => void;
onCancel: () => void;
onOpenAtt: (idx: number) => void;
onOpenPr: (idx: number) => void;
onOpenCr: (idx: number) => void;
attCount: number;
prCount: number;
crCount: number;
}
function EditLogRow({ logIdx, editForm, setEditForm, onSave, onCancel, onOpenAtt, onOpenPr, onOpenCr, attCount, prCount, crCount }: EditLogRowProps) {
const hours = calcHours(editForm.startDt, editForm.endDt);
return (
<div className="space-y-1.5 rounded-md border border-emerald-300 bg-emerald-50/50 p-2.5 dark:border-emerald-800 dark:bg-emerald-900/10">
<div className="flex flex-wrap items-center gap-1.5">
<Input type="datetime-local" value={editForm.startDt} onChange={(e) => setEditForm((p) => ({ ...p, startDt: e.target.value }))} className="h-6 w-[155px] text-[11px]" />
<span className="text-xs text-muted-foreground">~</span>
<Input type="datetime-local" value={editForm.endDt} onChange={(e) => setEditForm((p) => ({ ...p, endDt: e.target.value }))} className="h-6 w-[155px] text-[11px]" />
<span className="min-w-[30px] text-xs font-semibold text-primary">{hours}h</span>
</div>
<Textarea
value={editForm.desc}
onChange={(e) => setEditForm((p) => ({ ...p, desc: e.target.value }))}
placeholder="수행 내용을 입력하세요..."
className="min-h-[32px] text-xs"
rows={1}
/>
<div className="flex items-center justify-between">
<div className="flex gap-1">
<Button variant="outline" size="sm" className="h-5 px-1.5 text-[9px]" onClick={() => onOpenAtt(logIdx)}>
<Paperclip className="mr-0.5 h-2.5 w-2.5" />{attCount > 0 && ` (${attCount})`}
</Button>
<Button variant="outline" size="sm" className="h-5 px-1.5 text-[9px]" onClick={() => onOpenPr(logIdx)}>
<ShoppingCart className="mr-0.5 h-2.5 w-2.5" />{prCount > 0 && ` (${prCount})`}
</Button>
<Button variant="outline" size="sm" className="h-5 px-1.5 text-[9px]" onClick={() => onOpenCr(logIdx)}>
<Handshake className="mr-0.5 h-2.5 w-2.5" />{crCount > 0 && ` (${crCount})`}
</Button>
</div>
<div className="flex gap-1">
<Button variant="outline" size="sm" className="h-5 px-2 text-[10px]" onClick={onCancel}></Button>
<Button size="sm" className="h-5 bg-emerald-500 px-2 text-[10px] hover:bg-emerald-600" onClick={() => onSave(logIdx)}></Button>
</div>
</div>
</div>
);
}