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:
kjs
2026-05-26 15:42:33 +09:00
parent 9bdd3bb668
commit 08ff796ff1
6 changed files with 875 additions and 48 deletions

View File

@@ -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

View File

@@ -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별)

View File

@@ -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

View File

@@ -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
}
/>
{/* 테이블 설정 모달 */}

View File

@@ -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 &&

View 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>
);
}