"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 = { 최기구: { 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 ; case "drawing": return ; case "3d": return ; case "document": return ; default: return ; } } 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 = { 대기: "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 = { 긴급: "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 = { 요청: "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 = { 요청: "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 = { 요청: "접수", 접수: "진행중", 진행중: "완료", }; // ========== 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(); 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([]); 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(null); const [selectedSubItemId, setSelectedSubItemId] = useState("__unassigned__"); // 편집 상태 const [editingLogIdx, setEditingLogIdx] = useState(-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([]); const [modalPurchaseReqs, setModalPurchaseReqs] = useState([]); const [modalCoopReqs, setModalCoopReqs] = useState([]); // 구매요청 폼 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([]); 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(); 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(() => { 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 = { 대기: [], 진행중: [], 검토중: [], 완료: [] }; filteredTasks.forEach((t) => { if (cols[t.status]) cols[t.status].push(t); }); return cols; }, [filteredTasks]); const kanbanConfig = [ { key: "대기", icon: , color: "border-muted-foreground/30", titleColor: "text-muted-foreground" }, { key: "진행중", icon: , color: "border-blue-500", titleColor: "text-blue-500" }, { key: "검토중", icon: , color: "border-amber-500", titleColor: "text-amber-500" }, { key: "완료", icon: , 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 }> = {}; 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 (
업무 목록을 불러오는 중...
); } return (
{/* 헤더 */}

내 업무 현황

{currentUser.charAt(0)}
{currentUser}
{USER_INFO[currentUser]?.role}
{todayStr}
{/* 검색 필터 */}
setFilterKeyword(e.target.value)} placeholder="업무명 검색..." className="h-7 w-[120px] pl-7 text-xs" />
{/* 통계 카드 */}
{[ { icon: , value: stats.total, label: "전체 업무", bg: "bg-blue-50 dark:bg-blue-900/20" }, { icon: , value: stats.progress, label: "진행중", bg: "bg-amber-50 dark:bg-amber-900/20" }, { icon: , value: stats.done, label: "완료", bg: "bg-emerald-50 dark:bg-emerald-900/20" }, { icon: , value: stats.delay, label: "지연", bg: "bg-red-50 dark:bg-red-900/20" }, { icon: , value: `${stats.weekHours}h`, label: "이번주 투입", bg: "bg-violet-50 dark:bg-violet-900/20" }, ].map((s, i) => (
{s.icon}
{s.value}
{s.label}
))}
{/* 메인 영역 */} {/* 왼쪽: 업무 현황 */}
업무 현황 setViewMode(v as any)}> 보드 목록 타임시트
{viewMode === "kanban" && (
{kanbanConfig.map((col) => { const tasks = kanbanCols[col.key] || []; const extraCount = col.key === "대기" ? receivedCoops.length : 0; return (
{col.icon}{col.key} {tasks.length + extraCount}
{col.key === "대기" && receivedCoops.map((cr, i) => (
협조 · {cr.fromUser}
{cr.title}
{cr.projectName}
{cr.dueDate}
))} {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 (
handleSelectTask(t.projectId, t.name)} >
{t.projectId} · {t.projectName}
{t.name}
{t.priority || "보통"} {t.category}
{t.progress}%
{dueText}
{(coopCount > 0 || attCount > 0) && (
{coopCount > 0 && {coopCount}} {attCount > 0 && {attCount}}
)}
); })} {tasks.length === 0 && extraCount === 0 && (
없음
)}
); })}
)} {viewMode === "list" && ( 프로젝트 업무명 유형 상태 종료일 진행률 {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 = { 진행중: 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 ( handleSelectTask(t.projectId, t.name)} > {t.projectId}
{t.projectName}
{t.name} {t.category} {displayStatus} {t.end}
{t.progress}%
); })} {filteredTasks.length === 0 && ( 검색 결과 없음 )}
)} {viewMode === "timesheet" && (
{timesheetData.ws.getMonth() + 1}/{timesheetData.ws.getDate()} ~ {timesheetData.we.getMonth() + 1}/{timesheetData.we.getDate()} {timesheetWeekOffset !== 0 && ( )} {timesheetData.gt}h
프로젝트/업무 {timesheetData.wds.map((d, 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]}
{d.getMonth() + 1}/{d.getDate()}
))} 합계
{Object.keys(timesheetData.ph).length === 0 && ( 기록 없음 )} {Object.values(timesheetData.ph).map((p) => ( {p.id} {p.name} {p.days.map((v, i) => ( = 5 && "bg-red-50 dark:bg-red-900/10", v > 0 ? "" : "text-muted-foreground/30")}> {v > 0 ? `${v}h` : "-"} ))} {p.total}h {Object.entries(p.tasks).map(([tn, days]) => { const tt = days.reduce((s, v) => s + v, 0); return ( └ {tn} {days.map((v, i) => ( = 5 && "bg-red-50 dark:bg-red-900/10", v > 0 ? "" : "text-muted-foreground/30")}> {v > 0 ? `${v}h` : "-"} ))} {tt}h ); })} ))} 합계 {timesheetData.dt.map((v, i) => ( = 5 && "bg-red-50 dark:bg-red-900/10", v > 0 ? "text-primary" : "text-muted-foreground/30")}> {v > 0 ? `${v}h` : "-"} ))} {timesheetData.gt}h
)}
{/* 오른쪽: 상세 패널 */}
{!selectedTask ? (
왼쪽에서 업무를 선택하세요
) : ( <> {/* 상세 헤더 */}
{selectedTask.task.name}
{/* 업무 정보 바 */}
프로젝트 {selectedTask.projectId} {selectedTask.projectName} 상태 {selectedTask.task.status} {selectedTask.task.subItems.length > 0 && 자동} 유형 {selectedTask.task.category} 기간 {selectedTask.task.start} ~ {selectedTask.task.end}
0 ? calcAutoProgress(selectedTask.task) : selectedTask.task.progress} className="h-1.5 flex-1" /> 0 ? calcAutoProgress(selectedTask.task) : selectedTask.task.progress))}> {selectedTask.task.subItems.length > 0 ? calcAutoProgress(selectedTask.task) : selectedTask.task.progress}%
{/* 수행항목 + 기록 분할 */} {/* 수행항목 */}
수행항목
{/* 추가 입력 */}
setNewSiName(e.target.value)} placeholder="수행항목 추가..." className="h-6 flex-1 text-xs" /> setNewSiWeight(e.target.value)} placeholder="%" type="number" className="h-6 w-10 text-xs" min={1} max={100} />
{/* 수행항목 리스트 */} {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 (
{ setSelectedSubItemId(si.id); setEditingLogIdx(-1); }} > handleToggleSubCheck(idx)} onClick={(e) => e.stopPropagation()} className="h-4 w-4" /> {si.name} {si.weight}%
{si.progress}% {siLogs.length}
); })} {/* 요약 */} {selectedTask.task.subItems.length > 0 && (
가중 진행률: {calcAutoProgress(selectedTask.task)}% {selectedTask.task.subItems.reduce((s, i) => s + i.weight, 0) !== 100 && ( (합계:{selectedTask.task.subItems.reduce((s, i) => s + i.weight, 0)}%) )} {selectedTask.task.subItems.filter((i) => i.progress >= 100).length}/{selectedTask.task.subItems.length}
)} {/* 일반 수행기록 */}
{ setSelectedSubItemId("__unassigned__"); setEditingLogIdx(-1); }} > 일반 수행기록 ({selectedTask.task.workLogs.filter((wl) => !wl.subItemId).length}건)
{/* 수행기록 */}
수행기록
{selectedSubItemId === "__unassigned__" ? ( ({ ...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 (
왼쪽에서 수행항목을 선택하세요
); 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 ( <>
{si.name} {si.weight}%
handleUpdateSubProgress(si.id, v)} /> {si.progress}%
{siLogs.length}건의 기록
handleStartNewLog(si.id)} onSave={(logIdx) => handleSaveLog(logIdx, si.id)} onCancel={() => setEditingLogIdx(-1)} onDelete={handleDeleteLog} onOpenAtt={openAttModal} onOpenPr={openPrModal} onOpenCr={openCrModal} /> ); })() )}
)}
{/* 첨부파일 모달 */} 첨부파일 ({modalAttachments.length}) 파일을 추가하거나 삭제할 수 있습니다.
{modalAttachments.map((f, i) => (
{getFileIcon(f.type)}
{f.name}
{f.type} · {f.size}
))}
{ 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` }]); }} >
클릭하여 파일 추가
{/* 구매요청 모달 */} 구매요청 관리 구매요청을 등록하고 관리합니다.
setPrForm((p) => ({ ...p, item: e.target.value }))} placeholder="예: LM가이드 HSR15-R" className="h-7 text-xs" />
setPrForm((p) => ({ ...p, qty: e.target.value }))} type="number" className="h-7 text-xs" min={1} />
setPrForm((p) => ({ ...p, reason: e.target.value }))} placeholder="구매 사유 입력" className="h-7 text-xs" />
등록된 구매요청 ({modalPurchaseReqs.length})
{modalPurchaseReqs.length === 0 &&
등록된 요청이 없습니다.
} {modalPurchaseReqs.map((pr, i) => (
{pr.item} {pr.qty} {pr.unit} {pr.reason && {pr.reason}} {pr.status}
))}
{/* 협조요청 모달 */} 협조요청 관리 협조요청을 등록하고 관리합니다.
setCrForm((p) => ({ ...p, title: e.target.value }))} placeholder="협조 요청 제목" className="h-7 text-xs" />
setCrForm((p) => ({ ...p, dueDate: e.target.value }))} className="h-7 text-xs" />