Files
pipeline/frontend/app/(main)/COMPANY_16/design/my-work/page.tsx
T

1959 lines
88 KiB
TypeScript

"use client";
import React, { useState, useMemo, useCallback, useEffect } from "react";
import {
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 { 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 { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
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-primary" },
: { role: "기구설계 파트", color: "bg-chart-2" },
: { role: "전장설계 파트", color: "bg-warning" },
: { role: "기구설계 파트", color: "bg-chart-3" },
SW: { role: "SW개발 파트", color: "bg-destructive" },
: { role: "조립/시운전 파트", color: "bg-chart-1" },
: { role: "구매/조달 파트", color: "bg-info" },
: { role: "설계팀 팀장", color: "bg-success" },
};
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-success";
if (p >= 40) return "text-primary";
if (p > 0) return "text-warning";
return "text-muted-foreground";
}
function getProgressBg(p: number) {
if (p >= 80) return "bg-success";
if (p >= 40) return "bg-primary";
if (p > 0) return "bg-warning";
return "bg-muted";
}
const STATUS_STYLES: Record<string, string> = {
: "bg-muted text-foreground",
: "bg-primary/10 text-primary",
: "bg-warning/10 text-warning",
: "bg-success/10 text-success",
: "bg-destructive/10 text-destructive",
};
const PRIORITY_STYLES: Record<string, string> = {
: "bg-destructive/10 text-destructive",
: "bg-warning/10 text-warning",
: "bg-muted text-foreground",
: "bg-success/10 text-success",
};
const PR_STATUS_STYLES: Record<string, string> = {
: "bg-destructive/10 text-destructive",
: "bg-warning/10 text-warning",
: "bg-primary/10 text-primary",
: "bg-success/10 text-success",
};
const CR_STATUS_STYLES: Record<string, string> = {
: "bg-warning/10 text-warning",
: "bg-warning/10 text-warning",
: "bg-primary/10 text-primary",
: "bg-success/10 text-success",
};
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-primary" />, color: "border-primary", titleColor: "text-primary" },
{ key: "검토중", icon: <Circle className="h-3.5 w-3.5 text-warning" />, color: "border-warning", titleColor: "text-warning" },
{ key: "완료", icon: <CheckCircle2 className="h-3.5 w-3.5 text-success" />, color: "border-success", titleColor: "text-success" },
];
// ===== 타임시트 =====
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-[calc(100vh-4rem)] flex-col gap-3 p-3">
{/* 헤더 */}
<div className="flex shrink-0 items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex flex-col gap-0.5">
<div className="flex items-center gap-1 text-[11px] text-muted-foreground">
<span></span>
<ChevronRight className="h-3 w-3" />
<span className="text-foreground"></span>
</div>
<h1 className="text-lg font-bold leading-tight"> </h1>
</div>
<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="shrink-0 rounded-lg border border-border bg-card px-5 py-4">
<div className="grid grid-cols-[repeat(auto-fill,minmax(160px,1fr))] items-end gap-3">
<div className="flex flex-col gap-1">
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"></span>
<Select value={filterProject} onValueChange={setFilterProject}>
<SelectTrigger className="h-9 w-full text-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>
</div>
<div className="flex flex-col gap-1">
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"></span>
<Select value={filterStatus} onValueChange={setFilterStatus}>
<SelectTrigger className="h-9 w-full text-xs">
<SelectValue placeholder="전체 상태" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__"> </SelectItem>
{["대기", "진행중", "검토중", "완료"].map((s) => (<SelectItem key={s} value={s}>{s}</SelectItem>))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1">
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"></span>
<Select value={filterPriority} onValueChange={setFilterPriority}>
<SelectTrigger className="h-9 w-full text-xs">
<SelectValue placeholder="전체 우선순위" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__"> </SelectItem>
{["긴급", "높음", "보통", "낮음"].map((s) => (<SelectItem key={s} value={s}>{s}</SelectItem>))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1">
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"></span>
<Select value={filterCategory} onValueChange={setFilterCategory}>
<SelectTrigger className="h-9 w-full text-xs">
<SelectValue placeholder="전체 유형" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__"> </SelectItem>
{["기구설계", "전장설계", "SW개발", "구매/조달", "조립/시운전", "검토/승인"].map((s) => (<SelectItem key={s} value={s}>{s}</SelectItem>))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1">
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"></span>
<div className="relative">
<Search className="absolute left-2.5 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-9 pl-8 text-xs"
/>
</div>
</div>
<div className="flex items-end justify-end gap-2">
<Button variant="outline" size="sm" className="h-9" onClick={handleResetFilter}>
<RotateCcw className="mr-1.5 h-4 w-4" />
</Button>
</div>
</div>
</div>
{/* 통계 요약 바 */}
<div className="shrink-0 rounded-lg border border-border bg-card px-5 py-3">
<div className="flex flex-wrap items-center gap-5">
<div className="flex items-center gap-2">
<div className="flex h-7 w-7 items-center justify-center rounded-md bg-primary/10">
<ClipboardList className="h-3.5 w-3.5 text-primary" />
</div>
<div className="leading-none">
<span className="text-sm font-bold">{stats.total}</span>
<span className="ml-1.5 text-[11px] text-muted-foreground"></span>
</div>
</div>
<div className="h-4 w-px bg-border" />
<div className="flex items-center gap-2">
<div className="flex h-7 w-7 items-center justify-center rounded-md bg-warning/10">
<Circle className="h-3.5 w-3.5 text-warning" />
</div>
<div className="leading-none">
<span className="text-sm font-bold">{stats.progress}</span>
<span className="ml-1.5 text-[11px] text-muted-foreground"></span>
</div>
</div>
<div className="flex items-center gap-2">
<div className="flex h-7 w-7 items-center justify-center rounded-md bg-success/10">
<CheckCircle2 className="h-3.5 w-3.5 text-success" />
</div>
<div className="leading-none">
<span className="text-sm font-bold">{stats.done}</span>
<span className="ml-1.5 text-[11px] text-muted-foreground"></span>
</div>
</div>
<div className="flex items-center gap-2">
<div className={cn("flex h-7 w-7 items-center justify-center rounded-md", stats.delay > 0 ? "bg-destructive/10" : "bg-muted")}>
<AlertCircle className={cn("h-3.5 w-3.5", stats.delay > 0 ? "text-destructive" : "text-muted-foreground")} />
</div>
<div className="leading-none">
<span className={cn("text-sm font-bold", stats.delay > 0 && "text-destructive")}>{stats.delay}</span>
<span className="ml-1.5 text-[11px] text-muted-foreground"></span>
</div>
</div>
<div className="h-4 w-px bg-border" />
<div className="flex items-center gap-2">
<div className="flex h-7 w-7 items-center justify-center rounded-md bg-info/10">
<Clock className="h-3.5 w-3.5 text-info" />
</div>
<div className="leading-none">
<span className="text-sm font-bold">{stats.weekHours}h</span>
<span className="ml-1.5 text-[11px] text-muted-foreground"> </span>
</div>
</div>
<div className="ml-auto flex items-center gap-2">
<span className="rounded-full border border-primary/15 bg-primary/8 px-2.5 py-0.5 font-mono text-[11px] font-bold text-primary">
{filteredTasks.length}
</span>
</div>
</div>
</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/50 px-4 py-2.5">
<div className="flex items-center gap-2">
<ClipboardList className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-semibold"> </span>
<span className="rounded-full border border-primary/15 bg-primary/8 px-2 py-0.5 font-mono text-[11px] font-bold text-primary">
{filteredTasks.length}
</span>
</div>
<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 border bg-muted/30 p-2">
<div className={cn("mb-2 flex items-center justify-between rounded-md border-l-[3px] bg-card px-2 py-1.5", col.color)}>
<span className={cn("flex items-center gap-1.5 text-xs font-bold", col.titleColor)}>
{col.icon}{col.key}
</span>
<span className="rounded-full border border-border bg-muted px-1.5 py-0.5 font-mono text-[10px] font-bold text-muted-foreground">{tasks.length + extraCount}</span>
</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-warning/30 bg-warning/10 p-2">
<div className="text-[10px] text-warning">
<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-success")}>
<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",
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-warning" : "text-success")}>
<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" && (
<EDataTable
columns={[
{ key: "projectId", label: "프로젝트", width: "w-[90px]", render: (_v, row) => (
<div className="text-[10px]">
<span className="font-semibold text-primary">{row.projectId}</span>
<br />
<span className="text-muted-foreground">{row.projectName}</span>
</div>
)},
{ key: "name", label: "업무명" },
{ key: "category", label: "유형", width: "w-[65px]" },
{ key: "status", label: "상태", width: "w-[55px]", align: "center", render: (_v, row) => {
const isDelay = row.status !== "완료" && new Date(row.end) < today;
const displayStatus = isDelay ? "지연" : row.status;
return <Badge className={cn("text-[10px]", STATUS_STYLES[displayStatus])}>{displayStatus}</Badge>;
}},
{ key: "end", label: "종료일", width: "w-[80px]", render: (v, row) => {
const isDelay = row.status !== "완료" && new Date(row.end) < today;
return <span className={cn("text-[11px]", isDelay && "font-semibold text-destructive")}>{v}</span>;
}},
{ key: "progress", label: "진행률", width: "w-[70px]", sortable: true, render: (v) => (
<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(v))} style={{ width: `${v}%` }} />
</div>
<span className="text-[10px]">{v}%</span>
</div>
)},
] as EDataTableColumn<MyTask>[]}
data={[...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);
})}
rowKey={(row) => `${row.projectId}||${row.name}`}
selectedId={selectedTaskKey}
onRowClick={(row) => handleSelectTask(row.projectId, row.name)}
emptyMessage="검색 결과가 없어요"
showPagination={false}
draggableColumns={false}
/>
)}
{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 className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="min-w-[120px] text-[11px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">/</TableHead>
{timesheetData.wds.map((d, i) => (
<TableHead
key={i}
className={cn(
"text-center text-[10px]",
i >= 5 && "bg-destructive/5",
d.toDateString() === today.toDateString() && "bg-primary/5 text-primary"
)}
>
{timesheetData.dn[i]}
<br />
<span className="text-[9px]">{d.getMonth() + 1}/{d.getDate()}</span>
</TableHead>
))}
<TableHead className="text-center text-[11px] font-bold text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{Object.keys(timesheetData.ph).length === 0 && (
<TableRow>
<TableCell colSpan={9} className="py-8 text-center text-[13px] text-muted-foreground"> </TableCell>
</TableRow>
)}
{Object.values(timesheetData.ph).map((p) => (
<React.Fragment key={p.id}>
<TableRow className="bg-muted/50">
<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-destructive/5", v > 0 ? "" : "text-muted-foreground/30")}>
{v > 0 ? `${v}h` : "-"}
</TableCell>
))}
<TableCell className="text-center text-[13px] 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-destructive/5", 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-primary/5 font-bold">
<TableCell className="text-[11px]"></TableCell>
{timesheetData.dt.map((v, i) => (
<TableCell key={i} className={cn("text-center text-[11px]", i >= 5 && "bg-destructive/5", 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-3 text-muted-foreground">
<div className="flex h-14 w-14 items-center justify-center rounded-full border-2 border-dashed border-border">
<PointerIcon className="h-6 w-6 text-muted-foreground/50" />
</div>
<div className="text-center">
<p className="text-sm font-medium"> </p>
<p className="text-xs text-muted-foreground/60"> </p>
</div>
</div>
) : (
<>
{/* 상세 헤더 */}
<div className="flex shrink-0 items-center justify-between border-b bg-muted/50 px-3 py-2">
<h3 className="text-[13px] font-bold">{selectedTask.task.name}</h3>
<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/50 px-3 py-1.5">
<h3 className="text-[13px] font-bold"></h3>
</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-[13px] 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-primary/5 px-2.5 py-1 text-[11px]">
<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/50 px-3 py-1.5">
<h3 className="text-[13px] font-bold"></h3>
</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/50 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/50 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/50 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-warning/10 px-1 text-[9px] text-warning hover:bg-warning/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-info/10 px-1 text-[9px] text-info hover:bg-info/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-primary/30 bg-primary/5 p-2.5">
<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-success px-2 text-[10px] hover:bg-success/90" onClick={() => onSave(logIdx)}></Button>
</div>
</div>
</div>
);
}