775d698d06
- Introduced multiple new pages to enhance design management functionalities, allowing users to manage design requests, track their work, and oversee project and task statuses. - Implemented UI components such as tables, dialogs, and forms to facilitate user interactions and data management. - Integrated necessary API calls for fetching and manipulating design-related data, ensuring a seamless user experience across the new pages. These additions significantly expand the design management capabilities of the application, providing users with comprehensive tools for managing their design workflows.
1345 lines
62 KiB
TypeScript
1345 lines
62 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useMemo, useCallback, useRef, useEffect } from "react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "@/components/ui/table";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogFooter,
|
|
DialogDescription,
|
|
} from "@/components/ui/dialog";
|
|
import { Label } from "@/components/ui/label";
|
|
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 {
|
|
Search,
|
|
RotateCcw,
|
|
RefreshCw,
|
|
ClipboardList,
|
|
Pencil,
|
|
Inbox,
|
|
CheckCircle2,
|
|
XCircle,
|
|
Rocket,
|
|
Eye,
|
|
ChevronRight,
|
|
ArrowRight,
|
|
Check,
|
|
ChevronsUpDown,
|
|
UserCircle,
|
|
Loader2,
|
|
User,
|
|
Users,
|
|
} from "lucide-react";
|
|
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";
|
|
|
|
// --- 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-amber-100 text-amber-800 border-amber-200";
|
|
case "검토중":
|
|
return "bg-blue-100 text-blue-800 border-blue-200";
|
|
case "승인완료":
|
|
return "bg-emerald-100 text-emerald-800 border-emerald-200";
|
|
case "반려":
|
|
return "bg-rose-100 text-rose-800 border-rose-200";
|
|
case "프로젝트생성":
|
|
return "bg-violet-100 text-violet-800 border-violet-200";
|
|
default:
|
|
return "bg-gray-100 text-gray-800 border-gray-200";
|
|
}
|
|
};
|
|
|
|
const getPriorityVariant = (priority: Priority) => {
|
|
switch (priority) {
|
|
case "긴급":
|
|
return "bg-rose-100 text-rose-800 border-rose-200";
|
|
case "높음":
|
|
return "bg-amber-100 text-amber-800 border-amber-200";
|
|
case "보통":
|
|
return "bg-blue-100 text-blue-800 border-blue-200";
|
|
case "낮음":
|
|
return "bg-gray-100 text-gray-600 border-gray-200";
|
|
default:
|
|
return "bg-gray-100 text-gray-600 border-gray-200";
|
|
}
|
|
};
|
|
|
|
const getSourceBadge = (type: SourceType) => {
|
|
if (type === "dr")
|
|
return "bg-blue-100 text-blue-800 border-blue-200";
|
|
return "bg-amber-100 text-amber-800 border-amber-200";
|
|
};
|
|
|
|
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: "from-indigo-500 to-purple-600", textColor: "text-white" },
|
|
{ label: "검토중", status: "검토중", color: "from-amber-400 to-orange-500", textColor: "text-gray-900" },
|
|
{ label: "승인완료", status: "승인완료", color: "from-cyan-400 to-blue-500", textColor: "text-white" },
|
|
{ label: "반려", status: "반려", color: "from-rose-400 to-red-500", textColor: "text-white" },
|
|
{ label: "프로젝트", status: "프로젝트생성", color: "from-violet-400 to-purple-500", textColor: "text-white" },
|
|
];
|
|
|
|
interface EmployeeOption {
|
|
userId: string;
|
|
userName: string;
|
|
deptName: string;
|
|
}
|
|
|
|
export default function DesignTaskManagementPage() {
|
|
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]);
|
|
|
|
// 검색 필터
|
|
const [searchStatus, setSearchStatus] = useState<string>("all");
|
|
const [searchPriority, setSearchPriority] = useState<string>("all");
|
|
const [searchReqDept, setSearchReqDept] = useState<string>("all");
|
|
const [searchKeyword, setSearchKeyword] = useState("");
|
|
|
|
// 담당자 선택 모달 상태
|
|
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]);
|
|
|
|
// 필터링된 데이터
|
|
const filteredData = useMemo(() => {
|
|
return myRelatedTasks.filter((item) => {
|
|
if (currentTab === "dr" && item.sourceType !== "dr") return false;
|
|
if (currentTab === "ecr" && item.sourceType !== "ecr") return false;
|
|
if (searchStatus !== "all" && item.status !== searchStatus) return false;
|
|
if (searchPriority !== "all" && item.priority !== searchPriority) return false;
|
|
if (searchReqDept !== "all" && item.reqDept !== searchReqDept) return false;
|
|
if (searchKeyword) {
|
|
const str = [item.id, item.targetName, item.customer, item.requester, item.designer, item.reqDept]
|
|
.join(" ")
|
|
.toLowerCase();
|
|
if (!str.includes(searchKeyword.toLowerCase())) return false;
|
|
}
|
|
return true;
|
|
});
|
|
}, [myRelatedTasks, currentTab, searchStatus, searchPriority, searchReqDept, searchKeyword]);
|
|
|
|
// 현황 통계
|
|
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 handleResetSearch = useCallback(() => {
|
|
setSearchStatus("all");
|
|
setSearchPriority("all");
|
|
setSearchReqDept("all");
|
|
setSearchKeyword("");
|
|
}, []);
|
|
|
|
const handleFilterByStatus = useCallback((status: TaskStatus) => {
|
|
setSearchStatus(status);
|
|
}, []);
|
|
|
|
// 납기 남은 일수 계산
|
|
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-rose-600" : diffDays <= 7 ? "text-amber-600" : "text-emerald-600";
|
|
const text = diffDays < 0 ? `${Math.abs(diffDays)}일 초과` : diffDays === 0 ? "오늘" : `${diffDays}일 남음`;
|
|
return { color, text };
|
|
};
|
|
|
|
return (
|
|
<div className="flex h-full 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-rose-500 text-white" : "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-emerald-50 px-3 py-1 text-xs text-emerald-600 dark:bg-emerald-950/30 dark:text-emerald-400">
|
|
<span className="h-2 w-2 animate-pulse rounded-full bg-emerald-500" />
|
|
실시간 동기화 중
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 검색 섹션 */}
|
|
<div className="border-b border-border bg-card px-5 py-3">
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<div className="flex flex-1 flex-wrap items-center gap-3">
|
|
<Select value={searchStatus} onValueChange={setSearchStatus}>
|
|
<SelectTrigger className="h-9 w-[130px] text-xs">
|
|
<SelectValue placeholder="상태 전체" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">상태 전체</SelectItem>
|
|
<SelectItem value="신규접수">신규접수</SelectItem>
|
|
<SelectItem value="검토중">검토중</SelectItem>
|
|
<SelectItem value="승인완료">승인완료</SelectItem>
|
|
<SelectItem value="반려">반려</SelectItem>
|
|
<SelectItem value="프로젝트생성">프로젝트생성</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<Select value={searchPriority} onValueChange={setSearchPriority}>
|
|
<SelectTrigger className="h-9 w-[130px] text-xs">
|
|
<SelectValue placeholder="우선순위 전체" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">우선순위 전체</SelectItem>
|
|
<SelectItem value="긴급">긴급</SelectItem>
|
|
<SelectItem value="높음">높음</SelectItem>
|
|
<SelectItem value="보통">보통</SelectItem>
|
|
<SelectItem value="낮음">낮음</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<Select value={searchReqDept} onValueChange={setSearchReqDept}>
|
|
<SelectTrigger className="h-9 w-[130px] text-xs">
|
|
<SelectValue placeholder="의뢰부서 전체" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">의뢰부서 전체</SelectItem>
|
|
<SelectItem value="영업팀">영업팀</SelectItem>
|
|
<SelectItem value="생산팀">생산팀</SelectItem>
|
|
<SelectItem value="품질팀">품질팀</SelectItem>
|
|
<SelectItem value="구매팀">구매팀</SelectItem>
|
|
<SelectItem value="기획팀">기획팀</SelectItem>
|
|
<SelectItem value="설계팀">설계팀</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<div className="relative min-w-[280px]">
|
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
|
<Input
|
|
value={searchKeyword}
|
|
onChange={(e) => setSearchKeyword(e.target.value)}
|
|
placeholder="접수번호 / 설비명 / 품목명 / 고객명 검색"
|
|
className="h-9 pl-9 text-xs"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="flex shrink-0 gap-2">
|
|
<Button size="sm" variant="outline" onClick={handleResetSearch}>
|
|
<RotateCcw className="mr-1 h-3.5 w-3.5" />
|
|
초기화
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</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 border-b-2 border-border px-5 py-3">
|
|
<h2 className="text-base font-bold text-foreground">
|
|
{myTasksOnly ? "내 관련 업무" : "접수 업무 목록"} ({filteredData.length}건)
|
|
{myTasksOnly && (
|
|
<span className="ml-2 text-xs font-normal text-muted-foreground">
|
|
전체 {allTasks.length}건 중
|
|
</span>
|
|
)}
|
|
</h2>
|
|
<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>
|
|
</div>
|
|
<div className="flex-1 overflow-auto">
|
|
<Table>
|
|
<TableHeader className="sticky top-0 z-10 bg-muted">
|
|
<TableRow>
|
|
<TableHead className="w-[60px] text-center text-xs">구분</TableHead>
|
|
<TableHead className="w-[130px] text-xs">접수번호</TableHead>
|
|
<TableHead className="w-[90px] text-center text-xs">상태</TableHead>
|
|
<TableHead className="w-[80px] text-center text-xs">우선순위</TableHead>
|
|
<TableHead className="min-w-[180px] text-xs">설비/품목명</TableHead>
|
|
<TableHead className="w-[90px] text-xs">의뢰부서</TableHead>
|
|
<TableHead className="w-[80px] text-xs">의뢰자</TableHead>
|
|
<TableHead className="w-[100px] text-xs">접수일자</TableHead>
|
|
<TableHead className="w-[100px] text-xs">희망납기</TableHead>
|
|
<TableHead className="w-[80px] text-xs">설계담당</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{loading && allTasks.length === 0 ? (
|
|
<TableRow>
|
|
<TableCell colSpan={10}>
|
|
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
|
<Loader2 className="mb-2 h-10 w-10 animate-spin" />
|
|
<span className="text-sm">로딩 중...</span>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
) : filteredData.length === 0 ? (
|
|
<TableRow>
|
|
<TableCell colSpan={10}>
|
|
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
|
<Inbox className="mb-2 h-10 w-10" />
|
|
<span className="text-sm">조건에 맞는 업무가 없습니다</span>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
) : (
|
|
filteredData.map((item) => (
|
|
<TableRow
|
|
key={item.id}
|
|
className={cn(
|
|
"cursor-pointer transition-colors",
|
|
selectedTaskId === item.dbId && "bg-primary/5",
|
|
item.status === "신규접수" && "bg-amber-50/50 dark:bg-amber-950/10"
|
|
)}
|
|
onClick={() => handleSelectTask(item.dbId)}
|
|
>
|
|
<TableCell className="text-center">
|
|
<Badge variant="outline" className={cn("text-[10px] font-bold", getSourceBadge(item.sourceType))}>
|
|
{item.sourceType === "dr" ? "DR" : "ECR"}
|
|
</Badge>
|
|
</TableCell>
|
|
<TableCell className={cn("text-xs font-semibold", item.sourceType === "dr" ? "text-blue-600" : "text-amber-600")}>
|
|
{item.id}
|
|
</TableCell>
|
|
<TableCell className="text-center">
|
|
<Badge variant="outline" className={cn("text-[10px]", getStatusVariant(item.status))}>
|
|
{item.status}
|
|
</Badge>
|
|
</TableCell>
|
|
<TableCell className="text-center">
|
|
<Badge variant="outline" className={cn("text-[10px]", getPriorityVariant(item.priority))}>
|
|
{item.priority}
|
|
</Badge>
|
|
</TableCell>
|
|
<TableCell className="max-w-[200px] truncate text-xs font-medium">{item.targetName}</TableCell>
|
|
<TableCell className="text-xs">{item.reqDept}</TableCell>
|
|
<TableCell className="text-xs">{item.requester}</TableCell>
|
|
<TableCell className="text-xs">{item.date}</TableCell>
|
|
<TableCell className="text-xs">{item.dueDate}</TableCell>
|
|
<TableCell className="text-xs">
|
|
{item.designer || <span className="text-muted-foreground">미배정</span>}
|
|
</TableCell>
|
|
</TableRow>
|
|
))
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</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 border-b-2 border-border px-5 py-3">
|
|
<h2 className="text-base font-bold text-foreground">검토 및 승인</h2>
|
|
</div>
|
|
<div className="flex-1 overflow-auto p-4">
|
|
{/* 현황 카드 */}
|
|
<div className="mb-4 grid grid-cols-5 gap-2">
|
|
{STAT_CARDS.map((card) => (
|
|
<button
|
|
key={card.status}
|
|
className={cn(
|
|
"rounded-lg bg-linear-to-br p-3 text-center transition-all hover:-translate-y-0.5 hover:shadow-md",
|
|
card.color, card.textColor
|
|
)}
|
|
onClick={() => handleFilterByStatus(card.status)}
|
|
>
|
|
<div className="text-xs font-medium opacity-90">{card.label}</div>
|
|
<div className="text-2xl font-bold">{stats[card.status]}</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* 상세 정보 */}
|
|
{!selectedTask ? (
|
|
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
|
|
<ChevronRight className="mb-2 h-10 w-10" />
|
|
<span className="text-sm">좌측 목록에서 업무를 선택하세요</span>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-4">
|
|
{/* 승인 프로세스 */}
|
|
<div className="rounded-lg border border-sky-200 bg-sky-50/50 p-4 dark:border-sky-800 dark:bg-sky-950/20">
|
|
<h3 className="mb-3 text-sm font-bold text-sky-700 dark:text-sky-400">승인 프로세스</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-rose-400 bg-rose-50 text-rose-800 dark:bg-rose-950/30 dark:text-rose-300" : "border-border bg-card text-muted-foreground";
|
|
} else if (selectedTask.approvalStep > idx) {
|
|
stepClass = "border-emerald-400 bg-emerald-50 text-emerald-800 dark:bg-emerald-950/30 dark:text-emerald-300";
|
|
} 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" className="bg-emerald-600 text-white hover:bg-emerald-700" 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" className="bg-emerald-600 text-white hover:bg-emerald-700" 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" className="bg-linear-to-r from-indigo-500 to-purple-600 text-white shadow-md hover:shadow-lg" 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/50 p-4">
|
|
<h3 className="mb-3 border-b-2 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-blue-600" : "text-amber-600")}>
|
|
{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-blue-50 text-[10px] text-blue-700 dark:bg-blue-950/30 dark:text-blue-300">{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/50 p-4">
|
|
<h3 className="mb-3 border-b-2 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-amber-200 bg-amber-50/50 p-4 dark:border-amber-800 dark:bg-amber-950/20">
|
|
<h3 className="mb-2 text-sm font-semibold text-amber-800 dark:text-amber-400">검토 의견 / 메모</h3>
|
|
<Textarea
|
|
value={reviewMemoText}
|
|
onChange={(e) => setReviewMemoText(e.target.value)}
|
|
placeholder="검토 의견을 기록하세요..."
|
|
className="min-h-[60px] border-amber-200 text-xs focus-visible:ring-amber-400 dark:border-amber-800"
|
|
/>
|
|
<div className="mt-2 text-right">
|
|
<Button size="sm" onClick={handleSaveReviewMemo}>
|
|
메모 저장
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 처리 이력 */}
|
|
<div className="rounded-lg bg-muted/50 p-4">
|
|
<h3 className="mb-3 border-b-2 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-emerald-500 ring-emerald-500";
|
|
if (isLast) {
|
|
if (h.step === "반려") dotColor = "bg-rose-500 ring-rose-500";
|
|
else if (h.step !== "프로젝트") dotColor = "bg-amber-500 ring-amber-500 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-[500px]">
|
|
<DialogHeader>
|
|
<DialogTitle className="text-base sm:text-lg">설계 담당자 배정</DialogTitle>
|
|
<DialogDescription className="text-xs sm:text-sm">검토를 진행할 설계 담당자를 선택하세요.</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-3 sm:space-y-4">
|
|
<div>
|
|
<Label className="text-xs sm:text-sm">
|
|
설계 담당자 <span className="text-destructive">*</span>
|
|
</Label>
|
|
<Popover open={designerComboOpen} onOpenChange={setDesignerComboOpen}>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
role="combobox"
|
|
aria-expanded={designerComboOpen}
|
|
className="mt-1 h-8 w-full justify-between text-xs sm:h-10 sm: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="이름, 부서로 검색..." className="text-xs sm:text-sm" />
|
|
<CommandList>
|
|
<CommandEmpty className="py-3 text-center text-xs sm:text-sm">사원을 찾을 수 없습니다.</CommandEmpty>
|
|
<CommandGroup>
|
|
{employees.map((emp) => (
|
|
<CommandItem
|
|
key={emp.userId}
|
|
value={`${emp.userName} ${emp.deptName} ${emp.userId}`}
|
|
onSelect={() => {
|
|
setDesignerModalValue(emp.userId);
|
|
setDesignerComboOpen(false);
|
|
}}
|
|
className="text-xs sm:text-sm"
|
|
>
|
|
<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 className="gap-2 sm:gap-0">
|
|
<Button variant="outline" onClick={() => setDesignerModalOpen(false)} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
|
|
취소
|
|
</Button>
|
|
<Button onClick={handleConfirmDesigner} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
|
|
검토 착수
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* 반려 사유 모달 */}
|
|
<Dialog open={rejectModalOpen} onOpenChange={setRejectModalOpen}>
|
|
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
|
<DialogHeader>
|
|
<DialogTitle className="text-base sm:text-lg">반려 처리</DialogTitle>
|
|
<DialogDescription className="text-xs sm:text-sm">반려 사유를 상세히 기술하세요.</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-3">
|
|
<div>
|
|
<Label className="text-xs sm:text-sm">
|
|
반려 사유 <span className="text-destructive">*</span>
|
|
</Label>
|
|
<Textarea
|
|
value={rejectReason}
|
|
onChange={(e) => setRejectReason(e.target.value)}
|
|
placeholder="반려 사유를 상세히 기술하세요"
|
|
className="mt-1 min-h-[120px] text-xs sm:text-sm"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<DialogFooter className="gap-2 sm:gap-0">
|
|
<Button variant="outline" onClick={() => setRejectModalOpen(false)} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
|
|
취소
|
|
</Button>
|
|
<Button variant="destructive" onClick={handleConfirmReject} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
|
|
반려 확인
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* 프로젝트 생성 모달 */}
|
|
<Dialog open={projectModalOpen} onOpenChange={setProjectModalOpen}>
|
|
<DialogContent className="max-w-[95vw] sm:max-w-[700px]">
|
|
<DialogHeader>
|
|
<DialogTitle className="text-base sm:text-lg">설계 프로젝트 생성</DialogTitle>
|
|
<DialogDescription className="text-xs sm:text-sm">프로젝트 기본 정보를 입력하세요.</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-3 sm: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>
|
|
<Label className="text-xs sm:text-sm">프로젝트 번호</Label>
|
|
<Input value={projectForm.projNo} readOnly className="mt-1 h-8 bg-muted text-xs sm:h-10 sm:text-sm" />
|
|
</div>
|
|
<div>
|
|
<Label className="text-xs sm:text-sm">
|
|
프로젝트명 <span className="text-destructive">*</span>
|
|
</Label>
|
|
<Input
|
|
value={projectForm.projName}
|
|
onChange={(e) => setProjectForm((p) => ({ ...p, projName: e.target.value }))}
|
|
placeholder="프로젝트명 입력"
|
|
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label className="text-xs sm:text-sm">원 접수번호</Label>
|
|
<Input value={projectForm.projSourceNo} readOnly className="mt-1 h-8 bg-muted text-xs sm:h-10 sm:text-sm" />
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<Label className="text-xs sm:text-sm">
|
|
시작일 <span className="text-destructive">*</span>
|
|
</Label>
|
|
<Input
|
|
type="date"
|
|
value={projectForm.projStartDate}
|
|
onChange={(e) => setProjectForm((p) => ({ ...p, projStartDate: e.target.value }))}
|
|
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label className="text-xs sm:text-sm">
|
|
종료예정일 <span className="text-destructive">*</span>
|
|
</Label>
|
|
<Input
|
|
type="date"
|
|
value={projectForm.projEndDate}
|
|
onChange={(e) => setProjectForm((p) => ({ ...p, projEndDate: e.target.value }))}
|
|
className="mt-1 h-8 text-xs sm:h-10 sm:text-sm"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<Label className="text-xs sm:text-sm">
|
|
PM <span className="text-destructive">*</span>
|
|
</Label>
|
|
<Popover open={pmComboOpen} onOpenChange={setPmComboOpen}>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
role="combobox"
|
|
aria-expanded={pmComboOpen}
|
|
className="mt-1 h-8 w-full justify-between text-xs sm:h-10 sm: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="사원 검색..." className="text-xs sm:text-sm" />
|
|
<CommandList>
|
|
<CommandEmpty className="text-xs sm:text-sm py-3 text-center">사원을 찾을 수 없습니다.</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);
|
|
}}
|
|
className="text-xs sm:text-sm"
|
|
>
|
|
<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>
|
|
<Label className="text-xs sm:text-sm">고객명</Label>
|
|
<Input value={projectForm.projCustomer} readOnly className="mt-1 h-8 bg-muted text-xs sm:h-10 sm:text-sm" />
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<Label className="text-xs sm:text-sm">프로젝트 설명</Label>
|
|
<Textarea
|
|
value={projectForm.projDesc}
|
|
onChange={(e) => setProjectForm((p) => ({ ...p, projDesc: e.target.value }))}
|
|
placeholder="프로젝트 개요 및 목표를 기술하세요"
|
|
className="mt-1 min-h-[80px] text-xs sm:text-sm"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<DialogFooter className="gap-2 sm:gap-0">
|
|
<Button variant="outline" onClick={() => setProjectModalOpen(false)} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
|
|
취소
|
|
</Button>
|
|
<Button onClick={handleCreateProject} className="h-8 flex-1 bg-linear-to-r from-indigo-500 to-purple-600 text-xs text-white sm:h-10 sm:flex-none sm:text-sm">
|
|
프로젝트 생성
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|