diff --git a/.omc/state/idle-notif-cooldown.json b/.omc/state/idle-notif-cooldown.json index 351027da..176c69ac 100644 --- a/.omc/state/idle-notif-cooldown.json +++ b/.omc/state/idle-notif-cooldown.json @@ -1,3 +1,3 @@ { - "lastSentAt": "2026-03-24T02:34:43.277Z" + "lastSentAt": "2026-03-24T02:36:44.477Z" } \ No newline at end of file diff --git a/frontend/app/(main)/master-data/item-info/page.tsx b/frontend/app/(main)/master-data/item-info/page.tsx index b4be52f0..c3ad7029 100644 --- a/frontend/app/(main)/master-data/item-info/page.tsx +++ b/frontend/app/(main)/master-data/item-info/page.tsx @@ -151,16 +151,28 @@ export default function ItemInfoPage() { autoFilter: true, }); - const data = res.data?.data?.data || res.data?.data?.rows || []; + const raw = res.data?.data?.data || res.data?.data?.rows || []; + // 카테고리 코드→라벨 변환 + const resolve = (col: string, code: string) => { + if (!code) return ""; + return categoryOptions[col]?.find((o) => o.code === code)?.label || code; + }; + const data = raw.map((r: any) => { + const converted = { ...r }; + for (const col of CATEGORY_COLUMNS) { + if (converted[col]) converted[col] = resolve(col, converted[col]); + } + return converted; + }); setItems(data); - setTotalCount(res.data?.data?.total || data.length); + setTotalCount(res.data?.data?.total || raw.length); } catch (err) { console.error("품목 조회 실패:", err); toast.error("품목 목록을 불러오는데 실패했습니다."); } finally { setLoading(false); } - }, [searchKeyword, searchDivision, searchType, searchStatus]); + }, [searchKeyword, searchDivision, searchType, searchStatus, categoryOptions]); useEffect(() => { fetchItems(); diff --git a/frontend/app/(main)/production/plan-management/page.tsx b/frontend/app/(main)/production/plan-management/page.tsx index 6e1f70b3..26359d3a 100644 --- a/frontend/app/(main)/production/plan-management/page.tsx +++ b/frontend/app/(main)/production/plan-management/page.tsx @@ -36,7 +36,6 @@ import { ResizablePanelGroup, } from "@/components/ui/resizable"; import { - Settings, Upload, Download, RefreshCw, @@ -48,10 +47,12 @@ import { Wrench, AlertTriangle, ClipboardList, - Factory, Calendar, Scissors, Loader2, + Maximize2, + Minimize2, + Merge, } from "lucide-react"; import { cn } from "@/lib/utils"; import { toast } from "sonner"; @@ -59,14 +60,12 @@ import { getOrderSummary, getStockShortage, getPlans, - getPlanById, updatePlan, deletePlan, generateSchedule, previewSchedule, mergeSchedules, generateSemiSchedule, - previewSemiSchedule, splitSchedule, type OrderSummaryItem, type StockShortageItem, @@ -74,6 +73,34 @@ import { type GenerateScheduleRequest, type GenerateScheduleResponse, } from "@/lib/api/production"; +import TimelineScheduler, { + type TimelineResource, + type TimelineEvent, + type ZoomLevel, + type StatusColor, +} from "@/components/common/TimelineScheduler"; +import { DynamicSearchFilter, type FilterValue } from "@/components/common/DynamicSearchFilter"; +import { useConfirmDialog } from "@/components/common/ConfirmDialog"; +import { ExcelUploadModal } from "@/components/common/ExcelUploadModal"; +import { exportToExcel } from "@/lib/utils/excelExport"; +import { FormDatePicker } from "@/components/screen/filters/FormDatePicker"; +import { apiClient } from "@/lib/api/client"; + +// ─── 상수 ─── + +const STATUS_COLORS: StatusColor[] = [ + { key: "planned", label: "계획", bgClass: "from-blue-500 to-blue-600" }, + { key: "work-order", label: "지시", bgClass: "from-amber-500 to-amber-600" }, + { key: "in-progress", label: "진행", bgClass: "from-emerald-500 to-emerald-600" }, + { key: "completed", label: "완료", bgClass: "from-gray-400 to-gray-500" }, +]; + +const STATUS_LABEL: Record = { + planned: "계획", + "work-order": "작업지시", + "in-progress": "진행중", + completed: "완료", +}; // ========== 메인 컴포넌트 ========== @@ -82,6 +109,9 @@ export default function ProductionPlanManagementPage() { const [leftTab, setLeftTab] = useState("order"); const [rightTab, setRightTab] = useState("finished"); + // 전체화면 토글 + const [isFullscreen, setIsFullscreen] = useState(false); + // 로딩 상태 const [loadingOrders, setLoadingOrders] = useState(false); const [loadingStock, setLoadingStock] = useState(false); @@ -94,24 +124,25 @@ export default function ProductionPlanManagementPage() { const [stockItems, setStockItems] = useState([]); const [finishedPlans, setFinishedPlans] = useState([]); const [semiPlans, setSemiPlans] = useState([]); + const [equipmentList, setEquipmentList] = useState<{ equipment_id: string; equipment_name: string }[]>([]); // 선택/토글 상태 const [expandedItems, setExpandedItems] = useState>(new Set()); const [selectedItemGroups, setSelectedItemGroups] = useState>(new Set()); const [filterUnplannedOrdersOnly, setFilterUnplannedOrdersOnly] = useState(false); const [selectedStockItems, setSelectedStockItems] = useState>(new Set()); - const [filterUnplannedOnly, setFilterUnplannedOnly] = useState(false); - // 검색 필터 + // 검색 필터 (DynamicSearchFilter에서 사용) + const [searchFilters, setSearchFilters] = useState([]); const [searchItemCode, setSearchItemCode] = useState(""); const [searchStatus, setSearchStatus] = useState("all"); const [searchStartDate, setSearchStartDate] = useState(""); const [searchEndDate, setSearchEndDate] = useState(""); // 타임라인 옵션 - // 리드타임은 품목정보(item_info)에서 관리 - const [displayWeeks, setDisplayWeeks] = useState(4); const [recalculateUnstarted, setRecalculateUnstarted] = useState(true); + const [finishedZoom, setFinishedZoom] = useState("week"); + const [semiZoom, setSemiZoom] = useState("week"); // 반제품 옵션 const [semiConsiderStock, setSemiConsiderStock] = useState(true); @@ -122,8 +153,8 @@ export default function ProductionPlanManagementPage() { const [scheduleModalOpen, setScheduleModalOpen] = useState(false); const [orderImportModalOpen, setOrderImportModalOpen] = useState(false); const [stockImportModalOpen, setStockImportModalOpen] = useState(false); - const [equipmentModalOpen, setEquipmentModalOpen] = useState(false); const [changeConfirmModalOpen, setChangeConfirmModalOpen] = useState(false); + const [excelUploadOpen, setExcelUploadOpen] = useState(false); // 모달 데이터 const [selectedPlan, setSelectedPlan] = useState(null); @@ -133,6 +164,7 @@ export default function ProductionPlanManagementPage() { const [modalManager, setModalManager] = useState(""); const [modalWorkOrderNo, setModalWorkOrderNo] = useState(""); const [modalRemarks, setModalRemarks] = useState(""); + const [modalEquipmentId, setModalEquipmentId] = useState(""); // 미리보기 데이터 const [previewData, setPreviewData] = useState(null); @@ -140,6 +172,12 @@ export default function ProductionPlanManagementPage() { // 불러오기 모드 const [importMode, setImportMode] = useState<"add" | "new">("add"); + // 병합: 타임라인에서 선택된 계획 ID + const [selectedPlanIds, setSelectedPlanIds] = useState>(new Set()); + + // useConfirmDialog + const { confirm, ConfirmDialogComponent } = useConfirmDialog(); + // ========== 데이터 로드 ========== const fetchOrderSummary = useCallback(async () => { @@ -149,9 +187,7 @@ export default function ProductionPlanManagementPage() { excludePlanned: filterUnplannedOrdersOnly, itemCode: searchItemCode || undefined, }); - if (res.success) { - setOrderItems(res.data || []); - } + if (res.success) setOrderItems(res.data || []); } catch (err: any) { toast.error("수주 데이터 조회 실패: " + (err.message || "")); } finally { @@ -163,9 +199,7 @@ export default function ProductionPlanManagementPage() { setLoadingStock(true); try { const res = await getStockShortage(); - if (res.success) { - setStockItems(res.data || []); - } + if (res.success) setStockItems(res.data || []); } catch (err: any) { toast.error("안전재고 부족분 조회 실패: " + (err.message || "")); } finally { @@ -200,12 +234,49 @@ export default function ProductionPlanManagementPage() { } }, [searchStatus, searchStartDate, searchEndDate, searchItemCode]); + const fetchEquipmentList = useCallback(async () => { + try { + const res = await apiClient.get("/process-info/equipments"); + if (res.data?.success) setEquipmentList(res.data.data || []); + } catch { + // 설비 목록 없어도 정상 동작 + } + }, []); + useEffect(() => { fetchOrderSummary(); fetchStockShortage(); fetchPlans(); + fetchEquipmentList(); }, []); + // ========== DynamicSearchFilter 콜백 ========== + + const handleSearchFilterChange = useCallback( + (filters: FilterValue[]) => { + setSearchFilters(filters); + // 필터에서 주요 값 추출 + let itemCode = ""; + let status = "all"; + let startDate = ""; + let endDate = ""; + for (const f of filters) { + if (f.columnName === "item_code" && f.value) itemCode = f.value; + if (f.columnName === "status" && f.value) status = f.value; + if (f.columnName === "start_date" && f.value) { + const [s, e] = f.value.split(","); + if (s) startDate = s; + if (e) endDate = e; + } + } + setSearchItemCode(itemCode); + setSearchStatus(status); + setSearchStartDate(startDate); + setSearchEndDate(endDate); + }, + [] + ); + // ========== 토글/선택 핸들러 ========== const toggleItemExpand = useCallback((itemCode: string) => { @@ -228,11 +299,7 @@ export default function ProductionPlanManagementPage() { const toggleAllItemGroups = useCallback( (checked: boolean) => { - if (checked) { - setSelectedItemGroups(new Set(orderItems.map((i) => i.item_code))); - } else { - setSelectedItemGroups(new Set()); - } + setSelectedItemGroups(checked ? new Set(orderItems.map((i) => i.item_code)) : new Set()); }, [orderItems] ); @@ -248,70 +315,66 @@ export default function ProductionPlanManagementPage() { const toggleAllStockItems = useCallback( (checked: boolean) => { - if (checked) { - setSelectedStockItems(new Set(stockItems.map((i) => i.item_code))); - } else { - setSelectedStockItems(new Set()); - } + setSelectedStockItems(checked ? new Set(stockItems.map((i) => i.item_code)) : new Set()); }, [stockItems] ); - // ========== 타임라인 날짜 ========== + // ========== 타임라인 리소스 & 이벤트 ========== - const timelineDates = useMemo(() => { - const dates: Date[] = []; - const today = new Date(); - today.setHours(0, 0, 0, 0); - for (let i = 0; i < displayWeeks * 7; i++) { - const d = new Date(today); - d.setDate(today.getDate() + i); - dates.push(d); + const finishedResources: TimelineResource[] = useMemo(() => { + const map = new Map(); + for (const p of finishedPlans) { + if (!map.has(p.item_code)) { + map.set(p.item_code, { + id: p.item_code, + label: p.item_code, + subLabel: p.item_name, + }); + } } - return dates; - }, [displayWeeks]); + return Array.from(map.values()); + }, [finishedPlans]); - const isWeekend = (date: Date) => date.getDay() === 0 || date.getDay() === 6; - const isToday = (date: Date) => { - const today = new Date(); - return date.getFullYear() === today.getFullYear() && date.getMonth() === today.getMonth() && date.getDate() === today.getDate(); - }; - const formatDateLabel = (date: Date) => { - const dayNames = ["일", "월", "화", "수", "목", "금", "토"]; - return { day: date.getDate(), dayName: dayNames[date.getDay()] }; - }; + const finishedEvents: TimelineEvent[] = useMemo(() => { + return finishedPlans.map((p) => ({ + id: p.id, + resourceId: p.item_code, + startDate: p.start_date?.split("T")[0] || "", + endDate: p.end_date?.split("T")[0] || "", + label: `${Number(p.plan_qty).toLocaleString()}`, + status: p.status, + progress: Number(p.progress_rate) || 0, + data: p, + })); + }, [finishedPlans]); - // 생산 바의 위치/너비를 계산 - const getBarPosition = useCallback( - (startDate: string, endDate: string) => { - if (timelineDates.length === 0) return null; - const start = new Date(startDate); - const end = new Date(endDate); - const firstDate = timelineDates[0]; - const lastDate = timelineDates[timelineDates.length - 1]; + const semiResources: TimelineResource[] = useMemo(() => { + const map = new Map(); + for (const p of semiPlans) { + if (!map.has(p.item_code)) { + map.set(p.item_code, { + id: p.item_code, + label: p.item_code, + subLabel: p.item_name, + }); + } + } + return Array.from(map.values()); + }, [semiPlans]); - if (end < firstDate || start > lastDate) return null; - - const totalDays = timelineDates.length; - const cellWidth = 100 / totalDays; - - const startIdx = Math.max(0, Math.floor((start.getTime() - firstDate.getTime()) / (1000 * 60 * 60 * 24))); - const endIdx = Math.min(totalDays - 1, Math.floor((end.getTime() - firstDate.getTime()) / (1000 * 60 * 60 * 24))); - - return { - left: `${startIdx * cellWidth}%`, - width: `${(endIdx - startIdx + 1) * cellWidth}%`, - }; - }, - [timelineDates] - ); - - const statusBarColor: Record = { - planned: "from-blue-500 to-blue-600", - "work-order": "from-amber-500 to-amber-600", - "in-progress": "from-emerald-500 to-emerald-600", - completed: "from-gray-500 to-gray-600 opacity-70", - }; + const semiEvents: TimelineEvent[] = useMemo(() => { + return semiPlans.map((p) => ({ + id: p.id, + resourceId: p.item_code, + startDate: p.start_date?.split("T")[0] || "", + endDate: p.end_date?.split("T")[0] || "", + label: `${Number(p.plan_qty).toLocaleString()}`, + status: p.status, + progress: Number(p.progress_rate) || 0, + data: p, + })); + }, [semiPlans]); // ========== 액션 핸들러 ========== @@ -320,6 +383,7 @@ export default function ProductionPlanManagementPage() { fetchPlans(); }, [fetchOrderSummary, fetchPlans]); + // 자동 스케줄 생성 (preview → confirm → apply) const handleGenerateSchedule = useCallback(async () => { if (selectedItemGroups.size === 0) { toast.error("품목을 선택해주세요"); @@ -346,8 +410,6 @@ export default function ProductionPlanManagementPage() { product_type: "완제품", }, }; - - // 미리보기 const previewRes = await previewSchedule(req); if (previewRes.success) { setPreviewData(previewRes.data); @@ -397,6 +459,7 @@ export default function ProductionPlanManagementPage() { } }, [selectedItemGroups, orderItems, recalculateUnstarted, fetchPlans, fetchOrderSummary]); + // 타임라인 초기화 (계획 상태만 삭제) const handleClearTimeline = useCallback(async () => { if (finishedPlans.length === 0) { toast.info("삭제할 계획이 없습니다"); @@ -407,6 +470,13 @@ export default function ProductionPlanManagementPage() { toast.info("삭제 가능한 계획이 없습니다 (계획 상태만 삭제 가능)"); return; } + const ok = await confirm(`${plannedIds.length}건의 계획을 삭제하시겠습니까?`, { + description: "삭제된 계획은 복구할 수 없습니다.", + variant: "destructive", + confirmText: "삭제", + }); + if (!ok) return; + try { await Promise.all(plannedIds.map((id) => deletePlan(id))); toast.success(`${plannedIds.length}건의 계획이 삭제되었습니다`); @@ -414,8 +484,9 @@ export default function ProductionPlanManagementPage() { } catch (err: any) { toast.error("삭제 실패: " + (err.message || "")); } - }, [finishedPlans, fetchPlans]); + }, [finishedPlans, fetchPlans, confirm]); + // 반제품 자동 생성 const handleGenerateSemiSchedule = useCallback(async () => { const planIds = finishedPlans.map((p) => p.id); if (planIds.length === 0) { @@ -440,14 +511,17 @@ export default function ProductionPlanManagementPage() { }, [finishedPlans, semiConsiderStock, semiExcludeUsed, fetchPlans]); // 스케줄 상세 모달 열기 - const openScheduleDetail = useCallback(async (plan: ProductionPlan) => { + const openScheduleDetail = useCallback((event: TimelineEvent) => { + const plan = event.data as ProductionPlan; + if (!plan) return; setSelectedPlan(plan); setModalQuantity(Number(plan.plan_qty)); setModalStartDate(plan.start_date?.split("T")[0] || ""); setModalEndDate(plan.end_date?.split("T")[0] || ""); - setModalManager(plan.manager_name || ""); - setModalWorkOrderNo(plan.work_order_no || ""); + setModalManager((plan as any).manager_name || ""); + setModalWorkOrderNo((plan as any).work_order_no || ""); setModalRemarks(plan.remarks || ""); + setModalEquipmentId(plan.equipment_id ? String(plan.equipment_id) : ""); setScheduleModalOpen(true); }, []); @@ -462,7 +536,8 @@ export default function ProductionPlanManagementPage() { manager_name: modalManager, work_order_no: modalWorkOrderNo, remarks: modalRemarks, - }); + equipment_id: modalEquipmentId ? Number(modalEquipmentId) : null, + } as any); if (res.success) { toast.success("생산계획이 수정되었습니다"); setScheduleModalOpen(false); @@ -473,10 +548,15 @@ export default function ProductionPlanManagementPage() { } finally { setSaving(false); } - }, [selectedPlan, modalQuantity, modalStartDate, modalEndDate, modalManager, modalWorkOrderNo, modalRemarks, fetchPlans]); + }, [selectedPlan, modalQuantity, modalStartDate, modalEndDate, modalManager, modalWorkOrderNo, modalRemarks, modalEquipmentId, fetchPlans]); const handleDeletePlan = useCallback(async () => { if (!selectedPlan) return; + const ok = await confirm("이 생산계획을 삭제하시겠습니까?", { + variant: "destructive", + confirmText: "삭제", + }); + if (!ok) return; try { await deletePlan(selectedPlan.id); toast.success("삭제되었습니다"); @@ -485,7 +565,7 @@ export default function ProductionPlanManagementPage() { } catch (err: any) { toast.error("삭제 실패: " + (err.message || "")); } - }, [selectedPlan, fetchPlans]); + }, [selectedPlan, fetchPlans, confirm]); const handleSplitSchedule = useCallback(async (splitQty: number) => { if (!selectedPlan || splitQty <= 0) return; @@ -501,6 +581,64 @@ export default function ProductionPlanManagementPage() { } }, [selectedPlan, fetchPlans]); + // 병합 핸들러 + const handleMergeSchedules = useCallback(async () => { + if (selectedPlanIds.size < 2) { + toast.error("2개 이상의 계획을 선택해주세요"); + return; + } + const ok = await confirm(`${selectedPlanIds.size}건의 계획을 병합하시겠습니까?`, { + description: "동일 품목의 계획이 하나로 병합됩니다.", + confirmText: "병합", + }); + if (!ok) return; + + try { + const ids = Array.from(selectedPlanIds); + const productType = rightTab === "finished" ? "완제품" : "반제품"; + const res = await mergeSchedules(ids, productType); + if (res.success) { + toast.success("계획이 병합되었습니다"); + setSelectedPlanIds(new Set()); + fetchPlans(); + } + } catch (err: any) { + toast.error("병합 실패: " + (err.message || "")); + } + }, [selectedPlanIds, rightTab, fetchPlans, confirm]); + + // 타임라인 이벤트 드래그 이동 + const handleEventMove = useCallback(async (eventId: string | number, newStart: string, newEnd: string) => { + try { + const res = await updatePlan(Number(eventId), { + start_date: newStart, + end_date: newEnd, + } as any); + if (res.success) { + toast.success("일정이 변경되었습니다"); + fetchPlans(); + } + } catch (err: any) { + toast.error("일정 변경 실패: " + (err.message || "")); + } + }, [fetchPlans]); + + // 타임라인 이벤트 리사이즈 + const handleEventResize = useCallback(async (eventId: string | number, newStart: string, newEnd: string) => { + try { + const res = await updatePlan(Number(eventId), { + start_date: newStart, + end_date: newEnd, + } as any); + if (res.success) { + toast.success("기간이 변경되었습니다"); + fetchPlans(); + } + } catch (err: any) { + toast.error("기간 변경 실패: " + (err.message || "")); + } + }, [fetchPlans]); + // 불러오기 처리 const handleImportOrderItems = useCallback(async () => { if (selectedItemGroups.size === 0) { @@ -576,176 +714,99 @@ export default function ProductionPlanManagementPage() { } }, [selectedStockItems, stockItems, importMode, fetchPlans, fetchStockShortage]); + // 엑셀 다운로드 + const handleExcelDownload = useCallback(async () => { + const plans = rightTab === "finished" ? finishedPlans : semiPlans; + if (plans.length === 0) { + toast.error("다운로드할 데이터가 없습니다"); + return; + } + const data = plans.map((p) => ({ + 계획번호: p.plan_no || "", + 품목코드: p.item_code, + 품목명: p.item_name, + 제품구분: p.product_type, + 수량: Number(p.plan_qty), + 완료수량: Number(p.completed_qty), + 진행률: `${p.progress_rate || 0}%`, + 시작일: p.start_date?.split("T")[0] || "", + 종료일: p.end_date?.split("T")[0] || "", + 납기일: p.due_date?.split("T")[0] || "", + 상태: STATUS_LABEL[p.status] || p.status, + 설비: p.equipment_name || "", + 비고: p.remarks || "", + })); + const type = rightTab === "finished" ? "완제품" : "반제품"; + await exportToExcel(data, `생산계획_${type}.xlsx`, `${type} 생산계획`); + toast.success("엑셀 다운로드 완료"); + }, [rightTab, finishedPlans, semiPlans]); + + // 좌측 테이블 엑셀 다운로드 + const handleLeftExcelDownload = useCallback(async () => { + if (leftTab === "order") { + if (orderItems.length === 0) { toast.error("데이터가 없습니다"); return; } + const data = orderItems.map((i) => ({ + 품목코드: i.item_code, + 품목명: i.item_name, + 총수주량: Number(i.total_order_qty), + 출고량: Number(i.total_ship_qty), + 잔량: Number(i.total_balance_qty), + 현재고: Number(i.current_stock), + 안전재고: Number(i.safety_stock), + 기생산계획량: Number(i.existing_plan_qty), + 생산진행: Number(i.in_progress_qty), + 필요생산계획: Number(i.required_plan_qty), + "리드타임(일)": Number(i.lead_time) || 0, + })); + await exportToExcel(data, "수주데이터.xlsx", "수주데이터"); + } else { + if (stockItems.length === 0) { toast.error("데이터가 없습니다"); return; } + const data = stockItems.map((s) => ({ + 품목코드: s.item_code, + 품목명: s.item_name, + 현재고: Number(s.current_qty), + 안전재고: Number(s.safety_qty), + 부족수량: Number(s.shortage_qty), + 권장생산량: Number(s.recommended_qty), + 최종입고일: s.last_in_date || "", + })); + await exportToExcel(data, "안전재고부족분.xlsx", "안전재고 부족분"); + } + toast.success("엑셀 다운로드 완료"); + }, [leftTab, orderItems, stockItems]); + // 숫자 포맷 const formatNumber = (num: number | string) => Number(num).toLocaleString(); - // 타임라인 렌더링을 위해 품목별 그룹핑 - const groupedPlans = useMemo(() => { - const map = new Map(); - for (const plan of finishedPlans) { - const key = plan.item_code; - if (!map.has(key)) map.set(key, []); - map.get(key)!.push(plan); - } - return map; - }, [finishedPlans]); - - const groupedSemiPlans = useMemo(() => { - const map = new Map(); - for (const plan of semiPlans) { - const key = plan.item_code; - if (!map.has(key)) map.set(key, []); - map.get(key)!.push(plan); - } - return map; - }, [semiPlans]); - - // ========== 렌더링 ========== - - const renderTimeline = (plans: Map) => { - if (plans.size === 0) return null; - - return ( -
- {/* 날짜 헤더 */} -
- {timelineDates.map((date, idx) => { - const { day, dayName } = formatDateLabel(date); - return ( -
-
{day}
-
{dayName}
-
- ); - })} -
- - {/* 품목별 행 */} - {Array.from(plans.entries()).map(([itemCode, itemPlans]) => ( -
-
- {itemCode} - {itemPlans[0]?.item_name} -
-
- {/* 그리드 배경 */} -
- {timelineDates.map((date, idx) => ( -
- ))} -
- {/* 생산 바 */} - {itemPlans.map((plan) => { - const pos = getBarPosition(plan.start_date, plan.end_date); - if (!pos) return null; - const colorClass = statusBarColor[plan.status] || statusBarColor.planned; - return ( -
openScheduleDetail(plan)} - > - - {formatNumber(plan.plan_qty)} - -
- ); - })} -
-
- ))} -
- ); - }; - return ( -
- {/* 검색 섹션 */} -
-
-
-
- - setSearchStartDate(e.target.value)} - className="h-9 w-[150px] text-sm" - /> - ~ - setSearchEndDate(e.target.value)} - className="h-9 w-[150px] text-sm" - /> -
-
- - setSearchItemCode(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && handleSearch()} - className="h-9 w-[160px] text-sm" - /> -
-
- - -
- -
-
- - + } + /> +
+ - +
@@ -753,7 +814,7 @@ export default function ProductionPlanManagementPage() { {/* 데이터 섹션 */} {/* 왼쪽 패널 */} - +
@@ -787,7 +848,11 @@ export default function ProductionPlanManagementPage() { + + + {selectedPlanIds.size >= 2 && ( + + )} - -
-
-
- {/* 옵션 & 범례 */} -
-
-
-
- - setDisplayWeeks(Number(e.target.value))} className="h-8 w-[100px] text-xs" min={1} max={12} /> -
-
- setRecalculateUnstarted(!!c)} className="h-4 w-4" /> - -
-
-
- 상태: -
계획
-
지시
-
진행
-
완료
-
+
+ {/* 옵션 */} +
+
+
+ setRecalculateUnstarted(!!c)} className="h-4 w-4" /> +
- - {/* 타임라인 */} - {loadingPlans ? ( -
- -
- ) : ( -
- {groupedPlans.size === 0 ? ( -
- -

생산 스케줄이 없습니다

-

왼쪽에서 품목을 선택하고 “자동 스케줄 생성” 버튼을 클릭하세요

-
- ) : ( - renderTimeline(groupedPlans) - )} -
- )}
+ + {/* 타임라인 */} + } + onEventClick={openScheduleDetail} + onEventMove={handleEventMove} + onEventResize={handleEventResize} + />
@@ -1094,23 +1150,24 @@ export default function ProductionPlanManagementPage() {
- {loadingPlans ? ( -
- -
- ) : ( -
- {groupedSemiPlans.size === 0 ? ( -
- -

반제품 생산 스케줄이 없습니다

-

“반제품 계획 자동 생성” 버튼을 클릭하여 생성하세요

-
- ) : ( - renderTimeline(groupedSemiPlans) - )} -
- )} + {/* 반제품 타임라인 */} + } + onEventClick={openScheduleDetail} + onEventMove={handleEventMove} + onEventResize={handleEventResize} + />
@@ -1120,7 +1177,7 @@ export default function ProductionPlanManagementPage() { {/* ========== 모달들 ========== */} - {/* 스케줄 상세 모달 */} + {/* 스케줄 상세/편집 모달 */} @@ -1153,7 +1210,7 @@ export default function ProductionPlanManagementPage() {
- {selectedPlan.status === "planned" ? "계획" : selectedPlan.status === "work-order" ? "작업지시" : selectedPlan.status === "in-progress" ? "진행중" : "완료"} + {STATUS_LABEL[selectedPlan.status] || selectedPlan.status}
@@ -1167,8 +1224,20 @@ export default function ProductionPlanManagementPage() { setModalQuantity(Number(e.target.value))} className="h-8 text-xs" min={0} />
- - + +
@@ -1176,11 +1245,11 @@ export default function ProductionPlanManagementPage() {
- setModalStartDate(e.target.value)} className="h-8 text-xs" /> +
- setModalEndDate(e.target.value)} className="h-8 text-xs" /> +
@@ -1202,7 +1271,7 @@ export default function ProductionPlanManagementPage() {

+ +
+ } + /> + + {/* 분할 패널 */} +
+ + {/* 좌측: 거래처 목록 */} + +
+
+
+ 거래처 목록 + {customerCount}건 +
+
+ + + +
+
+ openCustomerEdit()} + tableName={CUSTOMER_TABLE} + emptyMessage="등록된 거래처가 없습니다" + /> +
+
+ + + + {/* 우측: 탭 (품목 정보 / 납품처) */} + +
+ {/* 탭 헤더 */} +
+
+ + + {selectedCustomer && {selectedCustomer.customer_name}} +
+
+ {rightTab === "items" && ( + + )} + {rightTab === "delivery" && ( + + )} +
+
+ + {/* 탭 콘텐츠 */} + {!selectedCustomerId ? ( +
+ 좌측에서 거래처를 선택하세요 +
+ ) : rightTab === "items" ? ( + openEditItem(row)} + /> + ) : ( + + )} +
+
+
+
+ + {/* 거래처 등록/수정 모달 */} + + + + {customerEditMode ? "거래처 수정" : "거래처 등록"} + {customerEditMode ? "거래처 정보를 수정합니다." : "새로운 거래처를 등록합니다."} + +
+
+ + setCustomerForm((p) => ({ ...p, customer_code: e.target.value }))} + placeholder="거래처 코드" className="h-9" disabled={customerEditMode} /> +
+
+ + setCustomerForm((p) => ({ ...p, customer_name: e.target.value }))} + placeholder="거래처명" className="h-9" /> +
+
+ + {renderSelect("division", customerForm.division, (v) => setCustomerForm((p) => ({ ...p, division: v })), "거래 유형")} +
+
+ + {renderSelect("status", customerForm.status, (v) => setCustomerForm((p) => ({ ...p, status: v })), "상태")} +
+
+ + setCustomerForm((p) => ({ ...p, contact_person: e.target.value }))} + placeholder="담당자" className="h-9" /> +
+
+ + handleFormChange("contact_phone", e.target.value)} + placeholder="010-0000-0000" className={cn("h-9", formErrors.contact_phone && "border-destructive")} /> + {formErrors.contact_phone &&

{formErrors.contact_phone}

} +
+
+ + handleFormChange("email", e.target.value)} + placeholder="example@email.com" className={cn("h-9", formErrors.email && "border-destructive")} /> + {formErrors.email &&

{formErrors.email}

} +
+
+ + handleFormChange("business_number", e.target.value)} + placeholder="000-00-00000" className={cn("h-9", formErrors.business_number && "border-destructive")} /> + {formErrors.business_number &&

{formErrors.business_number}

} +
+
+ + setCustomerForm((p) => ({ ...p, address: e.target.value }))} + placeholder="주소" className="h-9" /> +
+
+ + + + +
+
+ + {/* 품목 추가 모달 */} + + e.preventDefault()}> + + 품목 선택 + 거래처에 추가할 품목을 선택하세요. + +
+ setItemSearchKeyword(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && searchItems()} + className="h-9 flex-1" /> + +
+
+ + + + + 0 && itemCheckedIds.size === itemSearchResults.length} + onChange={(e) => { + if (e.target.checked) setItemCheckedIds(new Set(itemSearchResults.map((i) => i.id))); + else setItemCheckedIds(new Set()); + }} /> + + 품목코드 + 품명 + 규격 + 재질 + 단위 + + + + {itemSearchResults.length === 0 ? ( + 검색 결과가 없습니다 + ) : itemSearchResults.map((item) => ( + setItemCheckedIds((prev) => { + const next = new Set(prev); + if (next.has(item.id)) next.delete(item.id); else next.add(item.id); + return next; + })}> + + {item.item_number} + {item.item_name} + {item.size} + {item.material} + {item.unit} + + ))} + +
+
+ +
+ {itemCheckedIds.size}개 선택됨 +
+ + +
+
+
+
+
+ + {/* 품목 상세정보 입력 모달 (2단계) */} + + + + + } + > + +
+ {selectedItemsForDetail.map((item, idx) => { + const itemKey = item.item_number || item.id; + const mappingRows = itemMappings[itemKey] || []; + const prices = itemPrices[itemKey] || []; + + return ( +
+ {/* 품목 헤더 */} +
+
{idx + 1}. {item.item_name || itemKey}
+
{itemKey} | {item.size || ""} | {item.unit || ""}
+
+ +
+ {/* 좌: 거래처 품번/품명 (다중) */} +
+
+ 🏷 거래처 품번/품명 관리 + +
+
+ {mappingRows.length === 0 ? ( +
입력된 거래처 품번이 없습니다
+ ) : mappingRows.map((mRow, mIdx) => ( +
+ {mIdx + 1} + updateMappingRow(itemKey, mRow._id, "customer_item_code", e.target.value)} + placeholder="거래처 품번" className="h-8 text-sm flex-1" /> + updateMappingRow(itemKey, mRow._id, "customer_item_name", e.target.value)} + placeholder="거래처 품명" className="h-8 text-sm flex-1" /> + +
+ ))} +
+
+ + {/* 우: 기간별 단가 */} +
+
+ 💰 기간별 단가 설정 + +
+ +
+ {prices.map((price, pIdx) => ( +
+
+ 단가 {pIdx + 1} + {prices.length > 1 && ( + + )} +
+ {/* 기간 */} +
+
+ updatePriceRow(itemKey, price._id, "start_date", v)} placeholder="시작일" /> +
+ ~ +
+ updatePriceRow(itemKey, price._id, "end_date", v)} placeholder="종료일" /> +
+
+ +
+
+ {/* 기준가/할인/반올림 */} +
+
+ +
+ updatePriceRow(itemKey, price._id, "base_price", e.target.value)} + className="h-8 text-xs text-right flex-1" placeholder="기준가" /> +
+ +
+ updatePriceRow(itemKey, price._id, "discount_value", e.target.value)} + className="h-8 text-xs text-right w-[60px]" placeholder="0" /> +
+ +
+
+ {/* 계산된 단가 표시 */} +
+ 계산 단가: + {price.calculated_price ? Number(price.calculated_price).toLocaleString() : "-"} +
+
+ ))} +
+
+
+
+ ); + })} +
+ +
+ + {/* 납품처 등록 모달 */} + + + + 납품처 등록 + {selectedCustomer?.customer_name}의 납품처를 등록합니다. + +
+
+ + setDeliveryForm((p) => ({ ...p, destination_code: e.target.value }))} + placeholder="납품처코드" className="h-9" /> +
+
+ + setDeliveryForm((p) => ({ ...p, destination_name: e.target.value }))} + placeholder="납품처명" className="h-9" /> +
+
+ + setDeliveryForm((p) => ({ ...p, address: e.target.value }))} + placeholder="주소" className="h-9" /> +
+
+ + setDeliveryForm((p) => ({ ...p, manager_name: e.target.value }))} + placeholder="담당자" className="h-9" /> +
+
+ + { + const formatted = formatField("phone", e.target.value); + setDeliveryForm((p) => ({ ...p, phone: formatted })); + const err = validateField("phone", formatted); + setFormErrors((p) => { const n = { ...p }; if (err) n.delivery_phone = err; else delete n.delivery_phone; return n; }); + }} + placeholder="010-0000-0000" className={cn("h-9", formErrors.delivery_phone && "border-destructive")} /> + {formErrors.delivery_phone &&

{formErrors.delivery_phone}

} +
+
+ + setDeliveryForm((p) => ({ ...p, memo: e.target.value }))} + placeholder="메모" className="h-9" /> +
+
+ + + + +
+
+ + {/* 엑셀 업로드 (멀티테이블) */} + {excelChainConfig && ( + { + setExcelUploadOpen(open); + if (!open) setExcelChainConfig(null); + }} + config={excelChainConfig} + onSuccess={() => { + fetchCustomers(); + // 우측 새로고침 + const cid = selectedCustomerId; + setSelectedCustomerId(null); + setTimeout(() => setSelectedCustomerId(cid), 50); + }} + /> + )} + + {ConfirmDialogComponent} +
+ ); +} diff --git a/frontend/app/(main)/sales/order/page.tsx b/frontend/app/(main)/sales/order/page.tsx index 9a9800f6..42f92462 100644 --- a/frontend/app/(main)/sales/order/page.tsx +++ b/frontend/app/(main)/sales/order/page.tsx @@ -25,6 +25,7 @@ import { toast } from "sonner"; import { FormDatePicker } from "@/components/screen/filters/FormDatePicker"; import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; import { DataGrid, DataGridColumn } from "@/components/common/DataGrid"; +import { FullscreenDialog } from "@/components/common/FullscreenDialog"; import { ShippingPlanBatchModal } from "@/components/common/ShippingPlanBatchModal"; const DETAIL_TABLE = "sales_order_detail"; @@ -72,7 +73,7 @@ export default function SalesOrderPage() { // 모달 const [isModalOpen, setIsModalOpen] = useState(false); - const [isModalFullscreen, setIsModalFullscreen] = useState(false); + // isModalFullscreen 제거됨 — FullscreenDialog 사용 const [isEditMode, setIsEditMode] = useState(false); const [saving, setSaving] = useState(false); const [masterForm, setMasterForm] = useState>({}); @@ -144,6 +145,15 @@ export default function SalesOrderPage() { const custs = custRes.data?.data?.data || custRes.data?.data?.rows || []; optMap["partner_id"] = custs.map((c: any) => ({ code: c.customer_code, label: `${c.customer_name} (${c.customer_code})` })); } catch { /* skip */ } + // item_info 카테고리도 로드 (unit, material 등 코드→라벨 변환용) + for (const col of ["unit", "material", "division", "type"]) { + try { + const res = await apiClient.get(`/table-categories/item_info/${col}/values`); + if (res.data?.success && res.data.data?.length > 0) { + optMap[`item_${col}`] = flatten(res.data.data); + } + } catch { /* skip */ } + } setCategoryOptions(optMap); }; loadCategories(); @@ -183,14 +193,21 @@ export default function SalesOrderPage() { } catch { /* skip */ } } - // 조인 적용 + // 조인 적용 + 카테고리 코드→라벨 변환 + const resolveLabel = (key: string, code: string) => { + if (!code) return ""; + const opts = categoryOptions[key]; + if (!opts) return code; + return opts.find((o) => o.code === code)?.label || code; + }; const data = rows.map((row: any) => { const item = itemMap[row.part_code]; + const rawUnit = row.unit || item?.unit || ""; return { ...row, part_name: row.part_name || item?.item_name || "", spec: row.spec || item?.size || "", - unit: row.unit || item?.unit || "", + unit: resolveLabel("item_unit", rawUnit) || rawUnit, }; }); @@ -213,11 +230,34 @@ export default function SalesOrderPage() { }; // 등록 모달 열기 + // 납품처 목록 (거래처 선택 시 조회) + const [deliveryOptions, setDeliveryOptions] = useState<{ code: string; label: string }[]>([]); + + const loadDeliveryOptions = async (customerCode: string) => { + if (!customerCode) { setDeliveryOptions([]); return; } + try { + const res = await apiClient.post(`/table-management/tables/delivery_destination/data`, { + page: 1, size: 100, + dataFilter: { enabled: true, filters: [{ columnName: "customer_code", operator: "equals", value: customerCode }] }, + autoFilter: true, + }); + const rows = res.data?.data?.data || res.data?.data?.rows || []; + setDeliveryOptions(rows.map((r: any) => ({ + code: r.destination_code || r.id, + label: `${r.destination_name}${r.address ? ` (${r.address})` : ""}`, + }))); + } catch { setDeliveryOptions([]); } + }; + const openRegisterModal = () => { - setMasterForm({ input_mode: "", sell_mode: "", price_mode: "" }); + // 기본값: 각 카테고리의 첫 번째 옵션 + const defaultSellMode = categoryOptions["sell_mode"]?.[0]?.code || ""; + const defaultInputMode = categoryOptions["input_mode"]?.[0]?.code || ""; + const defaultPriceMode = categoryOptions["price_mode"]?.[0]?.code || ""; + setMasterForm({ input_mode: defaultInputMode, sell_mode: defaultSellMode, price_mode: defaultPriceMode }); setDetailRows([]); + setDeliveryOptions([]); setIsEditMode(false); - setIsModalFullscreen(false); setIsModalOpen(true); }; @@ -428,8 +468,8 @@ export default function SalesOrderPage() { part_code: itemCode, part_name: item.item_name, spec: item.size || "", - material: item.material || "", - unit: item.unit || "", + material: getCategoryLabel("item_material", item.material) || item.material || "", + unit: getCategoryLabel("item_unit", item.unit) || item.unit || "", qty: "", unit_price: unitPrice, amount: "", @@ -537,26 +577,20 @@ export default function SalesOrderPage() {
{/* 수주 등록/수정 모달 */} - - - -
-
- {isEditMode ? "수주 수정" : "수주 등록"} - {isEditMode ? "수주 정보를 수정합니다." : "새로운 수주를 등록합니다."} -
- -
-
+ + + + + } + >
{/* 기본 레이어 (항상 표시) */} @@ -604,7 +638,7 @@ export default function SalesOrderPage() {
- { setMasterForm((p) => ({ ...p, partner_id: v, delivery_partner_id: "" })); loadDeliveryOptions(v); }}> {(categoryOptions["partner_id"] || []).map((o) => {o.label})} @@ -618,8 +652,25 @@ export default function SalesOrderPage() {
- setMasterForm((p) => ({ ...p, delivery_partner_id: e.target.value }))} - placeholder="납품처" className="h-9" /> + {deliveryOptions.length > 0 ? ( + + ) : ( + setMasterForm((p) => ({ ...p, delivery_partner_id: e.target.value }))} + placeholder={masterForm.partner_id ? "등록된 납품처 없음" : "거래처를 먼저 선택하세요"} className="h-9" disabled={!masterForm.partner_id} /> + )}
@@ -737,13 +788,6 @@ export default function SalesOrderPage() {
- - - - - {/* 품목 선택 모달 (등록 모달 내부에 중첩) */} e.preventDefault()}> @@ -815,8 +859,7 @@ export default function SalesOrderPage() { - -
+ {/* 출하계획 동시 등록 모달 */} { - e.stopPropagation(); - if (col.editable) startEdit(rowIdx, col.key, row[col.key]); + if (col.editable) { + e.stopPropagation(); + startEdit(rowIdx, col.key, row[col.key]); + } }} > {renderCell(row, col, rowIdx)} diff --git a/frontend/components/common/FullscreenDialog.tsx b/frontend/components/common/FullscreenDialog.tsx new file mode 100644 index 00000000..23189fbc --- /dev/null +++ b/frontend/components/common/FullscreenDialog.tsx @@ -0,0 +1,88 @@ +"use client"; + +/** + * FullscreenDialog — 전체화면 토글이 포함된 공통 Dialog + * + * 사용법: + * + * {children} + * + * + * footer prop으로 하단 버튼 영역 커스텀 가능 + */ + +import React, { useState } from "react"; +import { + Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Maximize2, Minimize2 } from "lucide-react"; +import { cn } from "@/lib/utils"; + +interface FullscreenDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + title: React.ReactNode; + description?: React.ReactNode; + children: React.ReactNode; + footer?: React.ReactNode; + /** 기본 모달 최대 너비 (기본: "max-w-5xl") */ + defaultMaxWidth?: string; + /** 기본 모달 너비 (기본: "w-[95vw]") */ + defaultWidth?: string; + className?: string; +} + +export function FullscreenDialog({ + open, onOpenChange, title, description, children, footer, + defaultMaxWidth = "max-w-5xl", + defaultWidth = "w-[95vw]", + className, +}: FullscreenDialogProps) { + const [isFullscreen, setIsFullscreen] = useState(false); + + const handleOpenChange = (v: boolean) => { + if (!v) setIsFullscreen(false); + onOpenChange(v); + }; + + return ( + + + +
+
+ {typeof title === "string" ? {title} : title} + {description && ( + typeof description === "string" + ? {description} + : description + )} +
+ +
+
+ +
+ {children} +
+ + {footer && ( + + {footer} + + )} +
+
+ ); +} diff --git a/frontend/components/common/ShippingPlanBatchModal.tsx b/frontend/components/common/ShippingPlanBatchModal.tsx index 942174c7..3b23465f 100644 --- a/frontend/components/common/ShippingPlanBatchModal.tsx +++ b/frontend/components/common/ShippingPlanBatchModal.tsx @@ -8,14 +8,12 @@ import React, { useState, useEffect, useCallback } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; -import { - Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, -} from "@/components/ui/dialog"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { Plus, X, Loader2, Maximize2, Minimize2, Package, Truck, Clock } from "lucide-react"; +import { Plus, X, Loader2, Package, Truck, Clock } from "lucide-react"; import { cn } from "@/lib/utils"; import { toast } from "sonner"; import { FormDatePicker } from "@/components/screen/filters/FormDatePicker"; +import { FullscreenDialog } from "@/components/common/FullscreenDialog"; import { getShippingPlanAggregate, batchSaveShippingPlans, @@ -123,7 +121,6 @@ export function ShippingPlanBatchModal({ }: ShippingPlanBatchModalProps) { const [loading, setLoading] = useState(false); const [saving, setSaving] = useState(false); - const [isFullscreen, setIsFullscreen] = useState(false); const [aggregate, setAggregate] = useState({}); const [newPlans, setNewPlans] = useState>({}); @@ -216,25 +213,24 @@ export function ShippingPlanBatchModal({ const partCodes = Object.keys(aggregate); return ( - - - -
-
- - 출하계획 동시 등록 - - 출하계획 설정: {totalNewPlans}개 -
- +
-
+
+ } + >
{loading ? ( @@ -381,18 +377,6 @@ export function ShippingPlanBatchModal({ })}
- -
- 💡 수주 등 시 출하계획도 함께 저장됩니다 -
- - -
-
-
- - + ); } diff --git a/frontend/components/common/TimelineScheduler.tsx b/frontend/components/common/TimelineScheduler.tsx new file mode 100644 index 00000000..a4b8e8b8 --- /dev/null +++ b/frontend/components/common/TimelineScheduler.tsx @@ -0,0 +1,825 @@ +"use client"; + +/** + * TimelineScheduler — 하드코딩 페이지용 공통 타임라인 스케줄러 컴포넌트 + * + * 기능: + * - 리소스(설비/품목) 기준 Y축, 날짜 기준 X축 + * - 줌 레벨 전환 (일/주/월) + * - 날짜 네비게이션 (이전/다음/오늘) + * - 이벤트 바 드래그 이동 + * - 이벤트 바 리사이즈 (좌/우 핸들) + * - 오늘 날짜 빨간 세로선 + * - 진행률 바 시각화 + * - 마일스톤 (다이아몬드) 표시 + * - 상태별 색상 + 범례 + * - 충돌 감지 (같은 리소스에서 겹침 시 빨간 테두리) + */ + +import React, { useState, useRef, useCallback, useMemo, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { + ChevronLeft, + ChevronRight, + CalendarDays, + Loader2, + Diamond, +} from "lucide-react"; +import { cn } from "@/lib/utils"; + +// ─── 타입 정의 ─── + +export interface TimelineResource { + id: string; + label: string; + subLabel?: string; +} + +export interface TimelineEvent { + id: string | number; + resourceId: string; + startDate: string; // YYYY-MM-DD + endDate: string; // YYYY-MM-DD + label?: string; + status?: string; + progress?: number; // 0~100 + isMilestone?: boolean; + data?: any; +} + +export type ZoomLevel = "day" | "week" | "month"; + +export interface StatusColor { + key: string; + label: string; + bgClass: string; // tailwind gradient class e.g. "from-blue-500 to-blue-600" +} + +export interface TimelineSchedulerProps { + resources: TimelineResource[]; + events: TimelineEvent[]; + /** 타임라인 시작 기준일 (기본: 오늘) */ + startDate?: Date; + /** 줌 레벨 (기본: week) */ + zoomLevel?: ZoomLevel; + onZoomChange?: (zoom: ZoomLevel) => void; + /** 이벤트 바 클릭 */ + onEventClick?: (event: TimelineEvent) => void; + /** 드래그 이동 완료 */ + onEventMove?: (eventId: string | number, newStartDate: string, newEndDate: string) => void; + /** 리사이즈 완료 */ + onEventResize?: (eventId: string | number, newStartDate: string, newEndDate: string) => void; + /** 상태별 색상 배열 */ + statusColors?: StatusColor[]; + /** 진행률 바 표시 여부 */ + showProgress?: boolean; + /** 마일스톤 표시 여부 */ + showMilestones?: boolean; + /** 오늘 세로선 표시 */ + showTodayLine?: boolean; + /** 범례 표시 */ + showLegend?: boolean; + /** 충돌 감지 */ + conflictDetection?: boolean; + /** 로딩 상태 */ + loading?: boolean; + /** 데이터 없을 때 메시지 */ + emptyMessage?: string; + /** 데이터 없을 때 아이콘 */ + emptyIcon?: React.ReactNode; + /** 리소스 열 너비 (px) */ + resourceWidth?: number; + /** 행 높이 (px) */ + rowHeight?: number; +} + +// ─── 기본값 ─── + +const DEFAULT_STATUS_COLORS: StatusColor[] = [ + { key: "planned", label: "계획", bgClass: "from-blue-500 to-blue-600" }, + { key: "work-order", label: "지시", bgClass: "from-amber-500 to-amber-600" }, + { key: "in-progress", label: "진행", bgClass: "from-emerald-500 to-emerald-600" }, + { key: "completed", label: "완료", bgClass: "from-gray-400 to-gray-500" }, +]; + +const ZOOM_CONFIG: Record = { + day: { cellWidth: 60, spanDays: 28, navStep: 7 }, + week: { cellWidth: 36, spanDays: 56, navStep: 14 }, + month: { cellWidth: 16, spanDays: 90, navStep: 30 }, +}; + +// ─── 유틸리티 함수 ─── + +/** YYYY-MM-DD 문자열로 변환 */ +function toDateStr(d: Date): string { + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, "0"); + const day = String(d.getDate()).padStart(2, "0"); + return `${y}-${m}-${day}`; +} + +/** 날짜 문자열을 Date로 (시간 0시) */ +function parseDate(s: string): Date { + const [y, m, d] = s.split("T")[0].split("-").map(Number); + return new Date(y, m - 1, d); +} + +/** 두 날짜 사이의 일 수 차이 */ +function diffDays(a: Date, b: Date): number { + return Math.round((b.getTime() - a.getTime()) / (1000 * 60 * 60 * 24)); +} + +/** 날짜에 일 수 더하기 */ +function addDays(d: Date, n: number): Date { + const r = new Date(d); + r.setDate(r.getDate() + n); + return r; +} + +function isWeekend(d: Date): boolean { + return d.getDay() === 0 || d.getDay() === 6; +} + +function isSameDay(a: Date, b: Date): boolean { + return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate(); +} + +const DAY_NAMES = ["일", "월", "화", "수", "목", "금", "토"]; +const MONTH_NAMES = ["1월", "2월", "3월", "4월", "5월", "6월", "7월", "8월", "9월", "10월", "11월", "12월"]; + +// ─── 충돌 감지 ─── + +function detectConflicts(events: TimelineEvent[]): Set { + const conflictIds = new Set(); + const byResource = new Map(); + + for (const ev of events) { + if (ev.isMilestone) continue; + if (!byResource.has(ev.resourceId)) byResource.set(ev.resourceId, []); + byResource.get(ev.resourceId)!.push(ev); + } + + for (const [, resEvents] of byResource) { + for (let i = 0; i < resEvents.length; i++) { + for (let j = i + 1; j < resEvents.length; j++) { + const a = resEvents[i]; + const b = resEvents[j]; + const aStart = parseDate(a.startDate).getTime(); + const aEnd = parseDate(a.endDate).getTime(); + const bStart = parseDate(b.startDate).getTime(); + const bEnd = parseDate(b.endDate).getTime(); + if (aStart <= bEnd && bStart <= aEnd) { + conflictIds.add(a.id); + conflictIds.add(b.id); + } + } + } + } + + return conflictIds; +} + +// ─── 메인 컴포넌트 ─── + +export default function TimelineScheduler({ + resources, + events, + startDate: propStartDate, + zoomLevel: propZoom, + onZoomChange, + onEventClick, + onEventMove, + onEventResize, + statusColors = DEFAULT_STATUS_COLORS, + showProgress = true, + showMilestones = true, + showTodayLine = true, + showLegend = true, + conflictDetection = true, + loading = false, + emptyMessage = "데이터가 없습니다", + emptyIcon, + resourceWidth = 160, + rowHeight = 48, +}: TimelineSchedulerProps) { + // ── 상태 ── + const [zoom, setZoom] = useState(propZoom || "week"); + const [baseDate, setBaseDate] = useState(() => { + const d = propStartDate || new Date(); + d.setHours(0, 0, 0, 0); + return d; + }); + + // 드래그/리사이즈 상태 + const [dragState, setDragState] = useState<{ + eventId: string | number; + mode: "move" | "resize-left" | "resize-right"; + origStartDate: string; + origEndDate: string; + startX: number; + currentOffsetDays: number; + } | null>(null); + + const gridRef = useRef(null); + const scrollRef = useRef(null); + + // 줌 레벨 동기화 + useEffect(() => { + if (propZoom && propZoom !== zoom) setZoom(propZoom); + }, [propZoom]); + + const config = ZOOM_CONFIG[zoom]; + const today = useMemo(() => { + const d = new Date(); + d.setHours(0, 0, 0, 0); + return d; + }, []); + + // 날짜 배열 생성 + const dates = useMemo(() => { + const arr: Date[] = []; + for (let i = 0; i < config.spanDays; i++) { + arr.push(addDays(baseDate, i)); + } + return arr; + }, [baseDate, config.spanDays]); + + const totalWidth = config.cellWidth * config.spanDays; + + // 충돌 ID 집합 + const conflictIds = useMemo(() => { + return conflictDetection ? detectConflicts(events) : new Set(); + }, [events, conflictDetection]); + + // 리소스별 이벤트 그룹 + const eventsByResource = useMemo(() => { + const map = new Map(); + for (const r of resources) map.set(r.id, []); + for (const ev of events) { + if (!map.has(ev.resourceId)) map.set(ev.resourceId, []); + map.get(ev.resourceId)!.push(ev); + } + return map; + }, [resources, events]); + + // 같은 리소스 내 겹치는 이벤트들의 행(lane) 계산 + const eventLanes = useMemo(() => { + const laneMap = new Map(); + for (const [, resEvents] of eventsByResource) { + // 시작일 기준 정렬 + const sorted = [...resEvents].sort( + (a, b) => parseDate(a.startDate).getTime() - parseDate(b.startDate).getTime() + ); + const lanes: { endTime: number }[] = []; + for (const ev of sorted) { + if (ev.isMilestone) { + laneMap.set(ev.id, 0); + continue; + } + const evStart = parseDate(ev.startDate).getTime(); + const evEnd = parseDate(ev.endDate).getTime(); + let placed = false; + for (let l = 0; l < lanes.length; l++) { + if (evStart > lanes[l].endTime) { + lanes[l].endTime = evEnd; + laneMap.set(ev.id, l); + placed = true; + break; + } + } + if (!placed) { + laneMap.set(ev.id, lanes.length); + lanes.push({ endTime: evEnd }); + } + } + } + return laneMap; + }, [eventsByResource]); + + // 리소스별 최대 lane 수 -> 행 높이 결정 + const resourceLaneCounts = useMemo(() => { + const map = new Map(); + for (const [resId, resEvents] of eventsByResource) { + let maxLane = 0; + for (const ev of resEvents) { + const lane = eventLanes.get(ev.id) || 0; + maxLane = Math.max(maxLane, lane); + } + map.set(resId, resEvents.length > 0 ? maxLane + 1 : 1); + } + return map; + }, [eventsByResource, eventLanes]); + + // ── 줌/네비게이션 핸들러 ── + + const handleZoom = useCallback( + (z: ZoomLevel) => { + setZoom(z); + onZoomChange?.(z); + }, + [onZoomChange] + ); + + const handleNavPrev = useCallback(() => { + setBaseDate((prev) => addDays(prev, -config.navStep)); + }, [config.navStep]); + + const handleNavNext = useCallback(() => { + setBaseDate((prev) => addDays(prev, config.navStep)); + }, [config.navStep]); + + const handleNavToday = useCallback(() => { + const d = new Date(); + d.setHours(0, 0, 0, 0); + setBaseDate(d); + }, []); + + // ── 이벤트 바 위치 계산 ── + + const getBarStyle = useCallback( + (startDateStr: string, endDateStr: string) => { + const evStart = parseDate(startDateStr); + const evEnd = parseDate(endDateStr); + const firstDate = dates[0]; + const lastDate = dates[dates.length - 1]; + + // 완전히 범위 밖이면 표시하지 않음 + if (evEnd < firstDate || evStart > lastDate) return null; + + const startIdx = Math.max(0, diffDays(firstDate, evStart)); + const endIdx = Math.min(config.spanDays - 1, diffDays(firstDate, evEnd)); + const left = startIdx * config.cellWidth; + const width = (endIdx - startIdx + 1) * config.cellWidth; + + return { left, width }; + }, + [dates, config.cellWidth, config.spanDays] + ); + + // ── 드래그/리사이즈 핸들러 ── + + const handleMouseDown = useCallback( + ( + e: React.MouseEvent, + eventId: string | number, + mode: "move" | "resize-left" | "resize-right", + startDate: string, + endDate: string + ) => { + e.preventDefault(); + e.stopPropagation(); + setDragState({ + eventId, + mode, + origStartDate: startDate, + origEndDate: endDate, + startX: e.clientX, + currentOffsetDays: 0, + }); + }, + [] + ); + + // mousemove / mouseup (document-level) + useEffect(() => { + if (!dragState) return; + + const handleMouseMove = (e: MouseEvent) => { + const dx = e.clientX - dragState.startX; + const dayOffset = Math.round(dx / config.cellWidth); + setDragState((prev) => (prev ? { ...prev, currentOffsetDays: dayOffset } : null)); + }; + + const handleMouseUp = (e: MouseEvent) => { + if (!dragState) return; + const dx = e.clientX - dragState.startX; + const dayOffset = Math.round(dx / config.cellWidth); + + if (dayOffset !== 0) { + const origStart = parseDate(dragState.origStartDate); + const origEnd = parseDate(dragState.origEndDate); + + if (dragState.mode === "move") { + const newStart = toDateStr(addDays(origStart, dayOffset)); + const newEnd = toDateStr(addDays(origEnd, dayOffset)); + onEventMove?.(dragState.eventId, newStart, newEnd); + } else if (dragState.mode === "resize-left") { + const newStart = toDateStr(addDays(origStart, dayOffset)); + const newEnd = dragState.origEndDate.split("T")[0]; + // 시작이 종료를 넘지 않도록 + if (parseDate(newStart) <= parseDate(newEnd)) { + onEventResize?.(dragState.eventId, newStart, newEnd); + } + } else if (dragState.mode === "resize-right") { + const newStart = dragState.origStartDate.split("T")[0]; + const newEnd = toDateStr(addDays(origEnd, dayOffset)); + if (parseDate(newStart) <= parseDate(newEnd)) { + onEventResize?.(dragState.eventId, newStart, newEnd); + } + } + } + + setDragState(null); + }; + + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + return () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }; + }, [dragState, config.cellWidth, onEventMove, onEventResize]); + + // 드래그 중인 이벤트의 현재 표시 위치 계산 + const getDraggedBarStyle = useCallback( + (event: TimelineEvent) => { + if (!dragState || dragState.eventId !== event.id) return null; + + const origStart = parseDate(dragState.origStartDate); + const origEnd = parseDate(dragState.origEndDate); + const offset = dragState.currentOffsetDays; + + let newStart: Date, newEnd: Date; + if (dragState.mode === "move") { + newStart = addDays(origStart, offset); + newEnd = addDays(origEnd, offset); + } else if (dragState.mode === "resize-left") { + newStart = addDays(origStart, offset); + newEnd = origEnd; + if (newStart > newEnd) newStart = newEnd; + } else { + newStart = origStart; + newEnd = addDays(origEnd, offset); + if (newEnd < newStart) newEnd = newStart; + } + + return getBarStyle(toDateStr(newStart), toDateStr(newEnd)); + }, + [dragState, getBarStyle] + ); + + // ── 오늘 라인 위치 ── + + const todayLineLeft = useMemo(() => { + if (!showTodayLine || dates.length === 0) return null; + const firstDate = dates[0]; + const lastDate = dates[dates.length - 1]; + if (today < firstDate || today > lastDate) return null; + const idx = diffDays(firstDate, today); + return idx * config.cellWidth + config.cellWidth / 2; + }, [dates, today, config.cellWidth, showTodayLine]); + + // ── 상태 색상 매핑 ── + + const getStatusColor = useCallback( + (status?: string) => { + if (!status) return statusColors[0]?.bgClass || "from-blue-500 to-blue-600"; + const found = statusColors.find((c) => c.key === status); + return found?.bgClass || statusColors[0]?.bgClass || "from-blue-500 to-blue-600"; + }, + [statusColors] + ); + + // ── 날짜 헤더 그룹 ── + + const dateGroups = useMemo(() => { + if (zoom === "day") { + return null; // day 뷰에서는 상위 그룹 없이 바로 날짜 표시 + } + + // week / month 뷰: 월 단위로 그룹 + const groups: { label: string; span: number; startIdx: number }[] = []; + let currentMonth = -1; + let currentYear = -1; + + for (let i = 0; i < dates.length; i++) { + const d = dates[i]; + if (d.getMonth() !== currentMonth || d.getFullYear() !== currentYear) { + groups.push({ + label: `${d.getFullYear()}년 ${MONTH_NAMES[d.getMonth()]}`, + span: 1, + startIdx: i, + }); + currentMonth = d.getMonth(); + currentYear = d.getFullYear(); + } else { + groups[groups.length - 1].span++; + } + } + return groups; + }, [dates, zoom]); + + // ── 렌더링 ── + + if (loading) { + return ( +
+ +
+ ); + } + + if (resources.length === 0 || events.length === 0) { + return ( +
+ {emptyIcon} +

{emptyMessage}

+
+ ); + } + + const barHeight = 24; + const barGap = 2; + + return ( +
+ {/* 컨트롤 바 */} +
+
+ + + + + {toDateStr(dates[0])} ~ {toDateStr(dates[dates.length - 1])} + +
+
+ {(["day", "week", "month"] as ZoomLevel[]).map((z) => ( + + ))} +
+
+ + {/* 범례 */} + {showLegend && ( +
+ 상태: + {statusColors.map((sc) => ( +
+
+ {sc.label} +
+ ))} + {showMilestones && ( +
+ + 마일스톤 +
+ )} + {conflictDetection && ( +
+
+ 충돌 +
+ )} +
+ )} + + {/* 타임라인 본체 */} +
+
+
+ {/* 좌측: 리소스 라벨 */} +
+ {/* 헤더 공간 */} +
+ 리소스 +
+ {/* 리소스 행 */} + {resources.map((res) => { + const laneCount = resourceLaneCounts.get(res.id) || 1; + const h = Math.max(rowHeight, laneCount * (barHeight + barGap) + 12); + return ( +
+ + {res.label} + + {res.subLabel && ( + + {res.subLabel} + + )} +
+ ); + })} +
+ + {/* 우측: 타임라인 그리드 */} +
+ {/* 날짜 헤더 */} +
+ {/* 상위 그룹 (월) */} + {dateGroups && ( +
+ {dateGroups.map((g, idx) => ( +
+ {g.label} +
+ ))} +
+ )} + + {/* 하위 날짜 셀 */} +
+ {dates.map((date, idx) => { + const isT = isSameDay(date, today); + const isW = isWeekend(date); + return ( +
+ {zoom === "month" ? ( +
{date.getDate()}
+ ) : ( + <> +
{date.getDate()}
+
{DAY_NAMES[date.getDay()]}
+ + )} +
+ ); + })} +
+
+ + {/* 리소스별 이벤트 행 */} + {resources.map((res) => { + const resEvents = eventsByResource.get(res.id) || []; + const laneCount = resourceLaneCounts.get(res.id) || 1; + const h = Math.max(rowHeight, laneCount * (barHeight + barGap) + 12); + + return ( +
+ {/* 배경 그리드 */} +
+ {dates.map((date, idx) => ( +
+ ))} +
+ + {/* 오늘 라인 */} + {todayLineLeft != null && ( +
+ )} + + {/* 이벤트 바 */} + {resEvents.map((ev) => { + if (ev.isMilestone && showMilestones) { + // 마일스톤: 다이아몬드 아이콘 + const pos = getBarStyle(ev.startDate, ev.startDate); + if (!pos) return null; + return ( +
onEventClick?.(ev)} + > + +
+ ); + } + + // 일반 이벤트 바 + const isDragging = dragState?.eventId === ev.id; + const barStyle = isDragging + ? getDraggedBarStyle(ev) + : getBarStyle(ev.startDate, ev.endDate); + if (!barStyle) return null; + + const lane = eventLanes.get(ev.id) || 0; + const colorClass = getStatusColor(ev.status); + const isConflict = conflictIds.has(ev.id); + const progress = ev.progress ?? 0; + + return ( +
0 ? ` | ${progress}%` : ""}`} + onClick={(e) => { + if (!isDragging) { + e.stopPropagation(); + onEventClick?.(ev); + } + }} + onMouseDown={(e) => handleMouseDown(e, ev.id, "move", ev.startDate, ev.endDate)} + > + {/* 진행률 바 */} + {showProgress && progress > 0 && ( +
+ )} + + {/* 라벨 */} + + {ev.label || ""} + {showProgress && progress > 0 && ( + ({progress}%) + )} + + + {/* 좌측 리사이즈 핸들 */} +
{ + e.stopPropagation(); + handleMouseDown(e, ev.id, "resize-left", ev.startDate, ev.endDate); + }} + /> + + {/* 우측 리사이즈 핸들 */} +
{ + e.stopPropagation(); + handleMouseDown(e, ev.id, "resize-right", ev.startDate, ev.endDate); + }} + /> +
+ ); + })} +
+ ); + })} +
+
+
+
+
+ ); +} diff --git a/frontend/lib/utils/validation.ts b/frontend/lib/utils/validation.ts new file mode 100644 index 00000000..f9320d4d --- /dev/null +++ b/frontend/lib/utils/validation.ts @@ -0,0 +1,92 @@ +/** + * 공통 폼 유효성 검증 + 자동 포맷팅 유틸리티 + */ + +// --- 자동 포맷팅 --- + +// 전화번호: 숫자만 추출 → 자동 하이픈 +// 010-1234-5678 / 02-1234-5678 / 031-123-4567 +export function formatPhone(value: string): string { + const nums = value.replace(/\D/g, "").slice(0, 11); + if (nums.startsWith("02")) { + if (nums.length <= 2) return nums; + if (nums.length <= 5) return `${nums.slice(0, 2)}-${nums.slice(2)}`; + if (nums.length <= 9) return `${nums.slice(0, 2)}-${nums.slice(2, 5)}-${nums.slice(5)}`; + return `${nums.slice(0, 2)}-${nums.slice(2, 6)}-${nums.slice(6)}`; + } + if (nums.length <= 3) return nums; + if (nums.length <= 7) return `${nums.slice(0, 3)}-${nums.slice(3)}`; + return `${nums.slice(0, 3)}-${nums.slice(3, 7)}-${nums.slice(7)}`; +} + +// 사업자번호: 000-00-00000 +export function formatBusinessNumber(value: string): string { + const nums = value.replace(/\D/g, "").slice(0, 10); + if (nums.length <= 3) return nums; + if (nums.length <= 5) return `${nums.slice(0, 3)}-${nums.slice(3)}`; + return `${nums.slice(0, 3)}-${nums.slice(3, 5)}-${nums.slice(5)}`; +} + +// 필드명으로 자동 포맷팅 +export function formatField(fieldName: string, value: string): string { + switch (fieldName) { + case "contact_phone": + case "phone": + case "cell_phone": + return formatPhone(value); + case "business_number": + return formatBusinessNumber(value); + default: + return value; + } +} + +// --- 유효성 검증 --- + +export function validatePhone(value: string): string | null { + if (!value) return null; + const nums = value.replace(/\D/g, ""); + if (nums.length < 9) return "전화번호를 끝까지 입력해주세요"; + return null; +} + +export function validateEmail(value: string): string | null { + if (!value) return null; + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) return "올바른 이메일 형식이 아닙니다"; + return null; +} + +export function validateBusinessNumber(value: string): string | null { + if (!value) return null; + const nums = value.replace(/\D/g, ""); + if (nums.length < 10) return "사업자번호를 끝까지 입력해주세요"; + return null; +} + +export function validateField(fieldName: string, value: string): string | null { + if (!value) return null; + switch (fieldName) { + case "contact_phone": + case "phone": + case "cell_phone": + return validatePhone(value); + case "email": + return validateEmail(value); + case "business_number": + return validateBusinessNumber(value); + default: + return null; + } +} + +export function validateForm( + data: Record, + fields: string[] +): Record { + const errors: Record = {}; + for (const field of fields) { + const error = validateField(field, data[field] || ""); + if (error) errors[field] = error; + } + return errors; +}