Implement update functions for packaging and loading unit items
- Added `updatePkgUnitItem` and `updateLoadingUnitPkg` functions to the packaging controller for updating package unit items and loading unit packages, respectively. - Implemented company code filtering to ensure data integrity based on user permissions. - Enhanced error handling for missing fields and data not found scenarios. - Updated the packaging routes to include new PUT endpoints for these update operations. (TASK: ERP-node-XXX)
This commit is contained in:
@@ -527,6 +527,95 @@ export async function createLoadingUnitPkg(
|
||||
}
|
||||
}
|
||||
|
||||
export async function updatePkgUnitItem(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { id } = req.params;
|
||||
const { pkg_qty } = req.body;
|
||||
const pool = getPool();
|
||||
|
||||
if (pkg_qty === undefined) {
|
||||
res.status(400).json({ success: false, message: "수정할 필드가 없습니다." });
|
||||
return;
|
||||
}
|
||||
|
||||
const whereClause = companyCode === "*"
|
||||
? `WHERE id=$2`
|
||||
: `WHERE id=$2 AND company_code=$3`;
|
||||
const params: any[] = [pkg_qty, id];
|
||||
if (companyCode !== "*") params.push(companyCode);
|
||||
|
||||
const sql = `UPDATE pkg_unit_item SET pkg_qty=$1, updated_date=NOW() ${whereClause} RETURNING *`;
|
||||
const result = await pool.query(sql, params);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
res.status(404).json({ success: false, message: "데이터를 찾을 수 없습니다." });
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info("매칭품목 수정", { companyCode, id });
|
||||
res.json({ success: true, data: result.rows[0] });
|
||||
} catch (error: any) {
|
||||
logger.error("매칭품목 수정 실패", { error: error.message });
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateLoadingUnitPkg(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { id } = req.params;
|
||||
const { max_load_qty, load_method } = req.body;
|
||||
const pool = getPool();
|
||||
|
||||
const sets: string[] = [];
|
||||
const params: any[] = [];
|
||||
let i = 1;
|
||||
|
||||
if (max_load_qty !== undefined) {
|
||||
sets.push(`max_load_qty=$${i++}`);
|
||||
params.push(max_load_qty);
|
||||
}
|
||||
if (load_method !== undefined) {
|
||||
sets.push(`load_method=$${i++}`);
|
||||
params.push(load_method);
|
||||
}
|
||||
|
||||
if (sets.length === 0) {
|
||||
res.status(400).json({ success: false, message: "수정할 필드가 없습니다." });
|
||||
return;
|
||||
}
|
||||
|
||||
sets.push(`updated_date=NOW()`);
|
||||
|
||||
const whereClause = companyCode === "*"
|
||||
? `WHERE id=$${i}`
|
||||
: `WHERE id=$${i} AND company_code=$${i + 1}`;
|
||||
params.push(id);
|
||||
if (companyCode !== "*") params.push(companyCode);
|
||||
|
||||
const sql = `UPDATE loading_unit_pkg SET ${sets.join(', ')} ${whereClause} RETURNING *`;
|
||||
const result = await pool.query(sql, params);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
res.status(404).json({ success: false, message: "데이터를 찾을 수 없습니다." });
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info("적재구성 수정", { companyCode, id });
|
||||
res.json({ success: true, data: result.rows[0] });
|
||||
} catch (error: any) {
|
||||
logger.error("적재구성 수정 실패", { error: error.message });
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteLoadingUnitPkg(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
|
||||
@@ -3,10 +3,10 @@ import { authenticateToken } from "../middleware/authMiddleware";
|
||||
import {
|
||||
getPkgUnits, createPkgUnit, updatePkgUnit, deletePkgUnit,
|
||||
getPkgUnitsByItem,
|
||||
getPkgUnitItems, createPkgUnitItem, deletePkgUnitItem,
|
||||
getPkgUnitItems, createPkgUnitItem, updatePkgUnitItem, deletePkgUnitItem,
|
||||
getLoadingUnits, createLoadingUnit, updateLoadingUnit, deleteLoadingUnit,
|
||||
getLoadingUnitsByPkg,
|
||||
getLoadingUnitPkgs, createLoadingUnitPkg, deleteLoadingUnitPkg,
|
||||
getLoadingUnitPkgs, createLoadingUnitPkg, updateLoadingUnitPkg, deleteLoadingUnitPkg,
|
||||
getItemsByDivision, getGeneralItems,
|
||||
getTransactionLoadings, getTransactionPackagings,
|
||||
} from "../controllers/packagingController";
|
||||
@@ -27,6 +27,7 @@ router.get("/pkg-units-by-item/:itemNumber", getPkgUnitsByItem);
|
||||
// 포장단위 매칭품목
|
||||
router.get("/pkg-unit-items/:pkgCode", getPkgUnitItems);
|
||||
router.post("/pkg-unit-items", createPkgUnitItem);
|
||||
router.put("/pkg-unit-items/:id", updatePkgUnitItem);
|
||||
router.delete("/pkg-unit-items/:id", deletePkgUnitItem);
|
||||
|
||||
// 적재함
|
||||
@@ -41,6 +42,7 @@ router.get("/loading-units-by-pkg/:pkgCode", getLoadingUnitsByPkg);
|
||||
// 적재함 포장구성
|
||||
router.get("/loading-unit-pkgs/:loadingCode", getLoadingUnitPkgs);
|
||||
router.post("/loading-unit-pkgs", createLoadingUnitPkg);
|
||||
router.put("/loading-unit-pkgs/:id", updateLoadingUnitPkg);
|
||||
router.delete("/loading-unit-pkgs/:id", deleteLoadingUnitPkg);
|
||||
|
||||
// 품목정보 연동 (division별)
|
||||
|
||||
@@ -32,6 +32,8 @@ import { useTableSettings } from "@/hooks/useTableSettings";
|
||||
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
|
||||
import { SmartSelect } from "@/components/common/SmartSelect";
|
||||
import { MultiCategorySelect } from "@/components/common/MultiCategorySelect";
|
||||
|
||||
const GRID_COLUMNS = [
|
||||
{ key: "pkg_code", label: "품목코드" },
|
||||
@@ -42,16 +44,7 @@ const GRID_COLUMNS = [
|
||||
{ key: "status", label: "상태" },
|
||||
];
|
||||
|
||||
// --- 코드 → 라벨 매핑 ---
|
||||
const PKG_TYPE_LABEL: Record<string, string> = {
|
||||
BOX: "박스", PACK: "팩", CANBOARD: "캔보드", AIRCAP: "에어캡",
|
||||
ZIPCOS: "집코스", CYLINDER: "원통형", POLYCARTON: "포리/카톤",
|
||||
};
|
||||
const LOADING_TYPE_LABEL: Record<string, string> = {
|
||||
PALLET: "파렛트", WOOD_PALLET: "목재파렛트", PLASTIC_PALLET: "플라스틱파렛트",
|
||||
ALU_PALLET: "알루미늄파렛트", CONTAINER: "컨테이너", STEEL_BOX: "철재함",
|
||||
CAGE: "케이지", ETC: "기타",
|
||||
};
|
||||
// --- 상태 코드 → 라벨 매핑 (status는 카테고리 미연동, 그대로 유지) ---
|
||||
const STATUS_LABEL: Record<string, string> = { ACTIVE: "사용", INACTIVE: "미사용", UNREGISTERED: "미등록" };
|
||||
|
||||
const getStatusColor = (s: string) => s === "ACTIVE" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground";
|
||||
@@ -119,25 +112,56 @@ export default function PackagingPage() {
|
||||
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// 카테고리 옵션 (inventory_unit / material) — 코드 → 라벨 변환
|
||||
// 적재 가능 포장단위 행 수정 모달 state (입력 중에는 자유 — 저장 시 검증)
|
||||
const [editLoadingPkgOpen, setEditLoadingPkgOpen] = useState(false);
|
||||
const [editLoadingPkgForm, setEditLoadingPkgForm] = useState<{
|
||||
id: string;
|
||||
pkg_code: string;
|
||||
pkg_name: string;
|
||||
pkg_type: string;
|
||||
max_load_qty: string;
|
||||
load_method: string;
|
||||
} | null>(null);
|
||||
|
||||
// 매칭 품목 행 수정 모달 state (포장재 탭 우측)
|
||||
const [editPkgItemOpen, setEditPkgItemOpen] = useState(false);
|
||||
const [editPkgItemForm, setEditPkgItemForm] = useState<{
|
||||
id: string;
|
||||
item_number: string;
|
||||
item_name: string;
|
||||
spec: string;
|
||||
pkg_qty: string;
|
||||
} | null>(null);
|
||||
|
||||
// 카테고리 옵션 (inventory_unit / material / pkg_type / loading_type) — 코드 → 라벨 변환
|
||||
const [categoryOptions, setCategoryOptions] = useState<
|
||||
Record<string, { code: string; label: string }[]>
|
||||
>({});
|
||||
Record<string, { code: string; label: string; isActive?: boolean }[]>
|
||||
>({
|
||||
pkg_type: [],
|
||||
loading_type: [],
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
const flatten = (vals: any[]): { code: string; label: string }[] => {
|
||||
const out: { code: string; label: string }[] = [];
|
||||
const flatten = (vals: any[]): { code: string; label: string; isActive?: boolean }[] => {
|
||||
const out: { code: string; label: string; isActive?: boolean }[] = [];
|
||||
for (const v of vals) {
|
||||
const active = v.isActive ?? v.is_active;
|
||||
out.push({
|
||||
code: v.valueCode || v.value_code || v.code,
|
||||
label: v.valueLabel || v.value_label || v.label,
|
||||
isActive: active !== false,
|
||||
});
|
||||
if (v.children?.length) out.push(...flatten(v.children));
|
||||
}
|
||||
return out;
|
||||
};
|
||||
const optMap: Record<string, { code: string; label: string }[]> = {};
|
||||
const optMap: Record<string, { code: string; label: string }[]> = {
|
||||
pkg_type: [],
|
||||
loading_type: [],
|
||||
};
|
||||
|
||||
// item_info 카테고리 (inventory_unit, material)
|
||||
for (const col of ["inventory_unit", "material"]) {
|
||||
try {
|
||||
const res = await apiClient.get(
|
||||
@@ -148,6 +172,24 @@ export default function PackagingPage() {
|
||||
/* skip */
|
||||
}
|
||||
}
|
||||
|
||||
// pkg_type(포장유형) / loading_type(적재유형) 카테고리
|
||||
try {
|
||||
const [pkgRes, loadRes] = await Promise.all([
|
||||
apiClient.get('/table-categories/pkg_unit/pkg_type/values'),
|
||||
apiClient.get('/table-categories/loading_unit/loading_type/values'),
|
||||
]);
|
||||
const toOptions = (res: any): { code: string; label: string }[] => {
|
||||
const rows: any[] = res.data?.data || res.data?.values || [];
|
||||
return flatten(rows).filter((r) => r.code && r.label);
|
||||
};
|
||||
optMap['pkg_type'] = toOptions(pkgRes);
|
||||
optMap['loading_type'] = toOptions(loadRes);
|
||||
} catch (err) {
|
||||
console.error('카테고리 로드 실패 (pkg_type/loading_type):', err);
|
||||
toast.error('옵션 로드 실패');
|
||||
}
|
||||
|
||||
setCategoryOptions(optMap);
|
||||
};
|
||||
load();
|
||||
@@ -158,6 +200,23 @@ export default function PackagingPage() {
|
||||
return categoryOptions[col]?.find((o) => o.code === code)?.label || code;
|
||||
};
|
||||
|
||||
// pkg_type / loading_type 라벨 변환 헬퍼 (is_active 무관 — 기존 데이터 fallback 포함)
|
||||
const getCategoryLabel = (col: 'pkg_type' | 'loading_type', code?: string | null) => {
|
||||
if (!code) return '';
|
||||
return categoryOptions[col]?.find((o) => o.code === code)?.label || code;
|
||||
};
|
||||
|
||||
// 다중 선택(콤마 join) 라벨 변환 헬퍼 — 단일 코드값도 정상 동작
|
||||
const formatMultiCategoryLabel = (col: 'pkg_type' | 'loading_type', value?: string | null) => {
|
||||
if (!value) return '';
|
||||
return String(value)
|
||||
.split(',')
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean)
|
||||
.map((code) => categoryOptions[col]?.find((o) => o.code === code)?.label || code)
|
||||
.join(', ');
|
||||
};
|
||||
|
||||
// --- 데이터 로드 (item_info 기반 + pkg_unit/loading_unit LEFT JOIN 방식) ---
|
||||
const fetchPkgUnits = useCallback(async () => {
|
||||
setPkgLoading(true);
|
||||
@@ -464,6 +523,55 @@ export default function PackagingPage() {
|
||||
} catch { toast.error("추가 실패"); } finally { setSaving(false); }
|
||||
};
|
||||
|
||||
// 적재 가능 포장단위 행 수정 저장
|
||||
const saveEditLoadingPkg = async () => {
|
||||
if (!editLoadingPkgForm) return;
|
||||
const qty = Number(editLoadingPkgForm.max_load_qty);
|
||||
if (!Number.isFinite(qty) || qty < 1) {
|
||||
toast.error("최대수량은 1 이상 입력하세요");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await apiClient.put(`/packaging/loading-unit-pkgs/${editLoadingPkgForm.id}`, {
|
||||
max_load_qty: qty,
|
||||
load_method: editLoadingPkgForm.load_method,
|
||||
});
|
||||
if (res.data?.success) {
|
||||
toast.success("수정 완료");
|
||||
setEditLoadingPkgOpen(false);
|
||||
setEditLoadingPkgForm(null);
|
||||
if (selectedLoading) selectLoading(selectedLoading);
|
||||
} else {
|
||||
toast.error(res.data?.message || "수정 실패");
|
||||
}
|
||||
} catch { toast.error("수정 실패"); } finally { setSaving(false); }
|
||||
};
|
||||
|
||||
// 매칭 품목 행 수정 저장
|
||||
const saveEditPkgItem = async () => {
|
||||
if (!editPkgItemForm) return;
|
||||
const qty = Number(editPkgItemForm.pkg_qty);
|
||||
if (!Number.isFinite(qty) || qty < 1) {
|
||||
toast.error("포장수량은 1 이상 입력하세요");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await apiClient.put(`/packaging/pkg-unit-items/${editPkgItemForm.id}`, {
|
||||
pkg_qty: qty,
|
||||
});
|
||||
if (res.data?.success) {
|
||||
toast.success("수정 완료");
|
||||
setEditPkgItemOpen(false);
|
||||
setEditPkgItemForm(null);
|
||||
if (selectedPkg) selectPkg(selectedPkg);
|
||||
} else {
|
||||
toast.error(res.data?.message || "수정 실패");
|
||||
}
|
||||
} catch { toast.error("수정 실패"); } finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const handleDeleteLoadPkg = async (lp: LoadingUnitPkg) => {
|
||||
const ok = await confirm("적재 구성을 삭제하시겠습니까?", { variant: "destructive", confirmText: "삭제" });
|
||||
if (!ok) return;
|
||||
@@ -574,7 +682,7 @@ export default function PackagingPage() {
|
||||
<EDataTable
|
||||
columns={ts.visibleColumns.map((col): EDataTableColumn<PkgUnit> => {
|
||||
const renderMap: Record<string, Partial<EDataTableColumn<PkgUnit>>> = {
|
||||
pkg_type: { width: "w-[80px]", render: (v: any) => PKG_TYPE_LABEL[v] || v || "-" },
|
||||
pkg_type: { width: "w-[80px]", render: (v: any) => formatMultiCategoryLabel('pkg_type', v) || '-' },
|
||||
size: { width: "w-[100px]", render: (_v: any, row: any) => fmtSize(row.width_mm, row.length_mm, row.height_mm) },
|
||||
max_weight: { width: "w-[80px]", align: "right", render: (v: any) => Number(v || 0) > 0 ? `${v}kg` : "-" },
|
||||
max_load_kg: { width: "w-[80px]", align: "right", render: (v: any) => Number(v || 0) > 0 ? `${v}kg` : "-" },
|
||||
@@ -658,7 +766,21 @@ export default function PackagingPage() {
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{pkgItems.map((item) => (
|
||||
<TableRow key={item.id} className="text-xs">
|
||||
<TableRow
|
||||
key={item.id}
|
||||
className="text-xs cursor-pointer hover:bg-muted/40"
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest('button')) return;
|
||||
setEditPkgItemForm({
|
||||
id: item.id,
|
||||
item_number: item.item_number,
|
||||
item_name: item.item_name || "",
|
||||
spec: item.spec || "",
|
||||
pkg_qty: String(item.pkg_qty ?? ""),
|
||||
});
|
||||
setEditPkgItemOpen(true);
|
||||
}}
|
||||
>
|
||||
<TableCell className="p-2 font-medium">{item.item_number}</TableCell>
|
||||
<TableCell className="p-2">{item.item_name || "-"}</TableCell>
|
||||
<TableCell className="p-2">{item.spec || "-"}</TableCell>
|
||||
@@ -740,7 +862,7 @@ export default function PackagingPage() {
|
||||
>
|
||||
<TableCell className="p-2 font-medium">{l.loading_code}</TableCell>
|
||||
<TableCell className="p-2">{l.loading_name}</TableCell>
|
||||
<TableCell className="p-2">{LOADING_TYPE_LABEL[l.loading_type] || l.loading_type || "-"}</TableCell>
|
||||
<TableCell className="p-2">{formatMultiCategoryLabel('loading_type', l.loading_type) || '-'}</TableCell>
|
||||
<TableCell className="p-2 text-[10px] tabular-nums">{fmtSize(l.width_mm, l.length_mm, l.height_mm)}</TableCell>
|
||||
<TableCell className="p-2 text-right">{Number(l.max_load_kg || 0) > 0 ? `${l.max_load_kg}kg` : "-"}</TableCell>
|
||||
<TableCell className="p-2 text-center">
|
||||
@@ -804,10 +926,26 @@ export default function PackagingPage() {
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loadingPkgs.map((lp) => (
|
||||
<TableRow key={lp.id} className="text-xs">
|
||||
<TableRow
|
||||
key={lp.id}
|
||||
className="cursor-pointer text-xs hover:bg-muted/40"
|
||||
onClick={(e) => {
|
||||
// X(삭제) 버튼 클릭은 무시
|
||||
if ((e.target as HTMLElement).closest('button')) return;
|
||||
setEditLoadingPkgForm({
|
||||
id: lp.id,
|
||||
pkg_code: lp.pkg_code,
|
||||
pkg_name: lp.pkg_name || "",
|
||||
pkg_type: lp.pkg_type || "",
|
||||
max_load_qty: String(lp.max_load_qty ?? ""),
|
||||
load_method: lp.load_method || "",
|
||||
});
|
||||
setEditLoadingPkgOpen(true);
|
||||
}}
|
||||
>
|
||||
<TableCell className="p-2 font-medium">{lp.pkg_code}</TableCell>
|
||||
<TableCell className="p-2">{lp.pkg_name || "-"}</TableCell>
|
||||
<TableCell className="p-2">{PKG_TYPE_LABEL[lp.pkg_type || ""] || lp.pkg_type || "-"}</TableCell>
|
||||
<TableCell className="p-2">{formatMultiCategoryLabel('pkg_type', lp.pkg_type) || '-'}</TableCell>
|
||||
<TableCell className="p-2 text-right font-semibold">{Number(lp.max_load_qty).toLocaleString()}</TableCell>
|
||||
<TableCell className="p-2">{lp.load_method || "-"}</TableCell>
|
||||
<TableCell className="p-2 text-center">
|
||||
@@ -854,10 +992,12 @@ export default function PackagingPage() {
|
||||
<div><Label className="text-xs">포장명</Label><Input value={pkgForm.pkg_name || ""} readOnly className="h-9 bg-muted text-xs" /></div>
|
||||
<div>
|
||||
<Label className="text-xs">포장유형 <span className="text-destructive">*</span></Label>
|
||||
<Select value={pkgForm.pkg_type || ""} onValueChange={(v) => setPkgForm((p) => ({ ...p, pkg_type: v }))}>
|
||||
<SelectTrigger className="h-9 text-xs"><SelectValue placeholder="선택" /></SelectTrigger>
|
||||
<SelectContent>{Object.entries(PKG_TYPE_LABEL).map(([k, v]) => <SelectItem key={k} value={k}>{v}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
<MultiCategorySelect
|
||||
value={pkgForm.pkg_type || ""}
|
||||
onValueChange={(v: string) => setPkgForm((p) => ({ ...p, pkg_type: v }))}
|
||||
options={categoryOptions.pkg_type.filter((o) => o.isActive !== false)}
|
||||
placeholder="선택"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">상태</Label>
|
||||
@@ -910,10 +1050,12 @@ export default function PackagingPage() {
|
||||
<div><Label className="text-xs">적재함명</Label><Input value={loadForm.loading_name || ""} readOnly className="h-9 bg-muted text-xs" /></div>
|
||||
<div>
|
||||
<Label className="text-xs">적재유형 <span className="text-destructive">*</span></Label>
|
||||
<Select value={loadForm.loading_type || ""} onValueChange={(v) => setLoadForm((p) => ({ ...p, loading_type: v }))}>
|
||||
<SelectTrigger className="h-9 text-xs"><SelectValue placeholder="선택" /></SelectTrigger>
|
||||
<SelectContent>{Object.entries(LOADING_TYPE_LABEL).map(([k, v]) => <SelectItem key={k} value={k}>{v}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
<MultiCategorySelect
|
||||
value={loadForm.loading_type || ""}
|
||||
onValueChange={(v: string) => setLoadForm((p) => ({ ...p, loading_type: v }))}
|
||||
options={categoryOptions.loading_type.filter((o) => o.isActive !== false)}
|
||||
placeholder="선택"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">상태</Label>
|
||||
@@ -1073,7 +1215,7 @@ export default function PackagingPage() {
|
||||
<TableCell className="p-2 text-center">{pkgMatchSelected?.id === p.id ? "✓" : ""}</TableCell>
|
||||
<TableCell className="p-2 font-medium">{p.pkg_code}</TableCell>
|
||||
<TableCell className="p-2">{p.pkg_name}</TableCell>
|
||||
<TableCell className="p-2">{PKG_TYPE_LABEL[p.pkg_type] || p.pkg_type}</TableCell>
|
||||
<TableCell className="p-2">{formatMultiCategoryLabel('pkg_type', p.pkg_type) || p.pkg_type || '-'}</TableCell>
|
||||
<TableCell className="p-2 text-[10px]">{fmtSize(p.width_mm, p.length_mm, p.height_mm)}</TableCell>
|
||||
<TableCell className="p-2 text-right">{Number(p.max_load_kg || 0) > 0 ? `${p.max_load_kg}kg` : "-"}</TableCell>
|
||||
</TableRow>
|
||||
@@ -1111,6 +1253,109 @@ export default function PackagingPage() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 적재 가능 포장단위 수정 모달 */}
|
||||
<Dialog open={editLoadingPkgOpen} onOpenChange={(open) => { setEditLoadingPkgOpen(open); if (!open) setEditLoadingPkgForm(null); }}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>포장단위 수정</DialogTitle>
|
||||
<DialogDescription>최대수량과 적재방향을 수정할 수 있습니다.</DialogDescription>
|
||||
</DialogHeader>
|
||||
{editLoadingPkgForm && (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label className="text-xs">포장코드</Label>
|
||||
<Input value={editLoadingPkgForm.pkg_code} readOnly className="h-9 bg-muted text-xs" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">포장명</Label>
|
||||
<Input value={editLoadingPkgForm.pkg_name || "-"} readOnly className="h-9 bg-muted text-xs" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">유형</Label>
|
||||
<Input value={formatMultiCategoryLabel('pkg_type', editLoadingPkgForm.pkg_type) || "-"} readOnly className="h-9 bg-muted text-xs" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label className="text-xs">최대수량 <span className="text-destructive">*</span></Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={editLoadingPkgForm.max_load_qty}
|
||||
onChange={(e) =>
|
||||
setEditLoadingPkgForm((prev) => prev ? { ...prev, max_load_qty: e.target.value } : prev)
|
||||
}
|
||||
className="h-9 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">적재방향</Label>
|
||||
<Input
|
||||
value={editLoadingPkgForm.load_method}
|
||||
onChange={(e) =>
|
||||
setEditLoadingPkgForm((prev) => prev ? { ...prev, load_method: e.target.value } : prev)
|
||||
}
|
||||
placeholder="수직/수평/혼합"
|
||||
className="h-9 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => { setEditLoadingPkgOpen(false); setEditLoadingPkgForm(null); }}>취소</Button>
|
||||
<Button onClick={saveEditLoadingPkg} disabled={saving}>
|
||||
{saving ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : <Save className="mr-1 h-4 w-4" />} 저장
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 매칭 품목 수정 모달 (포장재 탭 우측) */}
|
||||
<Dialog open={editPkgItemOpen} onOpenChange={(open) => { setEditPkgItemOpen(open); if (!open) setEditPkgItemForm(null); }}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>매칭 품목 수정</DialogTitle>
|
||||
<DialogDescription>포장수량을 수정할 수 있습니다.</DialogDescription>
|
||||
</DialogHeader>
|
||||
{editPkgItemForm && (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label className="text-xs">품목코드</Label>
|
||||
<Input value={editPkgItemForm.item_number} readOnly className="h-9 bg-muted text-xs" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">품목명</Label>
|
||||
<Input value={editPkgItemForm.item_name || "-"} readOnly className="h-9 bg-muted text-xs" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">규격</Label>
|
||||
<Input value={editPkgItemForm.spec || "-"} readOnly className="h-9 bg-muted text-xs" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">포장수량 <span className="text-destructive">*</span></Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={editPkgItemForm.pkg_qty}
|
||||
onChange={(e) =>
|
||||
setEditPkgItemForm((prev) => prev ? { ...prev, pkg_qty: e.target.value } : prev)
|
||||
}
|
||||
className="h-9 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => { setEditPkgItemOpen(false); setEditPkgItemForm(null); }}>취소</Button>
|
||||
<Button onClick={saveEditPkgItem} disabled={saving}>
|
||||
{saving ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : <Save className="mr-1 h-4 w-4" />} 저장
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{ConfirmDialogComponent}
|
||||
|
||||
<TableSettingsModal
|
||||
|
||||
@@ -230,6 +230,8 @@ export default function SalesOrderPage() {
|
||||
|
||||
// 엑셀 업로드
|
||||
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
|
||||
// 엑셀 업로드용 채번 규칙 ID (모달 오픈 시 미리 로드)
|
||||
const [excelOrderNoRuleId, setExcelOrderNoRuleId] = useState<string | null | undefined>(undefined); // undefined=미로드, null=규칙없음
|
||||
|
||||
// 출하계획 모달
|
||||
const [shippingPlanOpen, setShippingPlanOpen] = useState(false);
|
||||
@@ -1261,7 +1263,20 @@ export default function SalesOrderPage() {
|
||||
<ClipboardList className="w-4 h-4" /> 결재 상신
|
||||
</Button>
|
||||
<div className="h-5 w-px bg-border mx-0.5" />
|
||||
<Button variant="outline" size="sm" onClick={() => setExcelUploadOpen(true)}>
|
||||
<Button variant="outline" size="sm" onClick={async () => {
|
||||
// 채번 규칙 미리 조회 (공란 행 자동 채번용)
|
||||
try {
|
||||
const ruleRes = await apiClient.get("/numbering-rules/by-column/sales_order_mng/order_no");
|
||||
if (ruleRes.data?.success && ruleRes.data?.data?.ruleId) {
|
||||
setExcelOrderNoRuleId(ruleRes.data.data.ruleId);
|
||||
} else {
|
||||
setExcelOrderNoRuleId(null); // 규칙 없음
|
||||
}
|
||||
} catch {
|
||||
setExcelOrderNoRuleId(null);
|
||||
}
|
||||
setExcelUploadOpen(true);
|
||||
}}>
|
||||
<FileSpreadsheet className="w-4 h-4" /> 엑셀 업로드
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleExcelDownload}>
|
||||
@@ -1591,10 +1606,10 @@ export default function SalesOrderPage() {
|
||||
</Label>
|
||||
<Input
|
||||
value={masterForm.order_no || ""}
|
||||
onChange={(e) => !orderNoRuleId && setMasterForm((p) => ({ ...p, order_no: e.target.value }))}
|
||||
readOnly={!!orderNoRuleId || isEditMode}
|
||||
onChange={(e) => setMasterForm((p) => ({ ...p, order_no: e.target.value }))}
|
||||
readOnly={isEditMode}
|
||||
placeholder={orderNoRuleId ? "자동 채번" : "수주번호"}
|
||||
className={cn("h-9", (orderNoRuleId || isEditMode) && "bg-muted cursor-not-allowed")}
|
||||
className={cn("h-9", isEditMode && "bg-muted cursor-not-allowed")}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
@@ -1849,7 +1864,6 @@ export default function SalesOrderPage() {
|
||||
<TableHead className="min-w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품번</TableHead>
|
||||
<TableHead className="min-w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품명</TableHead>
|
||||
<TableHead className="min-w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">규격</TableHead>
|
||||
<TableHead className="min-w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">재질</TableHead>
|
||||
<TableHead className="min-w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">포장재</TableHead>
|
||||
<TableHead className="min-w-[100px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">포장수량</TableHead>
|
||||
<TableHead className="min-w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">단위</TableHead>
|
||||
@@ -1871,7 +1885,6 @@ export default function SalesOrderPage() {
|
||||
<span className="text-[13px]" title={row.part_name}>{row.part_name}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-[13px] text-muted-foreground whitespace-nowrap">{row.spec}</TableCell>
|
||||
<TableCell className="text-[13px] text-muted-foreground whitespace-nowrap">{row.material}</TableCell>
|
||||
<TableCell>
|
||||
{(row.pkg_options && row.pkg_options.length > 0) ? (
|
||||
<Select value={row.pkg_code || ""} onValueChange={(v) => updateDetailRow(idx, "pkg_code", v)}>
|
||||
@@ -2051,14 +2064,13 @@ export default function SalesOrderPage() {
|
||||
<TableHead className="w-32 text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품목코드</TableHead>
|
||||
<TableHead className="min-w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품명</TableHead>
|
||||
<TableHead className="w-24 text-[11px] font-bold uppercase tracking-wide text-muted-foreground">규격</TableHead>
|
||||
<TableHead className="w-24 text-[11px] font-bold uppercase tracking-wide text-muted-foreground">재질</TableHead>
|
||||
<TableHead className="w-16 text-[11px] font-bold uppercase tracking-wide text-muted-foreground">단위</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{itemSearchLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="py-12 text-center">
|
||||
<TableCell colSpan={5} className="py-12 text-center">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-primary" />
|
||||
<span className="text-xs text-muted-foreground">품목을 불러오는 중...</span>
|
||||
@@ -2067,7 +2079,7 @@ export default function SalesOrderPage() {
|
||||
</TableRow>
|
||||
) : itemSearchResults.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="py-8 text-center text-muted-foreground">검색 결과가 없어요</TableCell>
|
||||
<TableCell colSpan={5} className="py-8 text-center text-muted-foreground">검색 결과가 없어요</TableCell>
|
||||
</TableRow>
|
||||
) : itemSearchResults.map((item) => (
|
||||
<TableRow
|
||||
@@ -2089,9 +2101,6 @@ export default function SalesOrderPage() {
|
||||
<span className="block truncate text-sm" title={item.item_name}>{item.item_name}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-[13px]">{item.size}</TableCell>
|
||||
<TableCell className="text-[13px]">
|
||||
{categoryOptions["item_material"]?.find((o) => o.code === item.material)?.label || item.material}
|
||||
</TableCell>
|
||||
<TableCell className="text-[13px]">
|
||||
{categoryOptions["item_inventory_unit"]?.find((o) => o.code === item.inventory_unit)?.label || item.inventory_unit}
|
||||
</TableCell>
|
||||
@@ -2175,6 +2184,147 @@ export default function SalesOrderPage() {
|
||||
tableName={DETAIL_TABLE}
|
||||
userId={user?.userId}
|
||||
onSuccess={() => fetchOrders()}
|
||||
// 품목 검증: part_code가 item_info에 미등록이면 전체 차단
|
||||
customValidator={async (mappedRows) => {
|
||||
// 엑셀에서 part_code 값 수집 (중복 제거)
|
||||
const partCodes = [...new Set(
|
||||
mappedRows
|
||||
.map((r) => String(r.part_code || "").trim())
|
||||
.filter(Boolean)
|
||||
)];
|
||||
if (partCodes.length === 0) {
|
||||
return {
|
||||
valid: false,
|
||||
errors: [{
|
||||
rowIndex: 0,
|
||||
column: "part_code",
|
||||
value: "",
|
||||
message: "품목 코드(part_code) 컬럼이 매핑되지 않았거나 모든 행이 공란입니다",
|
||||
}],
|
||||
};
|
||||
}
|
||||
try {
|
||||
const res = await apiClient.post(`/table-management/tables/item_info/data`, {
|
||||
page: 1,
|
||||
size: 0,
|
||||
dataFilter: {
|
||||
enabled: true,
|
||||
// item_info의 품목코드 컬럼은 item_number (part_code 아님)
|
||||
filters: [{ columnName: "item_number", operator: "in", value: partCodes }],
|
||||
},
|
||||
autoFilter: true,
|
||||
});
|
||||
const rows = res.data?.data?.data || res.data?.data?.rows || [];
|
||||
// 등록된 코드 집합: item_info.item_number 컬럼으로 수집
|
||||
const registeredCodes = new Set<string>(rows.map((r: any) => String(r.item_number || "").trim()).filter(Boolean));
|
||||
const errors: Array<{ rowIndex: number; column: string; value: string; message: string }> = [];
|
||||
mappedRows.forEach((row, idx) => {
|
||||
const code = String(row.part_code || "").trim();
|
||||
if (code && !registeredCodes.has(code)) {
|
||||
errors.push({
|
||||
rowIndex: idx,
|
||||
column: "part_code",
|
||||
value: code,
|
||||
message: `품목 코드 '${code}'가 품목정보(item_info)에 등록되어 있지 않습니다`,
|
||||
});
|
||||
}
|
||||
});
|
||||
return { valid: errors.length === 0, errors };
|
||||
} catch (err) {
|
||||
// 검증 API 실패 시 안전하게 차단 — "등록됨"을 단정할 수 없으므로 업로드 불허
|
||||
console.error("품목 검증 API 실패:", err);
|
||||
return {
|
||||
valid: false,
|
||||
errors: [{
|
||||
rowIndex: 0,
|
||||
column: "part_code",
|
||||
value: "",
|
||||
message: "품목 검증 중 오류가 발생했습니다. 잠시 후 다시 시도하거나 관리자에게 문의하세요.",
|
||||
}],
|
||||
};
|
||||
}
|
||||
}}
|
||||
// 수주번호 자동 채번: order_no 컬럼이 공란인 행마다 순차 채번 + 마스터 행 자동 생성
|
||||
autoFillKeyOnEmpty={
|
||||
excelOrderNoRuleId !== undefined
|
||||
? {
|
||||
columnName: "order_no",
|
||||
// 공란 행 전체를 1회 채번으로 묶어 1 마스터 + N 디테일로 등록
|
||||
groupEmptyRows: true,
|
||||
getOrAllocate: async (rowOrRows?: Record<string, any> | Record<string, any>[]) => {
|
||||
if (!excelOrderNoRuleId) {
|
||||
toast.error("수주번호 채번 규칙이 설정되어 있지 않습니다. 시스템 관리자에게 문의하세요.");
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
// 1. 채번 1회
|
||||
const allocRes = await allocateNumberingCode(excelOrderNoRuleId);
|
||||
if (!allocRes.success || !allocRes.data?.generatedCode) {
|
||||
return null;
|
||||
}
|
||||
const newOrderNo = allocRes.data.generatedCode;
|
||||
|
||||
// 2. 배열이면 첫 non-empty 값으로 마스터 필드 결정, 단일이면 그대로 사용
|
||||
const rows = Array.isArray(rowOrRows)
|
||||
? rowOrRows
|
||||
: rowOrRows
|
||||
? [rowOrRows]
|
||||
: [];
|
||||
const pickFirst = (key: string) => {
|
||||
for (const r of rows) {
|
||||
const v = r?.[key];
|
||||
if (v !== undefined && v !== null && String(v).trim() !== "") return v;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
// 3. 마스터 행 INSERT — id 제외(serial 자동 채번)
|
||||
// 신규 등록 폼 handleSave의 masterFields 패턴과 동일 (id 미포함)
|
||||
const masterPayload: Record<string, any> = {
|
||||
order_no: newOrderNo,
|
||||
order_date: pickFirst("order_date") || new Date().toISOString().split("T")[0],
|
||||
due_date: pickFirst("due_date"),
|
||||
partner_id: pickFirst("partner_id"),
|
||||
delivery_partner_id: pickFirst("delivery_partner_id"),
|
||||
delivery_address: pickFirst("delivery_address"),
|
||||
manager_id: pickFirst("manager_id"),
|
||||
sell_mode: pickFirst("sell_mode"),
|
||||
input_mode: pickFirst("input_mode"),
|
||||
price_mode: pickFirst("price_mode"),
|
||||
incoterms: pickFirst("incoterms"),
|
||||
payment_term: pickFirst("payment_term"),
|
||||
currency: pickFirst("currency"),
|
||||
part_code: pickFirst("part_code"),
|
||||
part_name: pickFirst("part_name"),
|
||||
spec: pickFirst("spec"),
|
||||
material: pickFirst("material"),
|
||||
order_qty: pickFirst("order_qty"),
|
||||
unit_price: pickFirst("unit_price"),
|
||||
total_amount: pickFirst("total_amount"),
|
||||
memo: pickFirst("memo"),
|
||||
status: pickFirst("status") || "WAITING",
|
||||
};
|
||||
// undefined 키 제거 (서버가 null로 해석하지 않도록)
|
||||
Object.keys(masterPayload).forEach((k) => {
|
||||
if (masterPayload[k] === undefined) delete masterPayload[k];
|
||||
});
|
||||
|
||||
try {
|
||||
await apiClient.post(`/table-management/tables/${MASTER_TABLE}/add`, masterPayload);
|
||||
} catch (masterErr) {
|
||||
// 마스터 INSERT 실패 시 채번은 이미 소비됨 — 디테일 INSERT도 중단하여 무결성 보호
|
||||
console.warn("마스터 INSERT 실패:", masterErr);
|
||||
toast.error(`수주 마스터 생성 실패 (${newOrderNo}). 관리자에게 문의하세요.`);
|
||||
return null;
|
||||
}
|
||||
return newOrderNo;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 테이블 설정 모달 */}
|
||||
|
||||
@@ -96,6 +96,23 @@ export interface ExcelUploadModalProps {
|
||||
masterDetailExcelConfig?: MasterDetailExcelConfig;
|
||||
// 업로드 후 제어 실행 설정
|
||||
afterUploadFlows?: Array<{ flowId: string; order: number }>;
|
||||
// 커스텀 검증 (3단계 검증 시 추가로 실행, 실패 시 업로드 차단)
|
||||
customValidator?: (
|
||||
mappedRows: Record<string, any>[]
|
||||
) => Promise<{
|
||||
valid: boolean;
|
||||
errors: Array<{ rowIndex: number; column: string; value: string; message: string }>;
|
||||
}>;
|
||||
// 특정 컬럼이 공란일 때 자동 채번 (업로드 직전 각 행에 대해 allocate 순차 호출)
|
||||
autoFillKeyOnEmpty?: {
|
||||
columnName: string;
|
||||
// 채번 규칙 ID 조회 실패 시 null 반환 → 업로드 차단
|
||||
// row/rows: 현재 처리 중인 매핑된 행 데이터 (마스터 INSERT 페이로드 구성에 활용)
|
||||
getOrAllocate: (rowOrRows?: Record<string, any> | Record<string, any>[]) => Promise<string | null>;
|
||||
// true면 공란 행 전체를 1회 채번으로 처리 (같은 order_no로 묶음 등록)
|
||||
// false(기본)면 공란 행마다 개별 채번
|
||||
groupEmptyRows?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
interface ColumnMapping {
|
||||
@@ -146,6 +163,9 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||
masterDetailExcelConfig,
|
||||
// 업로드 후 제어 실행 설정
|
||||
afterUploadFlows,
|
||||
// 커스텀 검증 props
|
||||
customValidator,
|
||||
autoFillKeyOnEmpty,
|
||||
}) => {
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
|
||||
@@ -175,6 +195,11 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||
const [isDataValidating, setIsDataValidating] = useState(false);
|
||||
const [validationResult, setValidationResult] = useState<import("@/lib/api/tableManagement").ExcelValidationResult | null>(null);
|
||||
|
||||
// 커스텀 검증 결과 (customValidator prop 사용 시)
|
||||
const [customValidationErrors, setCustomValidationErrors] = useState<
|
||||
Array<{ rowIndex: number; column: string; value: string; message: string }>
|
||||
>([]);
|
||||
|
||||
// 카테고리 검증 관련
|
||||
const [showCategoryValidation, setShowCategoryValidation] = useState(false);
|
||||
const [isCategoryValidating, setIsCategoryValidating] = useState(false);
|
||||
@@ -593,16 +618,18 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||
|
||||
// 자동 매핑 - 컬럼명과 라벨 모두 비교
|
||||
const handleAutoMapping = () => {
|
||||
// 템플릿 헤더의 필수표시 별표(" *")는 비교 시 제거
|
||||
const stripRequiredMark = (s: string) => s.replace(/\s*\*\s*$/, "");
|
||||
const newMappings = excelColumns.map((excelCol) => {
|
||||
const normalizedExcelCol = excelCol.toLowerCase().trim();
|
||||
const normalizedExcelCol = stripRequiredMark(excelCol.toLowerCase().trim());
|
||||
// [마스터], [디테일] 접두사 제거 후 비교
|
||||
const cleanExcelCol = normalizedExcelCol.replace(/^\[(마스터|디테일)\]\s*/i, "");
|
||||
|
||||
// 1. 먼저 라벨로 매칭 시도 (접두사 제거 후)
|
||||
let matchedSystemCol = systemColumns.find((sysCol) => {
|
||||
if (!sysCol.label) return false;
|
||||
// [마스터], [디테일] 접두사 제거 후 비교
|
||||
const cleanLabel = sysCol.label.toLowerCase().trim().replace(/^\[(마스터|디테일)\]\s*/i, "");
|
||||
// [마스터], [디테일] 접두사 + 필수표시 별표 제거 후 비교
|
||||
const cleanLabel = stripRequiredMark(sysCol.label.toLowerCase().trim()).replace(/^\[(마스터|디테일)\]\s*/i, "");
|
||||
return cleanLabel === normalizedExcelCol || cleanLabel === cleanExcelCol;
|
||||
});
|
||||
|
||||
@@ -1047,6 +1074,39 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||
} finally {
|
||||
setIsDataValidating(false);
|
||||
}
|
||||
|
||||
// 커스텀 검증 (customValidator prop 제공 시)
|
||||
if (customValidator) {
|
||||
setCustomValidationErrors([]);
|
||||
try {
|
||||
// 매핑된 전체 데이터 구성 (빈 행 제외)
|
||||
const mappedForCustom = allData.map((row) => {
|
||||
const mapped: Record<string, any> = {};
|
||||
columnMappings.forEach((m) => {
|
||||
if (m.systemColumn) {
|
||||
let colName = m.systemColumn;
|
||||
if (isMasterDetail && colName.includes(".")) {
|
||||
colName = colName.split(".")[1];
|
||||
}
|
||||
mapped[colName] = row[m.excelColumn];
|
||||
}
|
||||
});
|
||||
return mapped;
|
||||
}).filter((row) => Object.values(row).some((v) => v !== null && v !== undefined && String(v).trim() !== ""));
|
||||
|
||||
if (mappedForCustom.length > 0) {
|
||||
const customResult = await customValidator(mappedForCustom);
|
||||
if (!customResult.valid && customResult.errors.length > 0) {
|
||||
setCustomValidationErrors(customResult.errors);
|
||||
} else {
|
||||
setCustomValidationErrors([]);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("커스텀 검증 실패:", err);
|
||||
setCustomValidationErrors([]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setCurrentStep((prev) => Math.min(prev + 1, 3));
|
||||
@@ -1233,6 +1293,48 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||
// 검증 다이얼로그 통과 여부와 무관하게 저장 직전에 적용
|
||||
filteredData = await convertCategoryLabelsToCodes(filteredData);
|
||||
|
||||
// 자동 채번 처리 (autoFillKeyOnEmpty prop 제공 시)
|
||||
if (autoFillKeyOnEmpty) {
|
||||
const { columnName: keyColName, getOrAllocate, groupEmptyRows } = autoFillKeyOnEmpty;
|
||||
const isEmptyVal = (v: any) => v === undefined || v === null || String(v).trim() === "";
|
||||
|
||||
if (groupEmptyRows) {
|
||||
// 공란 행 전체를 1회 채번으로 묶음 처리 (1 마스터 + N 디테일)
|
||||
const emptyRows = filteredData.filter((r) => isEmptyVal(r[keyColName]));
|
||||
if (emptyRows.length > 0) {
|
||||
const allocated = await getOrAllocate(emptyRows); // 배열 전달
|
||||
if (!allocated) {
|
||||
toast.error("수주번호 채번에 실패했습니다. 규칙 설정을 확인해주세요.");
|
||||
setIsUploading(false);
|
||||
return;
|
||||
}
|
||||
filteredData = filteredData.map((r) =>
|
||||
isEmptyVal(r[keyColName]) ? { ...r, [keyColName]: allocated } : r
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// 공란 행마다 개별 채번 (backwards-compat)
|
||||
const newFilteredData: typeof filteredData = [];
|
||||
for (let i = 0; i < filteredData.length; i++) {
|
||||
const row = filteredData[i];
|
||||
if (isEmptyVal(row[keyColName])) {
|
||||
const allocated = await getOrAllocate(row);
|
||||
if (!allocated) {
|
||||
// 채번 실패 (규칙 미설정 등) → 업로드 중단
|
||||
toast.error("수주번호 채번에 실패했습니다. 규칙 설정을 확인해주세요.");
|
||||
setIsUploading(false);
|
||||
return;
|
||||
}
|
||||
newFilteredData.push({ ...row, [keyColName]: allocated });
|
||||
} else {
|
||||
newFilteredData.push(row);
|
||||
}
|
||||
}
|
||||
filteredData = newFilteredData;
|
||||
}
|
||||
console.log(`📊 자동 채번 완료: ${keyColName} 컬럼 처리`);
|
||||
}
|
||||
|
||||
// 마스터-디테일 간단 모드 처리 (마스터 필드 선택 + 채번 자동 감지)
|
||||
if (isSimpleMasterDetailMode && screenId && masterDetailRelation) {
|
||||
// 마스터 테이블에서 채번 컬럼 자동 감지
|
||||
@@ -1615,6 +1717,8 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||
// 검증 상태 초기화
|
||||
setValidationResult(null);
|
||||
setIsDataValidating(false);
|
||||
// 커스텀 검증 초기화
|
||||
setCustomValidationErrors([]);
|
||||
// 카테고리 검증 초기화
|
||||
setShowCategoryValidation(false);
|
||||
setCategoryMismatches({});
|
||||
@@ -2323,7 +2427,7 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{validationResult?.isValid && (
|
||||
{validationResult?.isValid && customValidationErrors.length === 0 && (
|
||||
<div className="rounded-md border border-success bg-success/10 p-4">
|
||||
<h3 className="flex items-center gap-2 text-sm font-medium text-success sm:text-base">
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
@@ -2335,6 +2439,46 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 커스텀 검증 오류 (품목 미등록 등) */}
|
||||
{customValidationErrors.length > 0 && (
|
||||
<div className="rounded-md border border-destructive bg-destructive/10 p-4">
|
||||
<h3 className="flex items-center gap-2 text-sm font-medium text-destructive sm:text-base">
|
||||
<XCircle className="h-4 w-4" />
|
||||
품목 검증 실패 ({customValidationErrors.length}건) — 업로드 차단
|
||||
</h3>
|
||||
<div className="mt-2 max-h-[200px] space-y-0.5 overflow-y-auto text-[10px] text-destructive sm:text-xs">
|
||||
{(() => {
|
||||
// 같은 code끼리 행 목록 묶기
|
||||
const groupedByCode = new Map<string, { message: string; rows: number[] }>();
|
||||
for (const err of customValidationErrors) {
|
||||
const key = `${err.column}|||${err.value}`;
|
||||
if (!groupedByCode.has(key)) {
|
||||
groupedByCode.set(key, { message: err.message, rows: [] });
|
||||
}
|
||||
groupedByCode.get(key)!.rows.push(err.rowIndex + 1); // 1-based
|
||||
}
|
||||
const entries = Array.from(groupedByCode.entries());
|
||||
const displayEntries = entries.slice(0, 50);
|
||||
return (
|
||||
<>
|
||||
{displayEntries.map(([key, { message, rows }], i) => (
|
||||
<p key={i}>
|
||||
<span className="font-medium">{rows.join(", ")}행</span>: {message}
|
||||
</p>
|
||||
))}
|
||||
{entries.length > 50 && (
|
||||
<p className="font-medium">... 외 {entries.length - 50}건</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
<p className="mt-2 text-[10px] text-destructive sm:text-xs font-medium">
|
||||
위 품목을 품목정보에 등록하거나 수정 후 다시 업로드해주세요.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-md border border-border bg-muted/50 p-4">
|
||||
<h3 className="text-sm font-medium sm:text-base">컬럼 매핑</h3>
|
||||
<div className="mt-2 space-y-1 text-[10px] text-muted-foreground sm:text-xs">
|
||||
@@ -2431,6 +2575,8 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||
disabled={
|
||||
isUploading ||
|
||||
columnMappings.filter((m) => m.systemColumn).length === 0 ||
|
||||
// 커스텀 검증 오류 있으면 업로드 차단
|
||||
customValidationErrors.length > 0 ||
|
||||
(validationResult !== null && !validationResult.isValid && !(
|
||||
validationResult.notNullErrors.length === 0 &&
|
||||
validationResult.uniqueInExcelErrors.length === 0 &&
|
||||
@@ -2441,6 +2587,7 @@ export const ExcelUploadModal: React.FC<ExcelUploadModalProps> = ({
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
{isUploading ? "업로드 중..." :
|
||||
customValidationErrors.length > 0 ? "품목 검증 실패 - 수정 후 재업로드" :
|
||||
validationResult && !validationResult.isValid && !(
|
||||
validationResult.notNullErrors.length === 0 &&
|
||||
validationResult.uniqueInExcelErrors.length === 0 &&
|
||||
|
||||
194
frontend/components/common/MultiCategorySelect.tsx
Normal file
194
frontend/components/common/MultiCategorySelect.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* MultiCategorySelect
|
||||
*
|
||||
* 다중 선택(체크박스 팝오버) 카테고리 셀렉트 컴포넌트.
|
||||
* - 외부 인터페이스: string (콤마 join). 내부에서 string ↔ array 변환 처리.
|
||||
* - 옵션 5개 미만: 검색 input 숨김
|
||||
* - 옵션 5개 이상: 검색 input 자동 노출
|
||||
* - 선택된 항목: 트리거에 라벨 콤마 join 표시. 3개 초과 시 truncate + "+N개" 배지
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo, useEffect } from "react";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { ChevronsUpDown, Search as SearchIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const SEARCH_THRESHOLD = 5;
|
||||
const MAX_LABEL_DISPLAY = 3;
|
||||
|
||||
export interface MultiCategorySelectOption {
|
||||
code: string;
|
||||
label: string;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
interface MultiCategorySelectProps {
|
||||
/** 콤마 join 문자열: "CODE1,CODE2" 형태 */
|
||||
value: string;
|
||||
onValueChange: (v: string) => void;
|
||||
options: MultiCategorySelectOption[];
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function parseValues(value: string): string[] {
|
||||
if (!value) return [];
|
||||
return value
|
||||
.split(",")
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function joinValues(codes: string[]): string {
|
||||
return codes.join(",");
|
||||
}
|
||||
|
||||
export function MultiCategorySelect({
|
||||
value,
|
||||
onValueChange,
|
||||
options,
|
||||
placeholder = "선택",
|
||||
disabled = false,
|
||||
className,
|
||||
}: MultiCategorySelectProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
// code가 비어있는 옵션 자동 제외
|
||||
const safeOptions = useMemo(
|
||||
() => options.filter((o) => o.code !== null && o.code !== undefined && o.code !== ""),
|
||||
[options]
|
||||
);
|
||||
|
||||
// 현재 선택된 코드 배열
|
||||
const selectedCodes = useMemo(() => parseValues(value), [value]);
|
||||
|
||||
// 팝오버 닫힐 때 검색어 리셋
|
||||
useEffect(() => {
|
||||
if (!open) setSearch("");
|
||||
}, [open]);
|
||||
|
||||
// 검색어 필터
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return safeOptions;
|
||||
return safeOptions.filter((o) => o.label.toLowerCase().includes(q));
|
||||
}, [safeOptions, search]);
|
||||
|
||||
// 트리거 표시 라벨
|
||||
const triggerLabel = useMemo(() => {
|
||||
if (selectedCodes.length === 0) return null;
|
||||
const labels = selectedCodes
|
||||
.map((code) => safeOptions.find((o) => o.code === code)?.label || code)
|
||||
.filter(Boolean);
|
||||
if (labels.length === 0) return null;
|
||||
if (labels.length <= MAX_LABEL_DISPLAY) return labels.join(", ");
|
||||
const shown = labels.slice(0, MAX_LABEL_DISPLAY).join(", ");
|
||||
const rest = labels.length - MAX_LABEL_DISPLAY;
|
||||
return (
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="truncate">{shown}</span>
|
||||
<span className="shrink-0 rounded-full bg-primary/15 px-1.5 py-0 text-[10px] font-semibold text-primary">
|
||||
+{rest}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}, [selectedCodes, safeOptions]);
|
||||
|
||||
const toggleCode = (code: string) => {
|
||||
let next: string[];
|
||||
if (selectedCodes.includes(code)) {
|
||||
next = selectedCodes.filter((c) => c !== code);
|
||||
} else {
|
||||
next = [...selectedCodes, code];
|
||||
}
|
||||
onValueChange(joinValues(next));
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
disabled={disabled}
|
||||
className={cn("h-9 w-full justify-between font-normal", className)}
|
||||
>
|
||||
<span className="min-w-0 flex-1 overflow-hidden text-left text-sm">
|
||||
{triggerLabel ?? (
|
||||
<span className="text-muted-foreground">{placeholder}</span>
|
||||
)}
|
||||
</span>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="p-0"
|
||||
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||
align="start"
|
||||
>
|
||||
{safeOptions.length >= SEARCH_THRESHOLD && (
|
||||
<div className="flex items-center border-b px-2">
|
||||
<SearchIcon className="mr-1 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder="검색..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="h-9 border-0 px-1 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="max-h-[280px] overflow-auto py-1">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="py-6 text-center text-sm text-muted-foreground">
|
||||
검색 결과가 없습니다.
|
||||
</div>
|
||||
) : (
|
||||
filtered.map((o) => {
|
||||
const isChecked = selectedCodes.includes(o.code);
|
||||
return (
|
||||
<button
|
||||
key={o.code}
|
||||
type="button"
|
||||
onClick={() => toggleCode(o.code)}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 px-3 py-2 text-sm hover:bg-accent",
|
||||
isChecked && "bg-accent/40"
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={isChecked}
|
||||
onCheckedChange={() => toggleCode(o.code)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="pointer-events-none h-4 w-4 shrink-0"
|
||||
/>
|
||||
<span className="truncate">{o.label}</span>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
{selectedCodes.length > 0 && (
|
||||
<div className="border-t px-3 py-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onValueChange("")}
|
||||
className="text-[11px] text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
선택 초기화
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user