2026-03-19 15:08:31 +09:00
|
|
|
"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";
|
2026-03-20 11:58:01 +09:00
|
|
|
import {
|
|
|
|
|
Popover,
|
|
|
|
|
PopoverContent,
|
|
|
|
|
PopoverTrigger,
|
|
|
|
|
} from "@/components/ui/popover";
|
|
|
|
|
import {
|
|
|
|
|
Command,
|
|
|
|
|
CommandEmpty,
|
|
|
|
|
CommandGroup,
|
|
|
|
|
CommandInput,
|
|
|
|
|
CommandItem,
|
|
|
|
|
CommandList,
|
|
|
|
|
} from "@/components/ui/command";
|
2026-03-19 15:08:31 +09:00
|
|
|
import {
|
|
|
|
|
Search,
|
|
|
|
|
RotateCcw,
|
|
|
|
|
RefreshCw,
|
|
|
|
|
ClipboardList,
|
|
|
|
|
Pencil,
|
|
|
|
|
Inbox,
|
|
|
|
|
CheckCircle2,
|
|
|
|
|
XCircle,
|
|
|
|
|
Rocket,
|
|
|
|
|
Eye,
|
|
|
|
|
ChevronRight,
|
|
|
|
|
ArrowRight,
|
2026-03-20 11:58:01 +09:00
|
|
|
Check,
|
|
|
|
|
ChevronsUpDown,
|
|
|
|
|
UserCircle,
|
|
|
|
|
Loader2,
|
|
|
|
|
User,
|
|
|
|
|
Users,
|
2026-03-19 15:08:31 +09:00
|
|
|
} from "lucide-react";
|
|
|
|
|
import { cn } from "@/lib/utils";
|
|
|
|
|
import { toast } from "sonner";
|
|
|
|
|
import {
|
|
|
|
|
getDesignRequestList,
|
|
|
|
|
updateDesignRequest,
|
|
|
|
|
addRequestHistory,
|
2026-03-20 11:58:01 +09:00
|
|
|
createProject,
|
2026-03-19 15:08:31 +09:00
|
|
|
} from "@/lib/api/design";
|
2026-03-20 11:58:01 +09:00
|
|
|
import { getUserList } from "@/lib/api/user";
|
|
|
|
|
import { useAuth } from "@/hooks/useAuth";
|
2026-03-19 15:08:31 +09:00
|
|
|
|
|
|
|
|
// --- 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" },
|
|
|
|
|
];
|
|
|
|
|
|
2026-03-20 11:58:01 +09:00
|
|
|
interface EmployeeOption {
|
|
|
|
|
userId: string;
|
|
|
|
|
userName: string;
|
|
|
|
|
deptName: string;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-19 15:08:31 +09:00
|
|
|
export default function DesignTaskManagementPage() {
|
2026-03-20 11:58:01 +09:00
|
|
|
const { user, userName, loading: authLoading } = useAuth();
|
2026-03-19 15:08:31 +09:00
|
|
|
const [allTasks, setAllTasks] = useState<TaskItem[]>([]);
|
|
|
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
|
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
|
|
|
|
|
const [currentTab, setCurrentTab] = useState<MainTab>("all");
|
2026-03-20 11:58:01 +09:00
|
|
|
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 {
|
|
|
|
|
// 사원 목록 로드 실패 시 빈 배열 유지
|
|
|
|
|
}
|
|
|
|
|
}, []);
|
2026-03-19 15:08:31 +09:00
|
|
|
|
|
|
|
|
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();
|
2026-03-20 11:58:01 +09:00
|
|
|
fetchEmployees();
|
|
|
|
|
}, [fetchTasks, fetchEmployees]);
|
2026-03-19 15:08:31 +09:00
|
|
|
|
|
|
|
|
// 검색 필터
|
|
|
|
|
const [searchStatus, setSearchStatus] = useState<string>("all");
|
|
|
|
|
const [searchPriority, setSearchPriority] = useState<string>("all");
|
|
|
|
|
const [searchReqDept, setSearchReqDept] = useState<string>("all");
|
|
|
|
|
const [searchKeyword, setSearchKeyword] = useState("");
|
|
|
|
|
|
2026-03-20 11:58:01 +09:00
|
|
|
// 담당자 선택 모달 상태
|
|
|
|
|
const [designerModalOpen, setDesignerModalOpen] = useState(false);
|
|
|
|
|
const [designerModalTaskId, setDesignerModalTaskId] = useState<string | null>(null);
|
|
|
|
|
const [designerModalValue, setDesignerModalValue] = useState("");
|
|
|
|
|
const [designerComboOpen, setDesignerComboOpen] = useState(false);
|
|
|
|
|
|
2026-03-19 15:08:31 +09:00
|
|
|
// 모달 상태
|
|
|
|
|
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);
|
2026-03-20 11:58:01 +09:00
|
|
|
const [pmComboOpen, setPmComboOpen] = useState(false);
|
2026-03-19 15:08:31 +09:00
|
|
|
const [projectForm, setProjectForm] = useState({
|
|
|
|
|
projNo: "",
|
|
|
|
|
projName: "",
|
|
|
|
|
projSourceNo: "",
|
|
|
|
|
projStartDate: "",
|
|
|
|
|
projEndDate: "",
|
|
|
|
|
projPM: "",
|
|
|
|
|
projCustomer: "",
|
|
|
|
|
projDesc: "",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 검토 메모
|
|
|
|
|
const [reviewMemoText, setReviewMemoText] = useState("");
|
|
|
|
|
|
2026-03-20 11:58:01 +09:00
|
|
|
// 현재 사용자 관련 업무만 필터링
|
|
|
|
|
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]);
|
|
|
|
|
|
2026-03-19 15:08:31 +09:00
|
|
|
// 탭별 카운트
|
|
|
|
|
const tabCounts = useMemo(() => {
|
2026-03-20 11:58:01 +09:00
|
|
|
const drItems = myRelatedTasks.filter((t) => t.sourceType === "dr");
|
|
|
|
|
const ecrItems = myRelatedTasks.filter((t) => t.sourceType === "ecr");
|
2026-03-19 15:08:31 +09:00
|
|
|
const newDR = drItems.filter((t) => t.status === "신규접수").length;
|
|
|
|
|
const newECR = ecrItems.filter((t) => t.status === "신규접수").length;
|
|
|
|
|
return {
|
2026-03-20 11:58:01 +09:00
|
|
|
all: newDR + newECR || myRelatedTasks.length,
|
2026-03-19 15:08:31 +09:00
|
|
|
allIsNew: newDR + newECR > 0,
|
|
|
|
|
dr: newDR || drItems.length,
|
|
|
|
|
drIsNew: newDR > 0,
|
|
|
|
|
ecr: newECR || ecrItems.length,
|
|
|
|
|
ecrIsNew: newECR > 0,
|
|
|
|
|
};
|
2026-03-20 11:58:01 +09:00
|
|
|
}, [myRelatedTasks]);
|
2026-03-19 15:08:31 +09:00
|
|
|
|
|
|
|
|
// 필터링된 데이터
|
|
|
|
|
const filteredData = useMemo(() => {
|
2026-03-20 11:58:01 +09:00
|
|
|
return myRelatedTasks.filter((item) => {
|
2026-03-19 15:08:31 +09:00
|
|
|
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;
|
|
|
|
|
});
|
2026-03-20 11:58:01 +09:00
|
|
|
}, [myRelatedTasks, currentTab, searchStatus, searchPriority, searchReqDept, searchKeyword]);
|
2026-03-19 15:08:31 +09:00
|
|
|
|
|
|
|
|
// 현황 통계
|
|
|
|
|
const stats = useMemo(() => {
|
|
|
|
|
return {
|
2026-03-20 11:58:01 +09:00
|
|
|
신규접수: 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,
|
2026-03-19 15:08:31 +09:00
|
|
|
};
|
2026-03-20 11:58:01 +09:00
|
|
|
}, [myRelatedTasks]);
|
2026-03-19 15:08:31 +09:00
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-03-20 11:58:01 +09:00
|
|
|
const handleOpenDesignerModal = useCallback((dbId: string) => {
|
|
|
|
|
setDesignerModalTaskId(dbId);
|
|
|
|
|
setDesignerModalValue("");
|
|
|
|
|
setDesignerComboOpen(false);
|
|
|
|
|
setDesignerModalOpen(true);
|
|
|
|
|
}, []);
|
2026-03-19 15:08:31 +09:00
|
|
|
|
2026-03-20 11:58:01 +09:00
|
|
|
const handleConfirmDesigner = useCallback(async () => {
|
|
|
|
|
if (!designerModalValue) {
|
|
|
|
|
toast.error("설계 담당자를 선택하세요.");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (!designerModalTaskId) return;
|
2026-03-19 15:08:31 +09:00
|
|
|
|
2026-03-20 11:58:01 +09:00
|
|
|
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]);
|
2026-03-19 15:08:31 +09:00
|
|
|
|
|
|
|
|
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);
|
2026-03-20 11:58:01 +09:00
|
|
|
const matchedEmployee = employees.find((e) => e.userName === task.designer);
|
2026-03-19 15:08:31 +09:00
|
|
|
setProjectForm({
|
|
|
|
|
projNo,
|
|
|
|
|
projName: task.targetName,
|
|
|
|
|
projSourceNo: task.id,
|
|
|
|
|
projStartDate: new Date().toISOString().split("T")[0],
|
|
|
|
|
projEndDate: task.dueDate,
|
2026-03-20 11:58:01 +09:00
|
|
|
projPM: matchedEmployee?.userId || "",
|
2026-03-19 15:08:31 +09:00
|
|
|
projCustomer: task.customer || task.reqDept,
|
|
|
|
|
projDesc: task.sourceType === "dr" ? task.spec || "" : task.reason || "",
|
|
|
|
|
});
|
|
|
|
|
setProjectModalOpen(true);
|
|
|
|
|
},
|
2026-03-20 11:58:01 +09:00
|
|
|
[allTasks, employees]
|
2026-03-19 15:08:31 +09:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
2026-03-20 11:58:01 +09:00
|
|
|
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) 이력 추가
|
2026-03-19 15:08:31 +09:00
|
|
|
const historyDate = new Date().toISOString().split("T")[0];
|
|
|
|
|
const historyRes = await addRequestHistory(projectTaskId, {
|
|
|
|
|
step: "프로젝트",
|
|
|
|
|
history_date: historyDate,
|
2026-03-20 11:58:01 +09:00
|
|
|
user_name: pmName,
|
2026-03-19 15:08:31 +09:00
|
|
|
description: `${projectForm.projNo} 프로젝트 생성 - ${projectForm.projName}`,
|
|
|
|
|
});
|
|
|
|
|
if (!historyRes.success) {
|
|
|
|
|
toast.error(historyRes.message || "이력 추가에 실패했습니다.");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-20 11:58:01 +09:00
|
|
|
// 3) 설계요청 상태 업데이트 + 프로젝트 ID 연결
|
2026-03-19 15:08:31 +09:00
|
|
|
const updateRes = await updateDesignRequest(projectTaskId, {
|
|
|
|
|
status: "프로젝트생성",
|
|
|
|
|
approval_step: 4,
|
2026-03-20 11:58:01 +09:00
|
|
|
project_id: createdProjectId,
|
2026-03-19 15:08:31 +09:00
|
|
|
});
|
|
|
|
|
if (!updateRes.success) {
|
|
|
|
|
toast.error(updateRes.message || "상태 업데이트에 실패했습니다.");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
setProjectModalOpen(false);
|
|
|
|
|
toast.success(`프로젝트 ${projectForm.projNo}가 생성되었습니다.`);
|
|
|
|
|
fetchTasks();
|
2026-03-20 11:58:01 +09:00
|
|
|
}, [projectForm, projectTaskId, employees, fetchTasks]);
|
2026-03-19 15:08:31 +09:00
|
|
|
|
|
|
|
|
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" />
|
2026-03-20 11:58:01 +09:00
|
|
|
<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>
|
2026-03-19 15:08:31 +09:00
|
|
|
</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">
|
2026-03-20 11:58:01 +09:00
|
|
|
{myTasksOnly ? "내 관련 업무" : "접수 업무 목록"} ({filteredData.length}건)
|
|
|
|
|
{myTasksOnly && (
|
|
|
|
|
<span className="ml-2 text-xs font-normal text-muted-foreground">
|
|
|
|
|
전체 {allTasks.length}건 중
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
2026-03-19 15:08:31 +09:00
|
|
|
</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",
|
2026-03-20 11:58:01 +09:00
|
|
|
selectedTaskId === item.dbId && "bg-primary/5",
|
2026-03-19 15:08:31 +09:00
|
|
|
item.status === "신규접수" && "bg-amber-50/50 dark:bg-amber-950/10"
|
|
|
|
|
)}
|
2026-03-20 11:58:01 +09:00
|
|
|
onClick={() => handleSelectTask(item.dbId)}
|
2026-03-19 15:08:31 +09:00
|
|
|
>
|
|
|
|
|
<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 === "신규접수" && (
|
|
|
|
|
<>
|
2026-03-20 11:58:01 +09:00
|
|
|
<Button size="sm" className="bg-emerald-600 text-white hover:bg-emerald-700" onClick={() => handleOpenDesignerModal(selectedTask.dbId)}>
|
2026-03-19 15:08:31 +09:00
|
|
|
<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>
|
|
|
|
|
|
2026-03-20 11:58:01 +09:00
|
|
|
{/* 설계 담당자 선택 모달 */}
|
|
|
|
|
<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>
|
|
|
|
|
|
2026-03-19 15:08:31 +09:00
|
|
|
{/* 반려 사유 모달 */}
|
|
|
|
|
<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>
|
2026-03-20 11:58:01 +09:00
|
|
|
<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>
|
2026-03-19 15:08:31 +09:00
|
|
|
</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>
|
|
|
|
|
);
|
|
|
|
|
}
|