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