Files
pipeline/frontend/app/(main)/COMPANY_16/design/task-management/page.tsx
T

1289 lines
58 KiB
TypeScript

"use client";
import React, { useState, useMemo, useCallback, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
RefreshCw,
ClipboardList,
Pencil,
Inbox,
CheckCircle2,
XCircle,
Rocket,
Eye,
ArrowRight,
Check,
ChevronsUpDown,
UserCircle,
Loader2,
User,
Users,
Settings2,
} from "lucide-react";
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
import {
getDesignRequestList,
updateDesignRequest,
addRequestHistory,
createProject,
} from "@/lib/api/design";
import { getUserList } from "@/lib/api/user";
import { useAuth } from "@/hooks/useAuth";
import { useTableSettings } from "@/hooks/useTableSettings";
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
// --- Types ---
type SourceType = "dr" | "ecr";
type TaskStatus = "신규접수" | "검토중" | "승인완료" | "반려" | "프로젝트생성";
type Priority = "긴급" | "높음" | "보통" | "낮음";
type MainTab = "all" | "dr" | "ecr";
interface HistoryItem {
step: string;
date: string;
user: string;
desc: string;
}
interface TaskItem {
dbId: string;
id: string;
sourceType: SourceType;
date: string;
dueDate: string;
priority: Priority;
status: TaskStatus;
approvalStep: number;
targetName: string;
customer: string;
reqDept: string;
requester: string;
designer: string;
orderNo?: string;
designType?: string;
spec?: string;
changeType?: string;
drawingNo?: string;
reason?: string;
impact?: string[];
reviewMemo: string;
projectNo?: string;
history: HistoryItem[];
}
// --- 상태/우선순위 배지 색상 ---
const getStatusVariant = (status: TaskStatus) => {
switch (status) {
case "신규접수":
return "bg-primary/10 text-primary border-primary/20";
case "검토중":
return "bg-muted text-muted-foreground border-border";
case "승인완료":
return "bg-primary/15 text-primary border-primary/25";
case "반려":
return "bg-destructive/10 text-destructive border-destructive/20";
case "프로젝트생성":
return "bg-secondary text-secondary-foreground border-border";
default:
return "bg-muted text-muted-foreground border-border";
}
};
const getPriorityVariant = (priority: Priority) => {
switch (priority) {
case "긴급":
return "bg-destructive/10 text-destructive border-destructive/20";
case "높음":
return "bg-primary/10 text-primary border-primary/20";
case "보통":
return "bg-muted text-muted-foreground border-border";
case "낮음":
return "bg-muted text-muted-foreground border-border";
default:
return "bg-muted text-muted-foreground border-border";
}
};
const getSourceBadge = (type: SourceType) => {
if (type === "dr")
return "bg-primary/10 text-primary border-primary/20";
return "bg-secondary text-secondary-foreground border-border";
};
const getStepIcon = (step: string) => {
switch (step) {
case "접수": return <Inbox className="inline h-4 w-4 mr-1" />;
case "검토": return <Eye className="inline h-4 w-4 mr-1" />;
case "승인": return <CheckCircle2 className="inline h-4 w-4 mr-1" />;
case "반려": return <XCircle className="inline h-4 w-4 mr-1" />;
case "프로젝트": return <Rocket className="inline h-4 w-4 mr-1" />;
default: return <ClipboardList className="inline h-4 w-4 mr-1" />;
}
};
// --- API 응답 → TaskItem 매핑 ---
function mapApiToTaskItem(r: any): TaskItem {
const historyRaw = Array.isArray(r.history) ? r.history : [];
const history: HistoryItem[] = historyRaw.map((h: any) => ({
step: h.step || "",
date: h.history_date || "",
user: h.user_name || "",
desc: h.description || "",
}));
const impact = Array.isArray(r.impact) ? r.impact : [];
const formatDate = (d: string | null | undefined) =>
d ? (d.includes("T") ? d.split("T")[0] : d) : "";
return {
dbId: r.id,
id: r.request_no || r.id,
sourceType: (r.source_type === "ecr" ? "ecr" : "dr") as SourceType,
date: formatDate(r.request_date),
dueDate: formatDate(r.due_date),
priority: (r.priority || "보통") as Priority,
status: (r.status || "신규접수") as TaskStatus,
approvalStep: typeof r.approval_step === "number" ? r.approval_step : 0,
targetName: r.target_name || "",
customer: r.customer || "",
reqDept: r.req_dept || "",
requester: r.requester || "",
designer: r.designer || "",
orderNo: r.order_no,
designType: r.design_type,
spec: r.spec,
changeType: r.change_type,
drawingNo: r.drawing_no,
reason: r.reason,
impact: impact.length ? impact : undefined,
reviewMemo: r.review_memo || "",
projectNo: r.project_id,
history,
};
}
// --- 승인 프로세스 스텝 ---
const APPROVAL_STEPS = [
{ label: "접수", step: 0 },
{ label: "검토", step: 1 },
{ label: "내부승인", step: 2 },
{ label: "프로젝트", step: 3 },
];
// --- 현황 카드 설정 ---
const STAT_CARDS: { label: string; status: TaskStatus; color: string; textColor: string }[] = [
{ label: "신규접수", status: "신규접수", color: "bg-primary", textColor: "text-primary-foreground" },
{ label: "검토중", status: "검토중", color: "bg-muted border border-border", textColor: "text-foreground" },
{ label: "승인완료", status: "승인완료", color: "bg-primary/80", textColor: "text-primary-foreground" },
{ label: "반려", status: "반려", color: "bg-destructive", textColor: "text-destructive-foreground" },
{ label: "프로젝트", status: "프로젝트생성", color: "bg-secondary", textColor: "text-secondary-foreground" },
];
interface EmployeeOption {
userId: string;
userName: string;
deptName: string;
}
const TASK_GRID_COLUMNS = [
{ key: "source_type", label: "구분" },
{ key: "request_no", label: "접수번호" },
{ key: "status", label: "상태" },
{ key: "priority", label: "우선순위" },
{ key: "target_name", label: "설비/품목명" },
{ key: "req_dept", label: "의뢰부서" },
{ key: "requester", label: "의뢰자" },
{ key: "request_date", label: "접수일자" },
{ key: "due_date", label: "희망납기" },
{ key: "designer", label: "설계담당" },
];
export default function DesignTaskManagementPage() {
const ts = useTableSettings("c16-task-management", "dsn_design_request", TASK_GRID_COLUMNS);
const { user, userName, loading: authLoading } = useAuth();
const [allTasks, setAllTasks] = useState<TaskItem[]>([]);
const [loading, setLoading] = useState(true);
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
const [currentTab, setCurrentTab] = useState<MainTab>("all");
const [employees, setEmployees] = useState<EmployeeOption[]>([]);
const [myTasksOnly, setMyTasksOnly] = useState(true);
const fetchEmployees = useCallback(async () => {
try {
const res = await getUserList({ size: 1000 });
if (res.success && res.data) {
const list = (res.data as any[]).map((u: any) => ({
userId: u.user_id || u.userId,
userName: u.user_name || u.userName || "",
deptName: u.dept_name || u.deptName || "",
}));
setEmployees(list);
}
} catch {
// 사원 목록 로드 실패 시 빈 배열 유지
}
}, []);
const fetchTasks = useCallback(async () => {
setLoading(true);
const res = await getDesignRequestList({});
setLoading(false);
if (res.success && res.data) {
const mapped = (res.data as any[]).map(mapApiToTaskItem);
setAllTasks(mapped);
} else {
toast.error(res.message || "데이터를 불러오지 못했습니다.");
setAllTasks([]);
}
}, []);
useEffect(() => {
fetchTasks();
fetchEmployees();
}, [fetchTasks, fetchEmployees]);
// 검색 필터 (DynamicSearchFilter)
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
// 담당자 선택 모달 상태
const [designerModalOpen, setDesignerModalOpen] = useState(false);
const [designerModalTaskId, setDesignerModalTaskId] = useState<string | null>(null);
const [designerModalValue, setDesignerModalValue] = useState("");
const [designerComboOpen, setDesignerComboOpen] = useState(false);
// 모달 상태
const [rejectModalOpen, setRejectModalOpen] = useState(false);
const [rejectTaskId, setRejectTaskId] = useState<string | null>(null);
const [rejectReason, setRejectReason] = useState("");
const [projectModalOpen, setProjectModalOpen] = useState(false);
const [projectTaskId, setProjectTaskId] = useState<string | null>(null);
const [pmComboOpen, setPmComboOpen] = useState(false);
const [projectForm, setProjectForm] = useState({
projNo: "",
projName: "",
projSourceNo: "",
projStartDate: "",
projEndDate: "",
projPM: "",
projCustomer: "",
projDesc: "",
});
// 검토 메모
const [reviewMemoText, setReviewMemoText] = useState("");
// 현재 사용자 관련 업무만 필터링
const myRelatedTasks = useMemo(() => {
if (!myTasksOnly || !userName) return allTasks;
const currentUserName = userName;
const currentDeptName = user?.deptName || "";
return allTasks.filter((item) => {
if (item.requester === currentUserName) return true;
if (item.designer === currentUserName) return true;
if (currentDeptName && item.reqDept === currentDeptName) return true;
const inHistory = item.history.some((h) => h.user === currentUserName);
if (inHistory) return true;
return false;
});
}, [allTasks, myTasksOnly, userName, user?.deptName]);
// 탭별 카운트
const tabCounts = useMemo(() => {
const drItems = myRelatedTasks.filter((t) => t.sourceType === "dr");
const ecrItems = myRelatedTasks.filter((t) => t.sourceType === "ecr");
const newDR = drItems.filter((t) => t.status === "신규접수").length;
const newECR = ecrItems.filter((t) => t.status === "신규접수").length;
return {
all: newDR + newECR || myRelatedTasks.length,
allIsNew: newDR + newECR > 0,
dr: newDR || drItems.length,
drIsNew: newDR > 0,
ecr: newECR || ecrItems.length,
ecrIsNew: newECR > 0,
};
}, [myRelatedTasks]);
// snake_case → camelCase 매핑 (DynamicSearchFilter의 columnName은 snake_case)
const taskFieldMap: Record<string, string> = {
source_type: "sourceType",
request_no: "id",
target_name: "targetName",
req_dept: "reqDept",
request_date: "date",
due_date: "dueDate",
approval_step: "approvalStep",
design_type: "designType",
drawing_no: "drawingNo",
change_type: "changeType",
review_memo: "reviewMemo",
project_id: "projectNo",
};
const getTaskFieldValue = (obj: any, colName: string): string => {
const key = taskFieldMap[colName] || colName;
const val = obj[key];
if (Array.isArray(val)) return val.join(",");
return val !== undefined && val !== null ? String(val) : "";
};
// 필터링된 데이터
const filteredData = useMemo(() => {
return myRelatedTasks.filter((item) => {
if (currentTab === "dr" && item.sourceType !== "dr") return false;
if (currentTab === "ecr" && item.sourceType !== "ecr") return false;
for (const f of searchFilters) {
const val = getTaskFieldValue(item, f.columnName);
if (f.operator === "contains") {
if (!val.toLowerCase().includes(f.value.toLowerCase())) return false;
} else if (f.operator === "equals") {
if (val !== f.value) return false;
} else if (f.operator === "in") {
const allowed = f.value.split("|");
if (!allowed.includes(val)) return false;
} else if (f.operator === "between") {
const [from, to] = f.value.split("|");
if (from && val < from) return false;
if (to && val > to) return false;
}
}
return true;
});
}, [myRelatedTasks, currentTab, searchFilters]);
// 현황 통계
const stats = useMemo(() => {
return {
신규접수: myRelatedTasks.filter((t) => t.status === "신규접수").length,
검토중: myRelatedTasks.filter((t) => t.status === "검토중").length,
승인완료: myRelatedTasks.filter((t) => t.status === "승인완료").length,
반려: myRelatedTasks.filter((t) => t.status === "반려").length,
프로젝트생성: myRelatedTasks.filter((t) => t.status === "프로젝트생성").length,
};
}, [myRelatedTasks]);
const selectedTask = useMemo(
() => allTasks.find((t) => t.dbId === selectedTaskId) || null,
[allTasks, selectedTaskId]
);
// 선택된 업무 변경 시 메모 동기화
useEffect(() => {
if (selectedTask) {
setReviewMemoText(selectedTask.reviewMemo || "");
}
}, [selectedTask]);
// --- 액션 핸들러 ---
const handleSelectTask = useCallback((dbId: string) => {
setSelectedTaskId(dbId);
}, []);
const handleOpenDesignerModal = useCallback((dbId: string) => {
setDesignerModalTaskId(dbId);
setDesignerModalValue("");
setDesignerComboOpen(false);
setDesignerModalOpen(true);
}, []);
const handleConfirmDesigner = useCallback(async () => {
if (!designerModalValue) {
toast.error("설계 담당자를 선택하세요.");
return;
}
if (!designerModalTaskId) return;
const selected = employees.find((e) => e.userId === designerModalValue);
const designerName = selected?.userName || designerModalValue;
const historyDate = new Date().toISOString().split("T")[0];
const historyRes = await addRequestHistory(designerModalTaskId, {
step: "검토",
history_date: historyDate,
user_name: designerName,
description: "검토 착수 - 담당자 배정",
});
if (!historyRes.success) {
toast.error(historyRes.message || "이력 추가에 실패했습니다.");
return;
}
const updateRes = await updateDesignRequest(designerModalTaskId, {
status: "검토중",
approval_step: 1,
designer: designerName,
});
if (!updateRes.success) {
toast.error(updateRes.message || "상태 업데이트에 실패했습니다.");
return;
}
setDesignerModalOpen(false);
toast.success("검토가 착수되었습니다.");
fetchTasks();
}, [designerModalTaskId, designerModalValue, employees, fetchTasks]);
const handleApprove = useCallback(
async (dbId: string) => {
if (!confirm("내부 승인을 진행하시겠습니까?")) return;
const historyDate = new Date().toISOString().split("T")[0];
const historyRes = await addRequestHistory(dbId, {
step: "승인",
history_date: historyDate,
user_name: "팀장",
description: "내부 검토 완료, 승인 처리",
});
if (!historyRes.success) {
toast.error(historyRes.message || "이력 추가에 실패했습니다.");
return;
}
const updateRes = await updateDesignRequest(dbId, {
status: "승인완료",
approval_step: 3,
});
if (!updateRes.success) {
toast.error(updateRes.message || "상태 업데이트에 실패했습니다.");
return;
}
toast.success("승인 처리되었습니다.");
fetchTasks();
},
[fetchTasks]
);
const handleOpenRejectModal = useCallback((dbId: string) => {
setRejectTaskId(dbId);
setRejectReason("");
setRejectModalOpen(true);
}, []);
const handleConfirmReject = useCallback(async () => {
if (!rejectReason.trim()) {
toast.error("반려 사유를 입력하세요.");
return;
}
if (!rejectTaskId) return;
const historyDate = new Date().toISOString().split("T")[0];
const historyRes = await addRequestHistory(rejectTaskId, {
step: "반려",
history_date: historyDate,
user_name: "팀장",
description: rejectReason,
});
if (!historyRes.success) {
toast.error(historyRes.message || "이력 추가에 실패했습니다.");
return;
}
const updateRes = await updateDesignRequest(rejectTaskId, {
status: "반려",
approval_step: -1,
review_memo: rejectReason,
});
if (!updateRes.success) {
toast.error(updateRes.message || "상태 업데이트에 실패했습니다.");
return;
}
setRejectModalOpen(false);
toast.success("반려 처리되었습니다.");
fetchTasks();
}, [rejectTaskId, rejectReason, fetchTasks]);
const handleOpenProjectModal = useCallback(
(dbId: string) => {
const task = allTasks.find((t) => t.dbId === dbId);
if (!task) return;
const year = new Date().getFullYear();
const existingProjects = allTasks.filter((t) => t.projectNo).length;
const projNo = `PJ-${year}-${String(existingProjects + 1).padStart(4, "0")}`;
setProjectTaskId(dbId);
const matchedEmployee = employees.find((e) => e.userName === task.designer);
setProjectForm({
projNo,
projName: task.targetName,
projSourceNo: task.id,
projStartDate: new Date().toISOString().split("T")[0],
projEndDate: task.dueDate,
projPM: matchedEmployee?.userId || "",
projCustomer: task.customer || task.reqDept,
projDesc: task.sourceType === "dr" ? task.spec || "" : task.reason || "",
});
setProjectModalOpen(true);
},
[allTasks, employees]
);
const handleCreateProject = useCallback(async () => {
if (!projectForm.projName.trim()) { toast.error("프로젝트명을 입력하세요."); return; }
if (!projectForm.projStartDate) { toast.error("시작일을 입력하세요."); return; }
if (!projectForm.projEndDate) { toast.error("종료예정일을 입력하세요."); return; }
if (!projectForm.projPM) { toast.error("PM을 선택하세요."); return; }
if (!projectTaskId) return;
const pmEmployee = employees.find((e) => e.userId === projectForm.projPM);
const pmName = pmEmployee?.userName || projectForm.projPM;
// 1) 실제 프로젝트 테이블(dsn_project)에 INSERT
const projectRes = await createProject({
project_no: projectForm.projNo,
name: projectForm.projName,
status: "계획",
pm: pmName,
customer: projectForm.projCustomer,
start_date: projectForm.projStartDate,
end_date: projectForm.projEndDate,
source_no: projectForm.projSourceNo,
description: projectForm.projDesc,
progress: "0",
});
if (!projectRes.success) {
toast.error(projectRes.message || "프로젝트 생성에 실패했습니다.");
return;
}
const createdProjectId = projectRes.data?.id || projectForm.projNo;
// 2) 이력 추가
const historyDate = new Date().toISOString().split("T")[0];
const historyRes = await addRequestHistory(projectTaskId, {
step: "프로젝트",
history_date: historyDate,
user_name: pmName,
description: `${projectForm.projNo} 프로젝트 생성 - ${projectForm.projName}`,
});
if (!historyRes.success) {
toast.error(historyRes.message || "이력 추가에 실패했습니다.");
return;
}
// 3) 설계요청 상태 업데이트 + 프로젝트 ID 연결
const updateRes = await updateDesignRequest(projectTaskId, {
status: "프로젝트생성",
approval_step: 4,
project_id: createdProjectId,
});
if (!updateRes.success) {
toast.error(updateRes.message || "상태 업데이트에 실패했습니다.");
return;
}
setProjectModalOpen(false);
toast.success(`프로젝트 ${projectForm.projNo}가 생성되었습니다.`);
fetchTasks();
}, [projectForm, projectTaskId, employees, fetchTasks]);
const handleSaveReviewMemo = useCallback(async () => {
if (!selectedTaskId) return;
const updateRes = await updateDesignRequest(selectedTaskId, {
review_memo: reviewMemoText,
});
if (!updateRes.success) {
toast.error(updateRes.message || "메모 저장에 실패했습니다.");
return;
}
toast.success("검토 메모가 저장되었습니다.");
fetchTasks();
}, [selectedTaskId, reviewMemoText, fetchTasks]);
const handleFilterByStatus = useCallback((_status: TaskStatus) => {
// Status filter now handled by DynamicSearchFilter
}, []);
// 납기 남은 일수 계산
const getDueDateInfo = (dueDate: string) => {
const due = new Date(dueDate);
const today = new Date();
const diffDays = Math.ceil((due.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
const color = diffDays < 0 ? "text-destructive" : diffDays <= 7 ? "text-primary" : "text-muted-foreground";
const text = diffDays < 0 ? `${Math.abs(diffDays)}일 초과` : diffDays === 0 ? "오늘" : `${diffDays}일 남음`;
return { color, text };
};
return (
<div className="flex h-[calc(100vh-4rem)] flex-col overflow-hidden bg-background">
{/* 탭 바 */}
<div className="flex items-center border-b-2 border-border bg-card px-5">
{([
{ key: "all" as MainTab, label: "전체", icon: <ClipboardList className="h-4 w-4" />, count: tabCounts.all, isNew: tabCounts.allIsNew },
{ key: "dr" as MainTab, label: "설계의뢰(DR)", icon: <Pencil className="h-4 w-4" />, count: tabCounts.dr, isNew: tabCounts.drIsNew },
{ key: "ecr" as MainTab, label: "설계변경(ECR)", icon: <RefreshCw className="h-4 w-4" />, count: tabCounts.ecr, isNew: tabCounts.ecrIsNew },
]).map((tab) => (
<button
key={tab.key}
className={cn(
"relative flex items-center gap-2 px-6 py-3.5 text-sm font-semibold transition-colors",
currentTab === tab.key
? "text-primary after:absolute after:bottom-[-2px] after:left-0 after:right-0 after:h-0.5 after:bg-primary"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
)}
onClick={() => {
setCurrentTab(tab.key);
setSelectedTaskId(null);
}}
>
{tab.icon}
{tab.label}
<span
className={cn(
"min-w-[20px] rounded-full px-1.5 py-0.5 text-center text-[11px] font-bold",
tab.isNew ? "bg-destructive text-destructive-foreground" : "bg-muted text-muted-foreground"
)}
>
{tab.count}
</span>
</button>
))}
<div className="flex-1" />
<div className="flex items-center gap-3">
{userName && (
<div className="flex items-center gap-1.5 rounded-full bg-primary/10 px-3 py-1 text-xs font-medium text-primary">
<UserCircle className="h-3.5 w-3.5" />
{userName}
{user?.deptName && <span className="text-primary/60">({user.deptName})</span>}
</div>
)}
<div className="flex items-center overflow-hidden rounded-full border border-border">
<button
className={cn(
"flex items-center gap-1 px-3 py-1.5 text-xs font-medium transition-colors",
myTasksOnly
? "bg-primary text-primary-foreground"
: "bg-card text-muted-foreground hover:text-foreground"
)}
onClick={() => setMyTasksOnly(true)}
>
<User className="h-3 w-3" />
</button>
<button
className={cn(
"flex items-center gap-1 px-3 py-1.5 text-xs font-medium transition-colors",
!myTasksOnly
? "bg-primary text-primary-foreground"
: "bg-card text-muted-foreground hover:text-foreground"
)}
onClick={() => setMyTasksOnly(false)}
>
<Users className="h-3 w-3" />
</button>
</div>
<div className="flex items-center gap-1.5 rounded-full bg-muted px-3 py-1 text-xs text-muted-foreground">
<span className="h-2 w-2 animate-pulse rounded-full bg-primary" />
</div>
</div>
</div>
{/* 검색 필터 */}
<div className="border-b border-border bg-card px-4 py-3">
<DynamicSearchFilter
tableName="dsn_design_request"
filterId="c16-task-management"
onFilterChange={setSearchFilters}
externalFilterConfig={ts.filterConfig}
dataCount={filteredData.length}
/>
</div>
{/* 좌우 분할 패널 */}
<div className="flex-1 overflow-hidden p-3">
<ResizablePanelGroup direction="horizontal" className="h-full rounded-lg border border-border">
{/* 왼쪽: 접수 목록 */}
<ResizablePanel defaultSize={58} minSize={35}>
<div className="flex h-full flex-col bg-card">
<div className="flex items-center justify-between px-4 py-2.5 border-b bg-muted/40 shrink-0">
<div className="text-[13px] font-bold flex items-center gap-2">
{myTasksOnly ? "내 관련 업무" : "접수 업무 목록"}
<span className="text-primary bg-primary/8 border border-primary/15 rounded-md px-1.5 py-0.5 text-[11px] font-semibold">{filteredData.length}</span>
{myTasksOnly && (
<span className="text-xs font-normal text-muted-foreground">
{allTasks.length}
</span>
)}
</div>
<Button size="sm" variant="outline" onClick={fetchTasks} disabled={loading}>
{loading ? <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="mr-1 h-3.5 w-3.5" />}
</Button>
<Button variant="ghost" size="sm" onClick={() => ts.setOpen(true)} title="테이블 설정">
<Settings2 className="h-4 w-4" />
</Button>
</div>
<EDataTable<TaskItem>
columns={ts.visibleColumns.map((col): EDataTableColumn<TaskItem> => ({
key: col.key === "request_no" ? "id" : col.key === "target_name" ? "targetName" : col.key === "req_dept" ? "reqDept" : col.key === "request_date" ? "date" : col.key === "due_date" ? "dueDate" : col.key === "source_type" ? "sourceType" : col.key,
label: col.label,
width: col.key === "source_type" ? "w-[60px]" : col.key === "request_no" ? "w-[130px]" : col.key === "status" ? "w-[90px]" : col.key === "priority" ? "w-[80px]" : col.key === "target_name" ? "min-w-[180px]" : col.key === "req_dept" ? "w-[90px]" : col.key === "requester" ? "w-[80px]" : col.key === "request_date" ? "w-[100px]" : col.key === "due_date" ? "w-[100px]" : col.key === "designer" ? "w-[80px]" : undefined,
align: (col.key === "source_type" || col.key === "status" || col.key === "priority") ? "center" : undefined,
render: col.key === "source_type"
? (val: any, row: TaskItem) => (
<Badge variant="outline" className={cn("text-[10px] font-bold", getSourceBadge(row.sourceType))}>
{row.sourceType === "dr" ? "DR" : "ECR"}
</Badge>
)
: col.key === "request_no"
? (val: any, row: TaskItem) => (
<span className={cn("text-[13px] font-semibold", row.sourceType === "dr" ? "text-primary" : "text-secondary-foreground")}>
{val}
</span>
)
: col.key === "status"
? (val: any) => (
<Badge variant="outline" className={cn("text-[10px]", getStatusVariant(val))}>
{val}
</Badge>
)
: col.key === "priority"
? (val: any) => (
<Badge variant="outline" className={cn("text-[10px]", getPriorityVariant(val))}>
{val}
</Badge>
)
: col.key === "designer"
? (val: any) => val ? <span>{val}</span> : <span className="text-muted-foreground"></span>
: undefined,
}))}
data={ts.groupData(filteredData)}
loading={loading}
emptyMessage="조건에 맞는 업무가 없어요"
rowKey={(row) => row.dbId}
selectedId={selectedTaskId}
onSelect={(id) => handleSelectTask(id ?? "")}
draggableColumns={false}
showPagination={false}
/>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* 오른쪽: 검토 및 승인 */}
<ResizablePanel defaultSize={42} minSize={30}>
<div className="flex h-full flex-col bg-card">
<div className="flex items-center justify-between px-4 py-2.5 border-b bg-muted/40 shrink-0">
<div className="text-[13px] font-bold"> </div>
</div>
<div className="flex-1 overflow-auto p-3">
{/* 업무 현황 */}
<div className="mb-3 space-y-1.5">
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"> </span>
<div className="grid grid-cols-5 gap-2">
{STAT_CARDS.map((card) => (
<button
key={card.status}
className={cn(
"rounded-lg p-2.5 text-center transition-all hover:-translate-y-0.5 hover:shadow-md",
card.color, card.textColor
)}
onClick={() => handleFilterByStatus(card.status)}
>
<div className="text-[11px] font-medium opacity-90 leading-tight">{card.label}</div>
<div className="text-xl font-bold mt-0.5">{stats[card.status]}</div>
</button>
))}
</div>
</div>
{/* 상세 정보 */}
{!selectedTask ? (
<div className="flex items-center justify-center p-6">
<div className="flex flex-col items-center gap-3 rounded-lg border-2 border-dashed border-border/60 px-10 py-8 text-center">
<Inbox className="h-10 w-10 text-muted-foreground/40" />
<p className="text-sm text-muted-foreground"> </p>
</div>
</div>
) : (
<div className="space-y-3">
{/* 승인 프로세스 */}
<div className="rounded-lg border border-border/80 bg-muted/30 p-3.5">
<h3 className="mb-3 text-sm font-bold text-foreground"> </h3>
<div className="mb-3 flex items-center gap-1">
{APPROVAL_STEPS.map((s, idx) => {
let stepClass = "border-border bg-card text-muted-foreground";
if (selectedTask.status === "반려" && idx >= 2) {
stepClass = idx === 2 ? "border-destructive bg-destructive/5 text-destructive" : "border-border bg-card text-muted-foreground";
} else if (selectedTask.approvalStep > idx) {
stepClass = "border-primary bg-primary/5 text-primary";
} else if (selectedTask.approvalStep === idx) {
stepClass = "border-primary bg-primary/5 text-primary ring-2 ring-primary/10";
}
return (
<React.Fragment key={s.label}>
<div className={cn("flex min-w-[70px] flex-col items-center gap-1 rounded-lg border-2 px-3 py-2 text-center text-xs font-semibold transition-all", stepClass)}>
{s.label}
</div>
{idx < APPROVAL_STEPS.length - 1 && (
<ArrowRight className="h-4 w-4 shrink-0 text-muted-foreground/50" />
)}
</React.Fragment>
);
})}
</div>
{/* 액션 버튼 */}
<div className="flex flex-wrap gap-2">
{selectedTask.status === "신규접수" && (
<>
<Button size="sm" variant="default" onClick={() => handleOpenDesignerModal(selectedTask.dbId)}>
<Eye className="mr-1 h-3.5 w-3.5" />
</Button>
<Button size="sm" variant="destructive" onClick={() => handleOpenRejectModal(selectedTask.dbId)}>
<XCircle className="mr-1 h-3.5 w-3.5" />
</Button>
</>
)}
{selectedTask.status === "검토중" && (
<>
<Button size="sm" variant="default" onClick={() => handleApprove(selectedTask.dbId)}>
<CheckCircle2 className="mr-1 h-3.5 w-3.5" />
</Button>
<Button size="sm" variant="destructive" onClick={() => handleOpenRejectModal(selectedTask.dbId)}>
<XCircle className="mr-1 h-3.5 w-3.5" />
</Button>
</>
)}
{selectedTask.status === "승인완료" && (
<Button size="sm" variant="default" onClick={() => handleOpenProjectModal(selectedTask.dbId)}>
<Rocket className="mr-1 h-3.5 w-3.5" />
</Button>
)}
{selectedTask.status === "프로젝트생성" && selectedTask.projectNo && (
<Button size="sm" variant="default" onClick={() => toast.info(`${selectedTask.projectNo} 프로젝트 → 설계프로젝트관리 메뉴에서 확인`)}>
({selectedTask.projectNo})
</Button>
)}
</div>
</div>
{/* 접수 정보 */}
<div className="rounded-lg bg-muted/30 border border-border/60 p-3.5">
<h3 className="mb-3 border-b border-border pb-2 text-sm font-semibold text-foreground"> </h3>
<div className="text-xs">
<table className="w-full border-collapse">
<tbody>
<tr>
<td className="whitespace-nowrap border border-muted bg-muted/70 px-2 py-1.5 font-semibold text-muted-foreground"></td>
<td className={cn("border border-muted px-2 py-1.5 font-semibold", selectedTask.sourceType === "dr" ? "text-primary" : "text-secondary-foreground")}>
{selectedTask.id}
</td>
<td className="whitespace-nowrap border border-muted bg-muted/70 px-2 py-1.5 font-semibold text-muted-foreground"></td>
<td className="border border-muted px-2 py-1.5">
<Badge variant="outline" className={cn("text-[10px] font-bold", getSourceBadge(selectedTask.sourceType))}>
{selectedTask.sourceType === "dr" ? "설계의뢰(DR)" : "설계변경(ECR)"}
</Badge>
</td>
</tr>
<tr>
<td className="whitespace-nowrap border border-muted bg-muted/70 px-2 py-1.5 font-semibold text-muted-foreground"></td>
<td className="border border-muted px-2 py-1.5">
<Badge variant="outline" className={cn("text-[10px]", getStatusVariant(selectedTask.status))}>
{selectedTask.status}
</Badge>
</td>
<td className="whitespace-nowrap border border-muted bg-muted/70 px-2 py-1.5 font-semibold text-muted-foreground"></td>
<td className="border border-muted px-2 py-1.5">
<Badge variant="outline" className={cn("text-[10px]", getPriorityVariant(selectedTask.priority))}>
{selectedTask.priority}
</Badge>
</td>
</tr>
<tr>
<td className="whitespace-nowrap border border-muted bg-muted/70 px-2 py-1.5 font-semibold text-muted-foreground">
{selectedTask.sourceType === "dr" ? "설비/품목명" : "품목명"}
</td>
<td colSpan={3} className="border border-muted px-2 py-1.5 font-medium">{selectedTask.targetName}</td>
</tr>
<tr>
<td className="whitespace-nowrap border border-muted bg-muted/70 px-2 py-1.5 font-semibold text-muted-foreground"></td>
<td className="border border-muted px-2 py-1.5">{selectedTask.reqDept}</td>
<td className="whitespace-nowrap border border-muted bg-muted/70 px-2 py-1.5 font-semibold text-muted-foreground"></td>
<td className="border border-muted px-2 py-1.5">{selectedTask.requester}</td>
</tr>
{selectedTask.sourceType === "dr" ? (
<>
<tr>
<td className="whitespace-nowrap border border-muted bg-muted/70 px-2 py-1.5 font-semibold text-muted-foreground"></td>
<td className="border border-muted px-2 py-1.5">{selectedTask.designType || "-"}</td>
<td className="whitespace-nowrap border border-muted bg-muted/70 px-2 py-1.5 font-semibold text-muted-foreground"></td>
<td className="border border-muted px-2 py-1.5">{selectedTask.customer || "-"}</td>
</tr>
<tr>
<td className="whitespace-nowrap border border-muted bg-muted/70 px-2 py-1.5 font-semibold text-muted-foreground"></td>
<td className="border border-muted px-2 py-1.5">{selectedTask.orderNo || "-"}</td>
<td className="whitespace-nowrap border border-muted bg-muted/70 px-2 py-1.5 font-semibold text-muted-foreground"></td>
<td className="border border-muted px-2 py-1.5">{selectedTask.designer || <span className="text-muted-foreground"></span>}</td>
</tr>
</>
) : (
<>
<tr>
<td className="whitespace-nowrap border border-muted bg-muted/70 px-2 py-1.5 font-semibold text-muted-foreground"></td>
<td className="border border-muted px-2 py-1.5">{selectedTask.changeType || "-"}</td>
<td className="whitespace-nowrap border border-muted bg-muted/70 px-2 py-1.5 font-semibold text-muted-foreground"></td>
<td className="border border-muted px-2 py-1.5">{selectedTask.drawingNo || "-"}</td>
</tr>
<tr>
<td className="whitespace-nowrap border border-muted bg-muted/70 px-2 py-1.5 font-semibold text-muted-foreground"></td>
<td className="border border-muted px-2 py-1.5">{selectedTask.designer || <span className="text-muted-foreground"></span>}</td>
<td className="whitespace-nowrap border border-muted bg-muted/70 px-2 py-1.5 font-semibold text-muted-foreground"></td>
<td className="border border-muted px-2 py-1.5">
{selectedTask.impact ? (
<div className="flex flex-wrap gap-1">
{selectedTask.impact.map((i) => (
<Badge key={i} variant="outline" className="bg-primary/5 text-[10px] text-primary">{i}</Badge>
))}
</div>
) : "-"}
</td>
</tr>
</>
)}
<tr>
<td className="whitespace-nowrap border border-muted bg-muted/70 px-2 py-1.5 font-semibold text-muted-foreground"></td>
<td className="border border-muted px-2 py-1.5">{selectedTask.date}</td>
<td className="whitespace-nowrap border border-muted bg-muted/70 px-2 py-1.5 font-semibold text-muted-foreground"></td>
<td className="border border-muted px-2 py-1.5">
{selectedTask.dueDate}{" "}
<span className={cn("text-[10px]", getDueDateInfo(selectedTask.dueDate).color)}>
({getDueDateInfo(selectedTask.dueDate).text})
</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
{/* 요구사양 / 변경사유 */}
<div className="rounded-lg bg-muted/30 border border-border/60 p-3.5">
<h3 className="mb-3 border-b border-border pb-2 text-sm font-semibold text-foreground">
{selectedTask.sourceType === "dr" ? "요구사양" : "변경 사유"}
</h3>
<pre className="whitespace-pre-wrap font-sans text-xs leading-relaxed text-foreground">
{selectedTask.sourceType === "dr" ? selectedTask.spec : selectedTask.reason}
</pre>
</div>
{/* 검토 메모 */}
<div className="rounded-lg border border-primary/20 bg-primary/5 p-3.5">
<h3 className="mb-2 text-sm font-semibold text-foreground"> / </h3>
<Textarea
value={reviewMemoText}
onChange={(e) => setReviewMemoText(e.target.value)}
placeholder="검토 의견을 기록해주세요..."
className="min-h-[60px] text-xs"
/>
<div className="mt-2 text-right">
<Button size="sm" onClick={handleSaveReviewMemo}>
</Button>
</div>
</div>
{/* 처리 이력 */}
<div className="rounded-lg bg-muted/30 border border-border/60 p-3.5">
<h3 className="mb-3 border-b border-border pb-2 text-sm font-semibold text-foreground"> </h3>
<div className="relative pl-6">
<div className="absolute bottom-0 left-[7px] top-0 w-0.5 bg-border" />
{selectedTask.history.map((h, idx) => {
const isLast = idx === selectedTask.history.length - 1;
let dotColor = "bg-primary ring-primary";
if (isLast) {
if (h.step === "반려") dotColor = "bg-destructive ring-destructive";
else if (h.step !== "프로젝트") dotColor = "bg-primary ring-primary animate-pulse";
}
return (
<div key={idx} className={cn("relative pb-5", isLast && "pb-0")}>
<div className={cn("absolute -left-[17px] top-1 h-3 w-3 rounded-full border-2 border-white ring-2", dotColor)} />
<div className="pl-1">
<div className="text-xs font-semibold text-foreground">
{getStepIcon(h.step)}
{h.step}
</div>
<div className="text-[11px] text-muted-foreground">{h.desc}</div>
<div className="text-[10px] text-muted-foreground/70">{h.date} · {h.user}</div>
</div>
</div>
);
})}
</div>
</div>
</div>
)}
</div>
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
{/* 설계 담당자 선택 모달 */}
<Dialog open={designerModalOpen} onOpenChange={setDesignerModalOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-lg max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription> .</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
<span className="text-destructive">*</span>
</span>
<Popover open={designerComboOpen} onOpenChange={setDesignerComboOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={designerComboOpen}
className="mt-1 h-9 w-full justify-between text-sm"
>
{designerModalValue
? (() => {
const emp = employees.find((e) => e.userId === designerModalValue);
return emp ? `${emp.userName} (${emp.deptName || "부서 미지정"})` : designerModalValue;
})()
: "사원 선택"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<Command>
<CommandInput placeholder="이름, 부서로 검색..." />
<CommandList>
<CommandEmpty className="py-3 text-center text-sm"> .</CommandEmpty>
<CommandGroup>
{employees.map((emp) => (
<CommandItem
key={emp.userId}
value={`${emp.userName} ${emp.deptName} ${emp.userId}`}
onSelect={() => {
setDesignerModalValue(emp.userId);
setDesignerComboOpen(false);
}}
>
<Check className={cn("mr-2 h-4 w-4", designerModalValue === emp.userId ? "opacity-100" : "opacity-0")} />
<UserCircle className="mr-2 h-4 w-4 text-muted-foreground" />
<div className="flex flex-col">
<span className="font-medium">{emp.userName}</span>
<span className="text-[10px] text-muted-foreground">{emp.deptName || "부서 미지정"}</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDesignerModalOpen(false)}></Button>
<Button onClick={handleConfirmDesigner}> </Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 반려 사유 모달 */}
<Dialog open={rejectModalOpen} onOpenChange={setRejectModalOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-lg max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription> .</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div>
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
<span className="text-destructive">*</span>
</span>
<Textarea
value={rejectReason}
onChange={(e) => setRejectReason(e.target.value)}
placeholder="반려 사유를 상세히 기술해주세요"
className="mt-1 min-h-[120px] text-sm"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setRejectModalOpen(false)}></Button>
<Button variant="destructive" onClick={handleConfirmReject}> </Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 프로젝트 생성 모달 */}
<Dialog open={projectModalOpen} onOpenChange={setProjectModalOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-2xl max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription> .</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="rounded-lg bg-muted/50 p-4">
<h4 className="mb-3 border-b border-border pb-2 text-sm font-semibold"> </h4>
<div className="space-y-3">
<div>
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"> </span>
<Input value={projectForm.projNo} readOnly className="mt-1 h-9 bg-muted text-sm" />
</div>
<div>
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
<span className="text-destructive">*</span>
</span>
<Input
value={projectForm.projName}
onChange={(e) => setProjectForm((p) => ({ ...p, projName: e.target.value }))}
placeholder="프로젝트명 입력"
className="mt-1 h-9 text-sm"
/>
</div>
<div>
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"> </span>
<Input value={projectForm.projSourceNo} readOnly className="mt-1 h-9 bg-muted text-sm" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
<span className="text-destructive">*</span>
</span>
<Input
type="date"
value={projectForm.projStartDate}
onChange={(e) => setProjectForm((p) => ({ ...p, projStartDate: e.target.value }))}
className="mt-1 h-9 text-sm"
/>
</div>
<div>
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
<span className="text-destructive">*</span>
</span>
<Input
type="date"
value={projectForm.projEndDate}
onChange={(e) => setProjectForm((p) => ({ ...p, projEndDate: e.target.value }))}
className="mt-1 h-9 text-sm"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
PM <span className="text-destructive">*</span>
</span>
<Popover open={pmComboOpen} onOpenChange={setPmComboOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={pmComboOpen}
className="mt-1 h-9 w-full justify-between text-sm"
>
{projectForm.projPM
? employees.find((e) => e.userId === projectForm.projPM)?.userName || projectForm.projPM
: "PM 선택"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<Command>
<CommandInput placeholder="사원 검색..." />
<CommandList>
<CommandEmpty className="py-3 text-center text-sm"> .</CommandEmpty>
<CommandGroup>
{employees.map((emp) => (
<CommandItem
key={emp.userId}
value={`${emp.userName} ${emp.deptName} ${emp.userId}`}
onSelect={() => {
setProjectForm((p) => ({ ...p, projPM: emp.userId }));
setPmComboOpen(false);
}}
>
<Check className={cn("mr-2 h-4 w-4", projectForm.projPM === emp.userId ? "opacity-100" : "opacity-0")} />
<div className="flex flex-col">
<span className="font-medium">{emp.userName}</span>
<span className="text-[10px] text-muted-foreground">{emp.deptName || "부서 미지정"}</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<div>
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"></span>
<Input value={projectForm.projCustomer} readOnly className="mt-1 h-9 bg-muted text-sm" />
</div>
</div>
<div>
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground"> </span>
<Textarea
value={projectForm.projDesc}
onChange={(e) => setProjectForm((p) => ({ ...p, projDesc: e.target.value }))}
placeholder="프로젝트 개요 및 목표를 기술해주세요"
className="mt-1 min-h-[80px] text-sm"
/>
</div>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setProjectModalOpen(false)}></Button>
<Button onClick={handleCreateProject}> </Button>
</DialogFooter>
</DialogContent>
</Dialog>
<TableSettingsModal
open={ts.open}
onOpenChange={ts.setOpen}
tableName={ts.tableName}
settingsId={ts.settingsId}
defaultVisibleKeys={ts.defaultVisibleKeys}
onSave={ts.applySettings}
/>
</div>
);
}