Update item info and sales order pages with new components and functionality
- Updated the item information page to include category code to label conversion for better data representation. - Enhanced the sales order page by integrating a fullscreen dialog for improved user experience during order registration and editing. - Added dynamic loading of delivery options based on selected customers to streamline the order process. - Introduced a new FullscreenDialog component for consistent fullscreen behavior across modals. - Implemented validation utilities for form fields to ensure data integrity during user input.
This commit is contained in:
parent
ec7308bf43
commit
1c562fa854
|
|
@ -1,3 +1,3 @@
|
|||
{
|
||||
"lastSentAt": "2026-03-24T02:34:43.277Z"
|
||||
"lastSentAt": "2026-03-24T02:36:44.477Z"
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -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<Record<string, any>>({});
|
||||
|
|
@ -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() {
|
|||
</div>
|
||||
|
||||
{/* 수주 등록/수정 모달 */}
|
||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<DialogContent className={cn(
|
||||
"overflow-auto transition-all duration-200",
|
||||
isModalFullscreen
|
||||
? "max-w-[100vw] max-h-[100vh] w-[100vw] h-[100vh] rounded-none"
|
||||
: "max-w-5xl max-h-[90vh]"
|
||||
)}>
|
||||
<DialogHeader>
|
||||
<div className="flex items-center justify-between pr-8">
|
||||
<div>
|
||||
<DialogTitle>{isEditMode ? "수주 수정" : "수주 등록"}</DialogTitle>
|
||||
<DialogDescription>{isEditMode ? "수주 정보를 수정합니다." : "새로운 수주를 등록합니다."}</DialogDescription>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0"
|
||||
onClick={() => setIsModalFullscreen((prev) => !prev)}
|
||||
title={isModalFullscreen ? "기본 크기" : "전체 화면"}>
|
||||
{isModalFullscreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
<FullscreenDialog
|
||||
open={isModalOpen}
|
||||
onOpenChange={setIsModalOpen}
|
||||
title={isEditMode ? "수주 수정" : "수주 등록"}
|
||||
description={isEditMode ? "수주 정보를 수정합니다." : "새로운 수주를 등록합니다."}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setIsModalOpen(false)}>취소</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />} 저장
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
{/* 기본 레이어 (항상 표시) */}
|
||||
|
|
@ -604,7 +638,7 @@ export default function SalesOrderPage() {
|
|||
<div className="grid grid-cols-4 gap-4 border-t pt-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">거래처</Label>
|
||||
<Select value={masterForm.partner_id || ""} onValueChange={(v) => setMasterForm((p) => ({ ...p, partner_id: v }))}>
|
||||
<Select value={masterForm.partner_id || ""} onValueChange={(v) => { setMasterForm((p) => ({ ...p, partner_id: v, delivery_partner_id: "" })); loadDeliveryOptions(v); }}>
|
||||
<SelectTrigger className="h-9"><SelectValue placeholder="거래처 선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{(categoryOptions["partner_id"] || []).map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
||||
|
|
@ -618,8 +652,25 @@ export default function SalesOrderPage() {
|
|||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">납품처</Label>
|
||||
<Input value={masterForm.delivery_partner_id || ""} onChange={(e) => setMasterForm((p) => ({ ...p, delivery_partner_id: e.target.value }))}
|
||||
placeholder="납품처" className="h-9" />
|
||||
{deliveryOptions.length > 0 ? (
|
||||
<Select value={masterForm.delivery_partner_id || ""} onValueChange={(v) => {
|
||||
setMasterForm((p) => ({ ...p, delivery_partner_id: v }));
|
||||
// 선택한 납품처의 주소를 자동 입력
|
||||
const found = deliveryOptions.find((o) => o.code === v);
|
||||
if (found) {
|
||||
const addr = found.label.match(/\((.+)\)$/)?.[1] || "";
|
||||
if (addr) setMasterForm((p) => ({ ...p, delivery_partner_id: v, delivery_address: addr }));
|
||||
}
|
||||
}}>
|
||||
<SelectTrigger className="h-9"><SelectValue placeholder="납품처 선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{deliveryOptions.map((o) => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input value={masterForm.delivery_partner_id || ""} onChange={(e) => setMasterForm((p) => ({ ...p, delivery_partner_id: e.target.value }))}
|
||||
placeholder={masterForm.partner_id ? "등록된 납품처 없음" : "거래처를 먼저 선택하세요"} className="h-9" disabled={!masterForm.partner_id} />
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">납품장소</Label>
|
||||
|
|
@ -737,13 +788,6 @@ export default function SalesOrderPage() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsModalOpen(false)}>취소</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <Save className="w-4 h-4 mr-1.5" />} 저장
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
{/* 품목 선택 모달 (등록 모달 내부에 중첩) */}
|
||||
<Dialog open={itemSelectOpen} onOpenChange={setItemSelectOpen}>
|
||||
<DialogContent className="max-w-3xl max-h-[70vh]" onInteractOutside={(e) => e.preventDefault()}>
|
||||
|
|
@ -815,8 +859,7 @@ export default function SalesOrderPage() {
|
|||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</FullscreenDialog>
|
||||
|
||||
{/* 출하계획 동시 등록 모달 */}
|
||||
<ShippingPlanBatchModal
|
||||
|
|
|
|||
|
|
@ -511,8 +511,10 @@ export function DataGrid({
|
|||
key={col.key}
|
||||
className={cn(col.width, col.minWidth, "py-1.5", col.editable && "cursor-text")}
|
||||
onDoubleClick={(e) => {
|
||||
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)}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,88 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* FullscreenDialog — 전체화면 토글이 포함된 공통 Dialog
|
||||
*
|
||||
* 사용법:
|
||||
* <FullscreenDialog open={open} onOpenChange={setOpen} title="제목" description="설명">
|
||||
* {children}
|
||||
* </FullscreenDialog>
|
||||
*
|
||||
* 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 (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className={cn(
|
||||
"overflow-auto flex flex-col transition-all duration-200",
|
||||
isFullscreen
|
||||
? "max-w-screen max-h-screen w-screen h-screen rounded-none"
|
||||
: `${defaultMaxWidth} ${defaultWidth} max-h-[90vh]`,
|
||||
className,
|
||||
)}>
|
||||
<DialogHeader className="shrink-0">
|
||||
<div className="flex items-center justify-between pr-8">
|
||||
<div>
|
||||
{typeof title === "string" ? <DialogTitle>{title}</DialogTitle> : title}
|
||||
{description && (
|
||||
typeof description === "string"
|
||||
? <DialogDescription>{description}</DialogDescription>
|
||||
: description
|
||||
)}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0"
|
||||
onClick={() => setIsFullscreen((p) => !p)}
|
||||
title={isFullscreen ? "기본 크기" : "전체 화면"}>
|
||||
{isFullscreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-auto">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{footer && (
|
||||
<DialogFooter className="shrink-0">
|
||||
{footer}
|
||||
</DialogFooter>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<AggregateResponse>({});
|
||||
const [newPlans, setNewPlans] = useState<Record<string, NewPlanRow[]>>({});
|
||||
|
||||
|
|
@ -216,25 +213,24 @@ export function ShippingPlanBatchModal({
|
|||
const partCodes = Object.keys(aggregate);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className={cn(
|
||||
"overflow-hidden flex flex-col transition-all duration-200",
|
||||
isFullscreen ? "max-w-screen max-h-screen w-screen h-screen rounded-none" : "max-w-[1200px] w-[95vw] max-h-[90vh]"
|
||||
)}>
|
||||
<DialogHeader className="shrink-0">
|
||||
<div className="flex items-center justify-between pr-8">
|
||||
<div>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Truck className="h-5 w-5" /> 출하계획 동시 등록
|
||||
</DialogTitle>
|
||||
<DialogDescription>출하계획 설정: <strong>{totalNewPlans}개</strong></DialogDescription>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0"
|
||||
onClick={() => setIsFullscreen((p) => !p)}>
|
||||
{isFullscreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
|
||||
<FullscreenDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={<><Truck className="h-5 w-5 inline mr-2" />출하계획 동시 등록</>}
|
||||
description={<>출하계획 설정: <strong>{totalNewPlans}개</strong></>}
|
||||
defaultMaxWidth="max-w-[1200px]"
|
||||
footer={
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<span className="text-sm text-muted-foreground">💡 수주 등 시 출하계획도 함께 저장됩니다</span>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>닫기</Button>
|
||||
<Button onClick={handleSave} disabled={saving || totalNewPlans === 0}>
|
||||
{saving && <Loader2 className="w-4 h-4 mr-1.5 animate-spin" />} 적용
|
||||
</Button>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
|
||||
<div className="flex-1 overflow-auto space-y-6 py-2 px-1">
|
||||
{loading ? (
|
||||
|
|
@ -381,18 +377,6 @@ export function ShippingPlanBatchModal({
|
|||
})}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="shrink-0 border-t pt-3">
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<span className="text-sm text-muted-foreground">💡 수주 등 시 출하계획도 함께 저장됩니다</span>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>닫기</Button>
|
||||
<Button onClick={handleSave} disabled={saving || totalNewPlans === 0}>
|
||||
{saving && <Loader2 className="w-4 h-4 mr-1.5 animate-spin" />} 적용
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</FullscreenDialog>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<ZoomLevel, { cellWidth: number; spanDays: number; navStep: number }> = {
|
||||
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<string | number> {
|
||||
const conflictIds = new Set<string | number>();
|
||||
const byResource = new Map<string, TimelineEvent[]>();
|
||||
|
||||
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<ZoomLevel>(propZoom || "week");
|
||||
const [baseDate, setBaseDate] = useState<Date>(() => {
|
||||
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<HTMLDivElement>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(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<string | number>();
|
||||
}, [events, conflictDetection]);
|
||||
|
||||
// 리소스별 이벤트 그룹
|
||||
const eventsByResource = useMemo(() => {
|
||||
const map = new Map<string, TimelineEvent[]>();
|
||||
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<string | number, number>();
|
||||
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<string, number>();
|
||||
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 (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (resources.length === 0 || events.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
|
||||
{emptyIcon}
|
||||
<p className="text-base font-medium mb-2 mt-3">{emptyMessage}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const barHeight = 24;
|
||||
const barGap = 2;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* 컨트롤 바 */}
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="outline" onClick={handleNavPrev}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={handleNavToday}>
|
||||
<CalendarDays className="mr-1 h-4 w-4" />
|
||||
오늘
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={handleNavNext}>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground ml-2">
|
||||
{toDateStr(dates[0])} ~ {toDateStr(dates[dates.length - 1])}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{(["day", "week", "month"] as ZoomLevel[]).map((z) => (
|
||||
<Button
|
||||
key={z}
|
||||
size="sm"
|
||||
variant={zoom === z ? "default" : "outline"}
|
||||
className="h-7 text-xs px-3"
|
||||
onClick={() => handleZoom(z)}
|
||||
>
|
||||
{z === "day" ? "일" : z === "week" ? "주" : "월"}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 범례 */}
|
||||
{showLegend && (
|
||||
<div className="flex items-center gap-4 flex-wrap text-xs">
|
||||
<span className="font-semibold text-muted-foreground">상태:</span>
|
||||
{statusColors.map((sc) => (
|
||||
<div key={sc.key} className="flex items-center gap-1.5">
|
||||
<div className={cn("h-3.5 w-5 rounded bg-gradient-to-br", sc.bgClass)} />
|
||||
<span>{sc.label}</span>
|
||||
</div>
|
||||
))}
|
||||
{showMilestones && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Diamond className="h-3.5 w-3.5 text-purple-500 fill-purple-500" />
|
||||
<span>마일스톤</span>
|
||||
</div>
|
||||
)}
|
||||
{conflictDetection && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="h-3.5 w-5 rounded border-2 border-red-500 bg-red-500/20" />
|
||||
<span>충돌</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 타임라인 본체 */}
|
||||
<div className="rounded-lg border bg-background overflow-hidden">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="overflow-x-auto overflow-y-auto"
|
||||
style={{ maxHeight: "calc(100vh - 350px)" }}
|
||||
>
|
||||
<div className="flex" style={{ minWidth: resourceWidth + totalWidth }}>
|
||||
{/* 좌측: 리소스 라벨 */}
|
||||
<div
|
||||
className="shrink-0 border-r bg-muted/30 z-20 sticky left-0"
|
||||
style={{ width: resourceWidth }}
|
||||
>
|
||||
{/* 헤더 공간 */}
|
||||
<div
|
||||
className="border-b bg-muted/50 flex items-center justify-center text-xs font-semibold text-muted-foreground"
|
||||
style={{ height: dateGroups ? 60 : 36 }}
|
||||
>
|
||||
리소스
|
||||
</div>
|
||||
{/* 리소스 행 */}
|
||||
{resources.map((res) => {
|
||||
const laneCount = resourceLaneCounts.get(res.id) || 1;
|
||||
const h = Math.max(rowHeight, laneCount * (barHeight + barGap) + 12);
|
||||
return (
|
||||
<div
|
||||
key={res.id}
|
||||
className="border-b px-3 flex flex-col justify-center"
|
||||
style={{ height: h }}
|
||||
>
|
||||
<span className="text-xs font-semibold text-foreground truncate">
|
||||
{res.label}
|
||||
</span>
|
||||
{res.subLabel && (
|
||||
<span className="text-[10px] text-muted-foreground truncate">
|
||||
{res.subLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 우측: 타임라인 그리드 */}
|
||||
<div className="flex-1 relative" ref={gridRef} style={{ width: totalWidth }}>
|
||||
{/* 날짜 헤더 */}
|
||||
<div className="sticky top-0 z-10 bg-background border-b">
|
||||
{/* 상위 그룹 (월) */}
|
||||
{dateGroups && (
|
||||
<div className="flex border-b">
|
||||
{dateGroups.map((g, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="text-center text-[11px] font-semibold text-muted-foreground border-r py-1"
|
||||
style={{ width: g.span * config.cellWidth }}
|
||||
>
|
||||
{g.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 하위 날짜 셀 */}
|
||||
<div className="flex">
|
||||
{dates.map((date, idx) => {
|
||||
const isT = isSameDay(date, today);
|
||||
const isW = isWeekend(date);
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className={cn(
|
||||
"text-center border-r select-none",
|
||||
isW && "text-red-400",
|
||||
isT && "bg-primary/10 font-bold text-primary"
|
||||
)}
|
||||
style={{
|
||||
width: config.cellWidth,
|
||||
minWidth: config.cellWidth,
|
||||
fontSize: zoom === "month" ? 9 : 11,
|
||||
padding: zoom === "month" ? "2px 0" : "3px 0",
|
||||
}}
|
||||
>
|
||||
{zoom === "month" ? (
|
||||
<div>{date.getDate()}</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="font-semibold">{date.getDate()}</div>
|
||||
<div>{DAY_NAMES[date.getDay()]}</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 리소스별 이벤트 행 */}
|
||||
{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 (
|
||||
<div key={res.id} className="relative border-b" style={{ height: h }}>
|
||||
{/* 배경 그리드 */}
|
||||
<div className="absolute inset-0 flex pointer-events-none">
|
||||
{dates.map((date, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={cn(
|
||||
"border-r border-border/20",
|
||||
isWeekend(date) && "bg-red-500/[0.03]"
|
||||
)}
|
||||
style={{ width: config.cellWidth, minWidth: config.cellWidth }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 오늘 라인 */}
|
||||
{todayLineLeft != null && (
|
||||
<div
|
||||
className="absolute top-0 bottom-0 w-[2px] bg-red-500 z-[5] pointer-events-none"
|
||||
style={{ left: todayLineLeft }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 이벤트 바 */}
|
||||
{resEvents.map((ev) => {
|
||||
if (ev.isMilestone && showMilestones) {
|
||||
// 마일스톤: 다이아몬드 아이콘
|
||||
const pos = getBarStyle(ev.startDate, ev.startDate);
|
||||
if (!pos) return null;
|
||||
return (
|
||||
<div
|
||||
key={ev.id}
|
||||
className="absolute z-10 flex items-center justify-center cursor-pointer"
|
||||
style={{
|
||||
left: pos.left + pos.width / 2 - 8,
|
||||
top: "50%",
|
||||
transform: "translateY(-50%)",
|
||||
}}
|
||||
title={ev.label || "마일스톤"}
|
||||
onClick={() => onEventClick?.(ev)}
|
||||
>
|
||||
<Diamond className="h-4 w-4 text-purple-500 fill-purple-500" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 일반 이벤트 바
|
||||
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 (
|
||||
<div
|
||||
key={ev.id}
|
||||
className={cn(
|
||||
"absolute rounded shadow-sm z-10 group select-none",
|
||||
`bg-gradient-to-br ${colorClass}`,
|
||||
isDragging && "opacity-80 shadow-lg z-20",
|
||||
isConflict && "ring-2 ring-red-500 ring-offset-1",
|
||||
"cursor-grab active:cursor-grabbing"
|
||||
)}
|
||||
style={{
|
||||
left: barStyle.left,
|
||||
width: Math.max(barStyle.width, config.cellWidth * 0.5),
|
||||
height: barHeight,
|
||||
top: 6 + lane * (barHeight + barGap),
|
||||
}}
|
||||
title={`${ev.label || ""} | ${ev.startDate.split("T")[0]} ~ ${ev.endDate.split("T")[0]}${progress > 0 ? ` | ${progress}%` : ""}`}
|
||||
onClick={(e) => {
|
||||
if (!isDragging) {
|
||||
e.stopPropagation();
|
||||
onEventClick?.(ev);
|
||||
}
|
||||
}}
|
||||
onMouseDown={(e) => handleMouseDown(e, ev.id, "move", ev.startDate, ev.endDate)}
|
||||
>
|
||||
{/* 진행률 바 */}
|
||||
{showProgress && progress > 0 && (
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 rounded-l bg-white/25"
|
||||
style={{ width: `${Math.min(progress, 100)}%` }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 라벨 */}
|
||||
<span className="absolute inset-0 flex items-center justify-center text-[10px] font-bold text-white drop-shadow-sm truncate px-1">
|
||||
{ev.label || ""}
|
||||
{showProgress && progress > 0 && (
|
||||
<span className="ml-1 opacity-75">({progress}%)</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{/* 좌측 리사이즈 핸들 */}
|
||||
<div
|
||||
className="absolute left-0 top-0 bottom-0 w-[5px] cursor-col-resize opacity-0 group-hover:opacity-100 bg-white/30 rounded-l"
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation();
|
||||
handleMouseDown(e, ev.id, "resize-left", ev.startDate, ev.endDate);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 우측 리사이즈 핸들 */}
|
||||
<div
|
||||
className="absolute right-0 top-0 bottom-0 w-[5px] cursor-col-resize opacity-0 group-hover:opacity-100 bg-white/30 rounded-r"
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation();
|
||||
handleMouseDown(e, ev.id, "resize-right", ev.startDate, ev.endDate);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<string, any>,
|
||||
fields: string[]
|
||||
): Record<string, string> {
|
||||
const errors: Record<string, string> = {};
|
||||
for (const field of fields) {
|
||||
const error = validateField(field, data[field] || "");
|
||||
if (error) errors[field] = error;
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
Loading…
Reference in New Issue