Merge branch 'main' of https://g.wace.me/jskim/vexplor_dev into jskim-node

This commit is contained in:
kjs
2026-04-22 12:27:36 +09:00
parent 3b796ca9e3
commit 84a3b12346
64 changed files with 3710 additions and 842 deletions
@@ -2142,6 +2142,35 @@ export const getDepartmentList = async (
}
};
/**
* GET /api/admin/users/name-map
* 사용자 ID → 이름 매핑만 반환하는 경량 엔드포인트
* 목적: 이력(writer/created_by 등)에 찍힌 user_id를 이름으로 표시하기 위함
* 보안: 민감 정보(전화번호/이메일 등) 미포함, 인증된 사용자면 누구나 조회
* 회사 필터 없음 — 최고 관리자 계정(company_code='*')도 포함
*/
export const getUserNameMap = async (req: AuthenticatedRequest, res: Response) => {
try {
const rows = await query(
`SELECT user_id, user_name FROM user_info WHERE user_id IS NOT NULL`,
[]
);
res.status(200).json({
success: true,
data: rows.map((r: any) => ({
user_id: r.user_id,
user_name: r.user_name,
})),
});
} catch (error) {
logger.error("사용자 이름 맵 조회 실패", { error });
res.status(500).json({
success: false,
message: "사용자 이름 맵 조회 중 오류가 발생했습니다.",
});
}
};
/**
* GET /api/admin/users/:userId
* 사용자 상세 조회 API
@@ -174,6 +174,7 @@ export async function getMaterialStatus(
ii.item_name AS material_name,
ii.item_number AS material_code,
ii.unit AS material_unit,
ii.inventory_unit AS material_inventory_unit,
COALESCE(ii.width::text, '') AS material_width,
COALESCE(ii.height::text, '') AS material_height,
COALESCE(ii.thickness::text, '') AS material_thickness
@@ -220,7 +221,11 @@ export async function getMaterialStatus(
materialCode:
bomRow.material_code || bomRow.child_item_id,
materialName: bomRow.material_name || "알 수 없음",
unit: bomRow.bom_unit || bomRow.material_unit || "EA",
unit:
bomRow.material_inventory_unit ||
bomRow.bom_unit ||
bomRow.material_unit ||
"EA",
requiredQty,
width: bomRow.material_width || "",
height: bomRow.material_height || "",
@@ -260,12 +265,16 @@ export async function getMaterialStatus(
}
const stockQuery = `
SELECT
SELECT
s.item_code,
s.warehouse_code,
w.warehouse_name,
s.location_code,
COALESCE(CAST(s.current_qty AS NUMERIC), 0) AS current_qty
FROM inventory_stock s
LEFT JOIN warehouse_info w
ON w.warehouse_code = s.warehouse_code
AND w.company_code = s.company_code
WHERE ${stockConditions.join(" AND ")}
AND COALESCE(CAST(s.current_qty AS NUMERIC), 0) > 0
ORDER BY s.item_code, s.warehouse_code, s.location_code
@@ -277,7 +286,7 @@ export async function getMaterialStatus(
// item_code 기준 재고 맵핑 (inventory_stock.item_code는 item_info.item_number 또는 item_info.id일 수 있음)
const stockByItem: Record<
string,
{ location: string; warehouse: string; qty: number }[]
{ location: string; warehouse: string; warehouse_name: string; qty: number }[]
> = {};
for (const stockRow of stockResult.rows) {
@@ -288,6 +297,7 @@ export async function getMaterialStatus(
stockByItem[code].push({
location: stockRow.location_code || "",
warehouse: stockRow.warehouse_code || "",
warehouse_name: stockRow.warehouse_name || "",
qty: Number(stockRow.current_qty),
});
}
+2
View File
@@ -11,6 +11,7 @@ import {
toggleMenuStatus, // 메뉴 상태 토글
copyMenu, // 메뉴 복사
getUserList,
getUserNameMap, // 사용자 ID→이름 맵 (경량)
getUserInfo, // 사용자 상세 조회
getUserHistory, // 사용자 변경이력 조회
changeUserStatus, // 사용자 상태 변경
@@ -70,6 +71,7 @@ router.delete("/menus/:menuId", deleteMenu); // 메뉴 삭제
// 사용자 관리 API
router.get("/users", getUserList);
router.get("/users/name-map", getUserNameMap); // 사용자 ID→이름 매핑 (경량)
router.get("/users/:userId", getUserInfo); // 사용자 상세 조회
router.get("/users/:userId/history", getUserHistory); // 사용자 변경이력 조회
router.get("/users/:userId/with-dept", getUserWithDept); // 사원 + 부서 조회 (NEW!)
@@ -358,13 +358,15 @@ export default function LogisticsInfoPage() {
loadReferences();
}, [loadReferences]);
// 카테고리 옵션 로드
// 카테고리 옵션 로드 (관리자 계정일 때 filterCompanyCode 미제공 시 "*" 스코프로 빈 결과 반환됨)
const loadCategoryOptions = useCallback(async (tableColumn: string) => {
if (loadedCategories.current.has(tableColumn)) return;
loadedCategories.current.add(tableColumn);
const [tableName, columnName] = tableColumn.split(":");
try {
const res = await apiClient.get(`/table-categories/${tableName}/${columnName}/values`);
const res = await apiClient.get(
`/table-categories/${tableName}/${columnName}/values?filterCompanyCode=COMPANY_10`
);
const data = res.data?.data || [];
setCategoryOptions((prev) => ({
...prev,
@@ -823,13 +825,24 @@ export default function LogisticsInfoPage() {
{/* 테이블 영역 */}
<div className="flex-1 overflow-auto">
<EDataTable
columns={getVisibleColumns(tab.key).map((col): EDataTableColumn => ({
key: col.key,
label: col.label,
align: col.align,
formatNumber: col.formatNumber,
truncate: true,
}))}
columns={getVisibleColumns(tab.key).map((col): EDataTableColumn => {
// 같은 key의 formField에 categoryKey가 있으면 코드→라벨 변환
const formField = tab.formFields.find((f) => f.key === col.key && f.categoryKey);
return {
key: col.key,
label: col.label,
align: col.align,
formatNumber: col.formatNumber,
truncate: true,
render: formField?.categoryKey
? (value: any) => {
const opts = categoryOptions[formField.categoryKey!] || [];
const matched = opts.find((o: any) => o.value === value);
return matched?.label || value || "-";
}
: undefined,
};
})}
data={tsMap[tab.key].groupData(displayData)}
rowKey={(row: any) => String(row.id)}
loading={tabLoading[tab.key]}
@@ -186,12 +186,12 @@ export default function InventoryStatusPage() {
};
load();
// 사용자 목록 로드
apiClient.get("/admin/users", { params: { size: 9999 } }).then((res) => {
const users = res.data?.data || res.data || [];
apiClient.get("/admin/users/name-map").then((res) => {
const users = res.data?.data || [];
const map: Record<string, string> = {};
for (const u of users) {
const id = u.userId || u.user_id || u.id;
const name = u.user_name || u.name || id;
const id = u.user_id;
const name = u.user_name || id;
if (id) map[id] = name;
}
setUserMap(map);
@@ -628,7 +628,7 @@ export default function MaterialStatusPage() {
className="inline-flex items-center gap-1 rounded bg-muted/40 px-2 py-0.5 text-xs transition-colors hover:bg-muted/60"
>
<span className="font-semibold font-mono text-primary">
{loc.location || loc.warehouse}
{loc.warehouse_name || loc.location || loc.warehouse}
</span>
<span className="font-semibold">
{loc.qty.toLocaleString()}
@@ -158,6 +158,10 @@ export default function WarehouseManagementPage() {
const [rackStatus, setRackStatus] = useState("");
const [rackPreview, setRackPreview] = useState<any[]>([]);
const [rackSaving, setRackSaving] = useState(false);
// 위치명 접미사 (자동 조립: {zone}{구역접미사}-{row}{열접미사}-{level}{단접미사})
const [rackZoneLabel, setRackZoneLabel] = useState("구역");
const [rackRowLabel, setRackRowLabel] = useState("열");
const [rackLevelLabel, setRackLevelLabel] = useState("단");
// 카테고리 옵션
const [categoryOptions, setCategoryOptions] = useState<
@@ -636,7 +640,7 @@ export default function WarehouseManagementPage() {
duplicates.push(locationCode);
continue;
}
const locationName = `${zoneCode}구역-${rowStr}열-${level}`;
const locationName = `${zoneCode}${rackZoneLabel}-${rowStr}${rackRowLabel}-${level}${rackLevelLabel}`;
items.push({
location_code: locationCode,
location_name: locationName,
@@ -1502,6 +1506,38 @@ export default function WarehouseManagementPage() {
</div>
</div>
{/* 위치명 형식 — 구역/열/단 뒤에 붙일 표현만 자유 입력 */}
<div className="rounded-lg border p-3 bg-muted/30 space-y-2">
<Label className="text-xs font-semibold"> </Label>
<div className="flex items-center gap-1 text-xs flex-wrap">
<span className="font-mono text-muted-foreground">A</span>
<Input
value={rackZoneLabel}
onChange={(e) => setRackZoneLabel(e.target.value)}
placeholder="구역"
className="h-8 w-20 text-xs"
/>
<span className="font-mono text-muted-foreground">- 01</span>
<Input
value={rackRowLabel}
onChange={(e) => setRackRowLabel(e.target.value)}
placeholder="열"
className="h-8 w-20 text-xs"
/>
<span className="font-mono text-muted-foreground">- 1</span>
<Input
value={rackLevelLabel}
onChange={(e) => setRackLevelLabel(e.target.value)}
placeholder="단"
className="h-8 w-20 text-xs"
/>
</div>
<p className="text-[11px] text-muted-foreground">
: <span className="font-mono font-semibold">A{rackZoneLabel}-01{rackRowLabel}-1{rackLevelLabel}</span>
{" "} // , .
</p>
</div>
{/* 등록 미리보기 */}
<div>
<div className="flex items-center justify-between mb-3">
@@ -59,6 +59,7 @@ import {
Settings2,
Save,
Package,
Pencil,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
@@ -355,7 +356,13 @@ export default function BomManagementPage() {
sort: { columnName: "created_at", order: "desc" },
});
const rows = res.data?.data?.data || res.data?.data?.rows || [];
// DB 컬럼이 item_type/expired_date → 프론트 내부에서는 bom_type/expiry_date로 통일
const rawRows = res.data?.data?.data || res.data?.data?.rows || [];
const rows = rawRows.map((r: any) => ({
...r,
bom_type: r.bom_type ?? r.item_type,
expiry_date: r.expiry_date ?? r.expired_date,
}));
setBomList(rows);
setTotalCount(rows.length);
} catch (err: any) {
@@ -452,9 +459,16 @@ export default function BomManagementPage() {
const fetchBomDetail = useCallback(async (bomId: string) => {
setDetailLoading(true);
try {
// 헤더 조회
// 헤더 조회 (DB 컬럼 item_type/expired_date → bom_type/expiry_date로 매핑)
const headerRes = await apiClient.get(`/bom/${bomId}/header`);
const header = headerRes.data?.data || headerRes.data;
const rawHeader = headerRes.data?.data || headerRes.data;
const header = rawHeader
? {
...rawHeader,
bom_type: rawHeader.bom_type ?? rawHeader.item_type,
expiry_date: rawHeader.expiry_date ?? rawHeader.expired_date,
}
: null;
setBomHeader(header);
setCurrentVersionId(header?.current_version_id || null);
@@ -1100,17 +1114,18 @@ export default function BomManagementPage() {
setSaving(true);
try {
// DB 실제 컬럼: item_type / expired_date (프론트 내부 bom_type/expiry_date와 다름)
const bomFields: Record<string, any> = {
item_id: masterForm.item_id,
item_code: masterForm.item_code,
item_name: masterForm.item_name,
bom_type: masterForm.bom_type,
item_type: masterForm.bom_type,
base_qty: masterForm.base_qty || "1",
unit: masterForm.unit || "",
version: masterForm.version || "1.0",
status: masterForm.status || "draft",
effective_date: masterForm.effective_date || null,
expiry_date: masterForm.expiry_date || null,
expired_date: masterForm.expiry_date || null,
remark: masterForm.remark || "",
writer: user?.userId || "",
company_code: user?.company_code || "",
@@ -1482,6 +1497,21 @@ export default function BomManagementPage() {
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
if (!selectedBomId || !bomHeader) {
toast.error("수정할 BOM을 선택해주세요");
return;
}
openEditModal();
}}
disabled={!selectedBomId || !bomHeader}
>
<Pencil className="w-3.5 h-3.5 mr-1" />
</Button>
<div className="w-px h-4 bg-border mx-0.5" />
<Button
size="sm"
@@ -12,6 +12,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
import {
@@ -91,8 +92,8 @@ export function ItemRoutingTab() {
const [formFixedOrder, setFormFixedOrder] = useState("Y");
const [formWorkType, setFormWorkType] = useState("내부");
const [formStandardTime, setFormStandardTime] = useState("");
const [formOutsource, setFormOutsource] = useState("");
const [subcontractorOptions, setSubcontractorOptions] = useState<{ code: string; name: string }[]>([]);
const [formOutsources, setFormOutsources] = useState<string[]>([]);
const [subcontractorOptions, setSubcontractorOptions] = useState<{ id: string; code: string; name: string }[]>([]);
const [detailSubmitting, setDetailSubmitting] = useState(false);
const [registerDialogOpen, setRegisterDialogOpen] = useState(false);
@@ -116,7 +117,7 @@ export function ItemRoutingTab() {
page: 1, size: 500, autoFilter: true,
});
const rows = res.data?.data?.data || res.data?.data?.rows || [];
setSubcontractorOptions(rows.map((r: any) => ({ code: r.subcontractor_code || r.id, name: r.subcontractor_name || "" })));
setSubcontractorOptions(rows.map((r: any) => ({ id: r.id, code: r.subcontractor_code || "", name: r.subcontractor_name || "" })));
} catch { /* skip */ }
})();
}, []);
@@ -281,7 +282,7 @@ export function ItemRoutingTab() {
setFormFixedOrder("Y");
setFormWorkType("내부");
setFormStandardTime("");
setFormOutsource("");
setFormOutsources([]);
setDetailDialogOpen(true);
};
@@ -308,7 +309,19 @@ export function ItemRoutingTab() {
setFormFixedOrder(row.is_fixed_order === "N" ? "N" : "Y");
setFormWorkType(row.work_type || "내부");
setFormStandardTime(row.standard_time || "");
setFormOutsource(row.outsource_supplier || "");
// 우선순위: id 배열 → legacy code 배열(id로 역변환) → legacy 단일 code(id로 역변환)
let loadedIds: string[] = [];
if (Array.isArray(row.outsource_supplier_ids) && row.outsource_supplier_ids.length > 0) {
loadedIds = row.outsource_supplier_ids;
} else {
const legacyCodes = Array.isArray(row.outsource_supplier_list) && row.outsource_supplier_list.length > 0
? row.outsource_supplier_list
: (row.outsource_supplier ? [row.outsource_supplier] : []);
loadedIds = legacyCodes
.map((c: string) => subcontractorOptions.find((s) => s.code === c)?.id)
.filter((v): v is string => Boolean(v));
}
setFormOutsources(loadedIds);
setDetailDialogOpen(true);
};
@@ -329,7 +342,10 @@ export function ItemRoutingTab() {
return;
}
const proc = processes.find((p) => p.process_code === formProcessCode);
const outsource = showOutsourceField ? formOutsource.trim() : "";
const outsourceIds = showOutsourceField ? formOutsources.filter((s) => s && s.trim() !== "") : [];
const outsourcePrimaryCode = outsourceIds.length > 0
? (subcontractorOptions.find((s) => s.id === outsourceIds[0])?.code || "")
: "";
setDetailSubmitting(true);
try {
@@ -344,7 +360,8 @@ export function ItemRoutingTab() {
is_fixed_order: formFixedOrder,
work_type: formWorkType,
standard_time: st || "0",
outsource_supplier: outsource,
outsource_supplier: outsourcePrimaryCode,
outsource_supplier_ids: outsourceIds,
};
setDetails((prev) => sortDetailsBySeq([...prev, newRow]));
toast.success("공정이 추가되었어요. 저장을 눌러 반영해주세요");
@@ -362,7 +379,8 @@ export function ItemRoutingTab() {
is_fixed_order: formFixedOrder,
work_type: formWorkType,
standard_time: st || "0",
outsource_supplier: outsource,
outsource_supplier: outsourcePrimaryCode,
outsource_supplier_ids: outsourceIds,
}
: d,
),
@@ -399,6 +417,7 @@ export function ItemRoutingTab() {
work_type: d.work_type || "내부",
standard_time: String(d.standard_time ?? "0"),
outsource_supplier: d.outsource_supplier || "",
outsource_supplier_ids: d.outsource_supplier_ids || [],
}));
setSaving(true);
@@ -480,11 +499,23 @@ export function ItemRoutingTab() {
const detailsGridData = useMemo(
() =>
details.map((d) => ({
...d,
process_display: d.process_name || d.process_code,
outsource_display: subcontractorOptions.find((s) => s.code === d.outsource_supplier)?.name || d.outsource_supplier || "—",
})),
details.map((d) => {
const ids = Array.isArray(d.outsource_supplier_ids) && d.outsource_supplier_ids.length > 0
? d.outsource_supplier_ids
: [];
let names = ids
.map((i) => subcontractorOptions.find((s) => s.id === i)?.name)
.filter((v): v is string => Boolean(v));
// 레거시 폴백: id 매핑 없을 때 단일 code로 표시
if (names.length === 0 && d.outsource_supplier) {
names = [subcontractorOptions.find((s) => s.code === d.outsource_supplier)?.name || d.outsource_supplier];
}
return {
...d,
process_display: d.process_name || d.process_code,
outsource_display: names.length === 0 ? "—" : names.join(", "),
};
}),
[details, subcontractorOptions],
);
@@ -909,15 +940,46 @@ export function ItemRoutingTab() {
</div>
{showOutsourceField && (
<div className="space-y-1">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Select value={formOutsource || ""} onValueChange={setFormOutsource}>
<SelectTrigger className="h-9"><SelectValue placeholder="외주업체 선택" /></SelectTrigger>
<SelectContent>
{subcontractorOptions.map((s) => (
<SelectItem key={s.code} value={s.code}>{s.name}</SelectItem>
))}
</SelectContent>
</Select>
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"> ( )</Label>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className="h-9 w-full justify-between font-normal">
<span className="truncate text-left text-sm">
{formOutsources.length === 0
? "외주업체 선택"
: formOutsources
.map((i) => subcontractorOptions.find((s) => s.id === i)?.name || i)
.join(", ")}
</span>
<Badge variant="secondary" className="ml-2 shrink-0">{formOutsources.length}</Badge>
</Button>
</PopoverTrigger>
<PopoverContent className="w-[320px] p-0" align="start">
<div className="max-h-64 overflow-y-auto p-2 space-y-1">
{subcontractorOptions.length === 0 ? (
<div className="text-xs text-muted-foreground px-2 py-3"> </div>
) : subcontractorOptions.map((s) => {
const checked = formOutsources.includes(s.id);
return (
<label
key={s.id}
className="flex items-center gap-2 rounded px-2 py-1.5 text-sm cursor-pointer hover:bg-muted"
>
<Checkbox
checked={checked}
onCheckedChange={(v) => {
setFormOutsources((prev) =>
v ? [...prev, s.id] : prev.filter((i) => i !== s.id),
);
}}
/>
<span className="truncate">{s.name}</span>
</label>
);
})}
</div>
</PopoverContent>
</Popover>
</div>
)}
</div>
@@ -52,6 +52,7 @@ const INSPECTION_COLUMNS = [
{ key: "inspection_code", label: "검사코드" },
{ key: "inspection_type", label: "검사유형" },
{ key: "inspection_criteria", label: "검사기준" },
{ key: "criteria_detail", label: "기준상세" },
{ key: "inspection_item", label: "검사항목" },
{ key: "inspection_method", label: "검사방법" },
{ key: "judgment_criteria", label: "판단기준" },
@@ -43,6 +43,7 @@ type InspectionRow = {
inspection_detail: string;
inspection_method: string;
apply_process: string;
classification: string;
acceptance_criteria: string;
is_required: boolean;
judgment_criteria?: string; // 판단기준 라벨 (수치(범위)/텍스트입력/O·X/선택형)
@@ -253,6 +254,11 @@ export default function ItemInspectionInfoPage() {
loadProcessOptions(item.code);
};
// 복사 모달: 편집 가능한 기준 데이터 상태 (등록/수정 폼과 평행 구조)
const [copyForm, setCopyForm] = useState<Record<string, any>>({});
const [copyInspectionRows, setCopyInspectionRows] = useState<Record<string, InspectionRow[]>>({});
const [copyCollapsedTypes, setCopyCollapsedTypes] = useState<Record<string, boolean>>({});
/* ═══════════════════ 복사 모달 (기준 품목 검사정보 → 다른 품목들) ═══════════════════ */
const [copyModalOpen, setCopyModalOpen] = useState(false);
const [copySearchKeyword, setCopySearchKeyword] = useState("");
@@ -294,11 +300,63 @@ export default function ItemInspectionInfoPage() {
setCopyTotal(resData?.total || resData?.totalCount || rows.length);
} catch { /* skip */ } finally { setCopySearchLoading(false); }
};
const openCopyModal = () => {
const openCopyModal = async () => {
if (!selectedItemCode) { toast.error("복사 기준 품목을 먼저 선택해주세요"); return; }
const srcGroup = groupedData.find(g => g.item_code === selectedItemCode);
if (!srcGroup || srcGroup.rows.length === 0) { toast.error("선택한 품목에 복사할 검사정보가 없어요"); return; }
setCopySearchKeyword(""); setCopyPage(1); setCopyCheckedIds([]);
// 기준 품목 데이터를 편집용 상태로 복제 (openEdit과 동일한 변환 로직)
const baseRow = srcGroup.rows[0];
try {
const res = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, {
page: 1, size: 0,
dataFilter: { enabled: true, filters: [{ columnName: "item_code", operator: "equals", value: selectedItemCode }] },
autoFilter: true,
});
const allRows = res.data?.data?.data || res.data?.data?.rows || [];
const rowMap: Record<string, InspectionRow[]> = {};
const typeFlags: Record<string, boolean> = {};
for (const r of allRows) {
const inspType = r.inspection_type || "";
const matched = INSPECTION_TYPES.find(t =>
t.matchLabels.some(ml => inspType.includes(ml)) ||
inspTypeCatOptions.some(cat => inspType.includes(cat.code) && t.matchLabels.some(ml => cat.label.includes(ml)))
);
const typeKey = matched?.key || "";
if (!typeKey) continue;
typeFlags[typeKey] = true;
if (!rowMap[typeKey]) rowMap[typeKey] = [];
const mCode = r.inspection_method || "";
const mLabel = inspMethodCatOptions.find(o => o.code === mCode)?.label || mCode;
const inspOpt = inspOptions.find(o => o.code === r.inspection_standard_id);
const jcCode = inspOpt?.judgment_criteria || "";
const jcLabel = judgmentCatOptions.find(c => c.code === jcCode)?.label || jcCode;
const unitCode = inspOpt?.unit || "";
const unitLabel = inspUnitCatOptions.find(c => c.code === unitCode)?.label || unitCode;
rowMap[typeKey].push({
id: crypto.randomUUID(), // 복사본은 새 id 부여 (원본과 분리)
inspection_standard_id: r.inspection_standard_id || "",
inspection_detail: r.inspection_item_name || r.inspection_standard || "",
inspection_method: mLabel,
apply_process: r.apply_process || "",
classification: r.classification || "",
acceptance_criteria: r.pass_criteria || "",
is_required: r.is_required === "true" || r.is_required === true,
judgment_criteria: jcLabel,
selection_options: inspOpt?.selection_options || "",
unit: unitLabel,
});
}
setCopyInspectionRows(rowMap);
setCopyForm({ ...baseRow, ...typeFlags });
setCopyCollapsedTypes({});
} catch {
setCopyInspectionRows({});
setCopyForm({ ...baseRow });
setCopyCollapsedTypes({});
}
setCopyModalOpen(true);
searchCopyTargets(1);
};
@@ -309,10 +367,18 @@ export default function ItemInspectionInfoPage() {
const handleCopy = async () => {
if (!selectedItemCode) { toast.error("복사 기준 품목이 없어요"); return; }
if (copyCheckedIds.length === 0) { toast.error("붙여넣을 품목을 선택해주세요"); return; }
const sourceGroup = groupedData.find(g => g.item_code === selectedItemCode);
if (!sourceGroup || sourceGroup.rows.length === 0) { toast.error("복사할 검사정보가 없어요"); return; }
// 편집된 rows를 평탄화 (선택된 검사유형의 rows만)
const enabledTypes = INSPECTION_TYPES.filter(t => !!copyForm[t.key]);
const flatRows: Array<{ row: InspectionRow; typeLabel: string }> = [];
for (const t of enabledTypes) {
const rows = copyInspectionRows[t.key] || [];
for (const r of rows) flatRows.push({ row: r, typeLabel: t.label });
}
if (flatRows.length === 0) { toast.error("복사할 검사항목이 없어요"); return; }
const ok = await confirm(
`선택한 ${copyCheckedIds.length}개 품목에 검사정보를 복사할까요?`,
`선택한 ${copyCheckedIds.length}개 품목에 편집된 검사정보(${flatRows.length}개 행)를 복사할까요?`,
{ description: "대상 품목의 기존 검사정보는 삭제 후 교체됩니다.", variant: "info", confirmText: "복사" }
);
if (!ok) return;
@@ -333,13 +399,26 @@ export default function ItemInspectionInfoPage() {
if (existing.length > 0) {
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, { data: existing.map((r: any) => ({ id: r.id })) });
}
for (const r of sourceGroup.rows) {
const { id: _id, created_at: _c, updated_at: _u, ...rest } = r;
let orderSeq = 0;
for (const { row: r, typeLabel } of flatRows) {
orderSeq += 1;
await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, {
...rest,
id: crypto.randomUUID(),
item_code: targetCode,
item_name: targetName,
inspection_type: typeLabel,
inspection_standard_id: r.inspection_standard_id || "",
inspection_item_name: r.inspection_detail || "",
inspection_method: r.inspection_method || "",
apply_process: r.apply_process || "",
classification: r.classification || "",
pass_criteria: r.acceptance_criteria || "",
is_required: r.is_required ? "true" : "false",
is_active: copyForm.is_active || "사용",
manager: copyForm.manager || "",
manager_id: copyForm.manager_id || "",
memo: copyForm.remarks || "",
sort_order: String(orderSeq).padStart(4, "0"),
});
}
setCopyProgress({ current: i + 1, total: copyCheckedIds.length });
@@ -402,7 +481,13 @@ export default function ItemInspectionInfoPage() {
// 선택된 탭의 검사항목 행
const selectedTabRows = useMemo(() => {
if (!selectedGroup || !selectedTypeTab) return [];
return selectedGroup.rows.filter((r: any) => r.inspection_type === selectedTypeTab && r.inspection_standard_id);
const filtered = selectedGroup.rows.filter((r: any) => r.inspection_type === selectedTypeTab && r.inspection_standard_id);
return [...filtered].sort((a: any, b: any) => {
const av = parseInt(String(a.sort_order || "9999"), 10);
const bv = parseInt(String(b.sort_order || "9999"), 10);
if (av === bv) return String(a.id).localeCompare(String(b.id));
return av - bv;
});
}, [selectedGroup, selectedTypeTab]);
// 검사기준 ID → 라벨
@@ -436,6 +521,13 @@ export default function ItemInspectionInfoPage() {
autoFilter: true,
});
const allRows = res.data?.data?.data || res.data?.data?.rows || [];
// sort_order 기준 오름차순 정렬 (varchar이므로 숫자 파싱 후 비교)
allRows.sort((a: any, b: any) => {
const av = parseInt(String(a.sort_order || "9999"), 10);
const bv = parseInt(String(b.sort_order || "9999"), 10);
if (av === bv) return String(a.id).localeCompare(String(b.id));
return av - bv;
});
const rowMap: Record<string, InspectionRow[]> = {};
const typeFlags: Record<string, boolean> = {};
@@ -462,7 +554,8 @@ export default function ItemInspectionInfoPage() {
inspection_standard_id: r.inspection_standard_id || "",
inspection_detail: r.inspection_item_name || r.inspection_standard || "",
inspection_method: mLabel,
apply_process: "",
apply_process: r.apply_process || "",
classification: r.classification || "",
acceptance_criteria: r.pass_criteria || "",
is_required: r.is_required === "true" || r.is_required === true,
judgment_criteria: jcLabel,
@@ -480,7 +573,7 @@ export default function ItemInspectionInfoPage() {
const addInspRow = (typeKey: string) => {
setInspectionRows(prev => ({
...prev,
[typeKey]: [...(prev[typeKey] || []), { id: crypto.randomUUID(), inspection_standard_id: "", inspection_detail: "", inspection_method: "", apply_process: "", acceptance_criteria: "", is_required: false }],
[typeKey]: [...(prev[typeKey] || []), { id: crypto.randomUUID(), inspection_standard_id: "", inspection_detail: "", inspection_method: "", apply_process: "", classification: "", acceptance_criteria: "", is_required: false }],
}));
};
const removeInspRow = (typeKey: string, rowId: string) => {
@@ -525,6 +618,46 @@ export default function ItemInspectionInfoPage() {
};
const toggleCollapse = (typeKey: string) => { setCollapsedTypes(prev => ({ ...prev, [typeKey]: !prev[typeKey] })); };
/* ═══════════════════ 복사 모달용 검사항목 행 관리 (등록 폼과 평행) ═══════════════════ */
const addCopyInspRow = (typeKey: string) => {
setCopyInspectionRows(prev => ({
...prev,
[typeKey]: [...(prev[typeKey] || []), { id: crypto.randomUUID(), inspection_standard_id: "", inspection_detail: "", inspection_method: "", apply_process: "", classification: "", acceptance_criteria: "", is_required: false }],
}));
};
const removeCopyInspRow = (typeKey: string, rowId: string) => {
setCopyInspectionRows(prev => ({ ...prev, [typeKey]: (prev[typeKey] || []).filter(r => r.id !== rowId) }));
};
const updateCopyInspRow = (typeKey: string, rowId: string, field: string, value: any) => {
setCopyInspectionRows(prev => ({
...prev,
[typeKey]: (prev[typeKey] || []).map(r => {
if (r.id !== rowId) return r;
if (field === "inspection_standard_id") {
const opt = inspOptions.find(o => o.code === value);
const methodCode = opt?.method || "";
const methodLabel = inspMethodCatOptions.find(o => o.code === methodCode)?.label || methodCode;
const jcCode = opt?.judgment_criteria || "";
const jcLabel = judgmentCatOptions.find(c => c.code === jcCode)?.label || jcCode;
const unitCode = opt?.unit || "";
const unitLabel = inspUnitCatOptions.find(c => c.code === unitCode)?.label || unitCode;
return {
...r,
inspection_standard_id: value,
inspection_detail: opt?.detail || "",
inspection_method: methodLabel,
judgment_criteria: jcLabel,
selection_options: opt?.selection_options || "",
unit: unitLabel,
acceptance_criteria: "",
};
}
return { ...r, [field]: value };
}),
}));
};
const toggleCopyCollapse = (typeKey: string) => { setCopyCollapsedTypes(prev => ({ ...prev, [typeKey]: !prev[typeKey] })); };
const handleSave = async () => {
if (!form.item_code) { toast.error("품목코드는 필수예요"); return; }
setSaving(true);
@@ -542,18 +675,23 @@ export default function ItemInspectionInfoPage() {
}
const enabledTypes = INSPECTION_TYPES.filter(t => !!form[t.key]);
const rows: any[] = [];
let globalOrder = 0;
for (const t of enabledTypes) {
const typeRows = inspectionRows[t.key] || [];
if (typeRows.length === 0) {
rows.push({ id: crypto.randomUUID(), item_code: form.item_code, item_name: form.item_name, inspection_type: t.label, is_active: form.is_active || "사용", manager_id: form.manager_id || "", memo: form.remarks || "" });
globalOrder += 1;
rows.push({ id: crypto.randomUUID(), item_code: form.item_code, item_name: form.item_name, inspection_type: t.label, is_active: form.is_active || "사용", manager_id: form.manager_id || "", memo: form.remarks || "", sort_order: String(globalOrder).padStart(4, "0") });
} else {
for (const r of typeRows) {
globalOrder += 1;
rows.push({
id: crypto.randomUUID(), item_code: form.item_code, item_name: form.item_name, inspection_type: t.label,
inspection_standard_id: r.inspection_standard_id || "", inspection_item_name: r.inspection_detail || "",
inspection_method: r.inspection_method || "", pass_criteria: r.acceptance_criteria || "",
apply_process: r.apply_process || "", classification: r.classification || "",
is_required: r.is_required ? "true" : "false", is_active: form.is_active || "사용",
manager_id: form.manager_id || "", memo: form.remarks || "",
sort_order: String(globalOrder).padStart(4, "0"),
});
}
}
@@ -974,6 +1112,7 @@ export default function ItemInspectionInfoPage() {
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8 w-[50px]"></TableHead>
@@ -983,7 +1122,7 @@ export default function ItemInspectionInfoPage() {
<TableBody>
{selectedTabRows.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="text-center py-8 text-xs text-muted-foreground"> </TableCell>
<TableCell colSpan={9} className="text-center py-8 text-xs text-muted-foreground"> </TableCell>
</TableRow>
) : selectedTabRows.map((row: any) => (
<TableRow key={row.id}>
@@ -1002,6 +1141,7 @@ export default function ItemInspectionInfoPage() {
const proc = processOptions.find(p => p.code === code);
return proc?.name || code;
})()}</TableCell>
<TableCell className="text-xs py-2">{row.classification || "-"}</TableCell>
<TableCell className="text-xs py-2">
{(() => {
const insp = inspOptions.find(o => o.code === row.inspection_standard_id);
@@ -1185,6 +1325,7 @@ export default function ItemInspectionInfoPage() {
<TableHead className="text-[10px] font-bold w-[130px]"> </TableHead>
<TableHead className="text-[10px] font-bold w-[90px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[90px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[100px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[80px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[200px]"> ()</TableHead>
<TableHead className="text-[10px] font-bold w-[40px]"></TableHead>
@@ -1194,7 +1335,7 @@ export default function ItemInspectionInfoPage() {
</TableHeader>
<TableBody>
{(!inspectionRows[key] || inspectionRows[key].length === 0) ? (
<TableRow><TableCell colSpan={9} className="text-center py-4 text-xs text-muted-foreground"> </TableCell></TableRow>
<TableRow><TableCell colSpan={10} className="text-center py-4 text-xs text-muted-foreground"> </TableCell></TableRow>
) : inspectionRows[key].map((row) => (
<TableRow key={row.id}>
<TableCell className="p-1">
@@ -1219,6 +1360,9 @@ export default function ItemInspectionInfoPage() {
<Input className="h-8 text-xs" value={row.apply_process} onChange={(e) => updateInspRow(key, row.id, "apply_process", e.target.value)} placeholder="공정" />
)}
</TableCell>
<TableCell className="p-1">
<Input className="h-8 text-xs" value={row.classification || ""} onChange={(e) => updateInspRow(key, row.id, "classification", e.target.value)} placeholder="구분 입력" />
</TableCell>
<TableCell className="p-1 text-center">
{row.judgment_criteria ? <Badge variant="outline" className="text-[10px]">{row.judgment_criteria}</Badge> : <span className="text-[10px] text-muted-foreground">-</span>}
</TableCell>
@@ -1285,20 +1429,20 @@ export default function ItemInspectionInfoPage() {
</DialogContent>
</Dialog>
{/* ═══════════════════ 복사 모달 ═══════════════════ */}
{/* ═══════════════════ 복사 모달 (2단 분할: 좌 대상 / 우 편집) ═══════════════════ */}
<Dialog open={copyModalOpen} onOpenChange={(v) => { if (!copying) setCopyModalOpen(v); }}>
<DialogContent
className="max-w-3xl w-[95vw] max-h-[85vh] flex flex-col overflow-hidden"
className="max-w-[95vw] sm:max-w-[1400px] w-[95vw] max-h-[90vh] flex flex-col overflow-hidden"
onPointerDownOutside={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => { if (copying) e.preventDefault(); }}
>
<DialogHeader>
<DialogHeader className="shrink-0">
<DialogTitle>{copying ? "검사정보 복사 중..." : "검사정보 복사"}</DialogTitle>
<DialogDescription>
<span className="font-medium text-foreground">{selectedGroup?.item_name || "-"}</span>
<span className="text-muted-foreground"> ({selectedItemCode})</span>
<span>{copying ? " 의 검사정보를 복사하고 있습니다" : " 의 검사정보를 아래 선택한 품목들에 복사합니다"}</span>
<span>{copying ? " 의 검사정보를 복사하고 있습니다" : " 의 검사정보를 편집해서 선택한 품목들에 복사합니다. 기준 품목은 변경되지 않아요"}</span>
</DialogDescription>
</DialogHeader>
{copying ? (
@@ -1322,81 +1466,229 @@ export default function ItemInspectionInfoPage() {
</p>
</div>
</div>
) : (<>
<div className="flex gap-2 shrink-0">
<Input className="h-9 flex-1" placeholder="품목코드 또는 품목명" value={copySearchKeyword}
onChange={(e) => setCopySearchKeyword(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") handleCopySearch(); }} />
<Button size="sm" className="h-9" onClick={handleCopySearch} disabled={copySearchLoading}>
{copySearchLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <><Search className="w-4 h-4 mr-1" /></>}
</Button>
</div>
<div className="flex-1 border rounded-lg overflow-auto">
<Table>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-[40px] text-center">
<Checkbox
checked={copyFilteredItems.length > 0 && copyFilteredItems.every(i => copyCheckedIds.includes(i.code))}
onCheckedChange={(v) => {
if (v) setCopyCheckedIds(prev => Array.from(new Set([...prev, ...copyFilteredItems.map(i => i.code)])));
else setCopyCheckedIds(prev => prev.filter(c => !copyFilteredItems.some(i => i.code === c)));
}}
/>
</TableHead>
<TableHead className="text-[11px] font-bold w-[140px]"></TableHead>
<TableHead className="text-[11px] font-bold"></TableHead>
<TableHead className="text-[11px] font-bold w-[100px]"></TableHead>
<TableHead className="text-[11px] font-bold w-[80px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{copyFilteredItems.length === 0 ? (
<TableRow><TableCell colSpan={5} className="text-center py-8 text-muted-foreground text-sm">
{copySearchLoading ? "검색 중..." : "검색 결과가 없어요"}
</TableCell></TableRow>
) : copyFilteredItems.map((item) => (
<TableRow key={item.code} className="cursor-pointer hover:bg-primary/5"
onClick={() => toggleCopyChecked(item.code)}>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<Checkbox checked={copyCheckedIds.includes(item.code)} onCheckedChange={() => toggleCopyChecked(item.code)} />
</TableCell>
<TableCell className="text-sm font-mono">{item.code}</TableCell>
<TableCell className="text-sm">{item.name}</TableCell>
<TableCell className="text-sm">{item.item_type}</TableCell>
<TableCell className="text-sm">{item.unit}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="shrink-0 flex items-center justify-between border-t px-2 py-2 text-xs text-muted-foreground">
<span>
<span className="font-medium text-foreground">{copyTotal.toLocaleString()}</span>
{copyCheckedIds.length > 0 && <span className="ml-2"> <span className="font-medium text-primary">{copyCheckedIds.length}</span></span>}
</span>
<div className="flex items-center gap-0.5">
<button onClick={() => { setCopyPage(1); searchCopyTargets(1); }} disabled={copyPage <= 1}
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronsLeft className="h-3.5 w-3.5" /></button>
<button onClick={() => { const p = copyPage - 1; setCopyPage(p); searchCopyTargets(p); }} disabled={copyPage <= 1}
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronLeft className="h-3.5 w-3.5" /></button>
{Array.from({ length: Math.min(5, copyTotalPages) }, (_, i) => {
const start = Math.max(1, Math.min(copyPage - 2, copyTotalPages - 4));
const p = start + i;
if (p > copyTotalPages) return null;
return (
<button key={p} onClick={() => { setCopyPage(p); searchCopyTargets(p); }}
className={cn("h-7 w-7 flex items-center justify-center rounded text-xs",
p === copyPage ? "bg-primary text-primary-foreground font-medium" : "hover:bg-muted")}>{p}</button>
);
})}
<button onClick={() => { const p = copyPage + 1; setCopyPage(p); searchCopyTargets(p); }} disabled={copyPage >= copyTotalPages}
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronRight className="h-3.5 w-3.5" /></button>
<button onClick={() => { setCopyPage(copyTotalPages); searchCopyTargets(copyTotalPages); }} disabled={copyPage >= copyTotalPages}
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronsRight className="h-3.5 w-3.5" /></button>
) : (
<div className="flex-1 grid grid-cols-[420px_1fr] gap-4 overflow-hidden">
{/* 좌측: 복사 대상 품목 선택 */}
<div className="flex flex-col overflow-hidden border rounded-lg">
<div className="flex items-center justify-between border-b bg-muted/50 px-3 py-2">
<span className="text-xs font-semibold"> </span>
{copyCheckedIds.length > 0 && <span className="text-[10px] text-primary"> {copyCheckedIds.length}</span>}
</div>
<div className="flex gap-2 px-2 pt-2">
<Input className="h-8 flex-1 text-xs" placeholder="품목코드 또는 품목명" value={copySearchKeyword}
onChange={(e) => setCopySearchKeyword(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") handleCopySearch(); }} />
<Button size="sm" className="h-8 text-xs" onClick={handleCopySearch} disabled={copySearchLoading}>
{copySearchLoading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Search className="w-3.5 h-3.5" />}
</Button>
</div>
<div className="flex-1 overflow-auto mt-2">
<Table>
<TableHeader className="sticky top-0 z-10 bg-background">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-[36px] text-center text-[10px]">
<Checkbox
checked={copyFilteredItems.length > 0 && copyFilteredItems.every(i => copyCheckedIds.includes(i.code))}
onCheckedChange={(v) => {
if (v) setCopyCheckedIds(prev => Array.from(new Set([...prev, ...copyFilteredItems.map(i => i.code)])));
else setCopyCheckedIds(prev => prev.filter(c => !copyFilteredItems.some(i => i.code === c)));
}}
/>
</TableHead>
<TableHead className="text-[10px] font-bold w-[120px]"></TableHead>
<TableHead className="text-[10px] font-bold"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{copyFilteredItems.length === 0 ? (
<TableRow><TableCell colSpan={3} className="text-center py-6 text-muted-foreground text-xs">
{copySearchLoading ? "검색 중..." : "검색 결과가 없어요"}
</TableCell></TableRow>
) : copyFilteredItems.map((item) => (
<TableRow key={item.code} className="cursor-pointer hover:bg-primary/5"
onClick={() => toggleCopyChecked(item.code)}>
<TableCell className="text-center p-1" onClick={(e) => e.stopPropagation()}>
<Checkbox checked={copyCheckedIds.includes(item.code)} onCheckedChange={() => toggleCopyChecked(item.code)} />
</TableCell>
<TableCell className="text-xs font-mono p-1">{item.code}</TableCell>
<TableCell className="text-xs p-1 truncate">{item.name}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="border-t flex items-center justify-between px-2 py-1 text-[10px] text-muted-foreground">
<span> <span className="font-medium text-foreground">{copyTotal.toLocaleString()}</span></span>
<div className="flex items-center gap-0.5">
<button onClick={() => { setCopyPage(1); searchCopyTargets(1); }} disabled={copyPage <= 1}
className="h-6 w-6 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronsLeft className="h-3 w-3" /></button>
<button onClick={() => { const p = copyPage - 1; setCopyPage(p); searchCopyTargets(p); }} disabled={copyPage <= 1}
className="h-6 w-6 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronLeft className="h-3 w-3" /></button>
<span className="text-[10px] mx-1">{copyPage}/{copyTotalPages}</span>
<button onClick={() => { const p = copyPage + 1; setCopyPage(p); searchCopyTargets(p); }} disabled={copyPage >= copyTotalPages}
className="h-6 w-6 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronRight className="h-3 w-3" /></button>
<button onClick={() => { setCopyPage(copyTotalPages); searchCopyTargets(copyTotalPages); }} disabled={copyPage >= copyTotalPages}
className="h-6 w-6 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronsRight className="h-3 w-3" /></button>
</div>
</div>
</div>
{/* 우측: 편집 폼 (등록/수정 폼과 동일 구조) */}
<div className="flex flex-col overflow-hidden border rounded-lg">
<div className="border-b bg-muted/50 px-3 py-2">
<span className="text-xs font-semibold"> (: {selectedItemCode})</span>
</div>
<div className="flex-1 overflow-y-auto p-3 space-y-4">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-muted-foreground"></Label>
<Select value={copyForm.is_active === false || copyForm.is_active === "N" ? "N" : "Y"} onValueChange={(v) => setCopyForm(p => ({ ...p, is_active: v === "Y" ? "사용" : "미사용" }))}>
<SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="Y"></SelectItem>
<SelectItem value="N"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-muted-foreground"></Label>
<Select value={copyForm.manager || ""} onValueChange={(v) => setCopyForm(p => ({ ...p, manager: v }))}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="관리자 선택" /></SelectTrigger>
<SelectContent>{userOptions.map(o => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<h4 className="text-xs font-semibold"> </h4>
<div className="flex flex-wrap gap-3">
{INSPECTION_TYPES.map(({ key, label }) => (
<div key={key} className="flex items-center gap-1.5">
<Checkbox checked={!!copyForm[key]} onCheckedChange={(v) => setCopyForm(p => ({ ...p, [key]: !!v }))} />
<Label className="text-xs cursor-pointer">{label}</Label>
</div>
))}
</div>
</div>
{INSPECTION_TYPES.filter(t => !!copyForm[t.key]).map(({ key, label }) => (
<div key={key} className="space-y-1.5">
<button type="button" className="w-full flex items-center gap-2 py-1.5 px-2 rounded-md border bg-muted/50 hover:bg-muted text-left" onClick={() => toggleCopyCollapse(key)}>
<Badge variant="default" className="text-[10px]">{label}</Badge>
<span className="text-xs font-medium"> </span>
<span className="text-[10px] text-muted-foreground ml-auto">{(copyInspectionRows[key] || []).length}</span>
</button>
{!copyCollapsedTypes[key] && (
<div className="space-y-1.5 pl-1">
<div className="flex items-center justify-between">
<span className="text-[10px] font-semibold text-muted-foreground"> </span>
<Button type="button" size="sm" variant="outline" className="h-6 text-[10px]" onClick={() => addCopyInspRow(key)}>
<Plus className="w-3 h-3 mr-1" />
</Button>
</div>
<div className="border rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow className="bg-muted/50 hover:bg-muted/50">
<TableHead className="text-[10px] font-bold w-[150px]"> </TableHead>
<TableHead className="text-[10px] font-bold w-[110px]"> </TableHead>
<TableHead className="text-[10px] font-bold w-[80px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[80px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[90px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[70px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[180px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[36px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[60px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[32px]" />
</TableRow>
</TableHeader>
<TableBody>
{(!copyInspectionRows[key] || copyInspectionRows[key].length === 0) ? (
<TableRow><TableCell colSpan={10} className="text-center py-3 text-[10px] text-muted-foreground"> </TableCell></TableRow>
) : copyInspectionRows[key].map((row) => (
<TableRow key={row.id}>
<TableCell className="p-1">
<Select value={row.inspection_standard_id || ""} onValueChange={(v) => updateCopyInspRow(key, row.id, "inspection_standard_id", v)}>
<SelectTrigger className="h-7 text-[10px]"><SelectValue placeholder="검사기준" /></SelectTrigger>
<SelectContent>{getFilteredInspOptions(key).map(o => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}</SelectContent>
</Select>
</TableCell>
<TableCell className="p-1"><Input className="h-7 text-[10px] bg-muted" value={row.inspection_detail} readOnly placeholder="자동" /></TableCell>
<TableCell className="p-1"><Input className="h-7 text-[10px] bg-muted" value={row.inspection_method} readOnly placeholder="자동" /></TableCell>
<TableCell className="p-1">
{processOptions.length > 0 ? (
<Select value={row.apply_process || ""} onValueChange={(v) => updateCopyInspRow(key, row.id, "apply_process", v)}>
<SelectTrigger className="h-7 text-[10px]"><SelectValue placeholder="공정" /></SelectTrigger>
<SelectContent>
{processOptions.map((p) => (
<SelectItem key={p.code} value={p.code} className="text-xs">{p.name}</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input className="h-7 text-[10px]" value={row.apply_process} onChange={(e) => updateCopyInspRow(key, row.id, "apply_process", e.target.value)} placeholder="공정" />
)}
</TableCell>
<TableCell className="p-1">
<Input className="h-7 text-[10px]" value={row.classification || ""} onChange={(e) => updateCopyInspRow(key, row.id, "classification", e.target.value)} placeholder="구분" />
</TableCell>
<TableCell className="p-1 text-center">
{row.judgment_criteria ? <Badge variant="outline" className="text-[9px]">{row.judgment_criteria}</Badge> : <span className="text-[9px] text-muted-foreground">-</span>}
</TableCell>
<TableCell className="p-1">
{row.judgment_criteria === "선택형" && row.selection_options ? (
<Select value={row.acceptance_criteria || ""} onValueChange={(v) => updateCopyInspRow(key, row.id, "acceptance_criteria", v)} disabled={!row.inspection_standard_id}>
<SelectTrigger className="h-7 text-[10px]"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{row.selection_options.split(",").filter(Boolean).map((opt) => (
<SelectItem key={opt} value={opt} className="text-xs">{opt}</SelectItem>
))}
</SelectContent>
</Select>
) : row.judgment_criteria === "O/X" ? (
<Select value={row.acceptance_criteria || ""} onValueChange={(v) => updateCopyInspRow(key, row.id, "acceptance_criteria", v)} disabled={!row.inspection_standard_id}>
<SelectTrigger className="h-7 text-[10px]"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
<SelectItem value="O" className="text-xs">O ()</SelectItem>
<SelectItem value="X" className="text-xs">X ()</SelectItem>
</SelectContent>
</Select>
) : row.judgment_criteria === "수치(범위)" ? (
<div className="flex items-center gap-1">
<Input className="h-7 text-[10px] w-14" value={row.acceptance_criteria?.split("|")[0] || ""} onChange={(e) => {
const parts = (row.acceptance_criteria || "||").split("|");
parts[0] = e.target.value;
updateCopyInspRow(key, row.id, "acceptance_criteria", parts.join("|"));
}} placeholder="기준" disabled={!row.inspection_standard_id} />
<span className="text-[9px] text-muted-foreground">±</span>
<Input className="h-7 text-[10px] w-10" value={row.acceptance_criteria?.split("|")[1] || ""} onChange={(e) => {
const parts = (row.acceptance_criteria || "||").split("|");
parts[1] = e.target.value;
updateCopyInspRow(key, row.id, "acceptance_criteria", parts.join("|"));
}} placeholder="±" disabled={!row.inspection_standard_id} />
</div>
) : (
<Input className="h-7 text-[10px]" value={row.acceptance_criteria} onChange={(e) => updateCopyInspRow(key, row.id, "acceptance_criteria", e.target.value)} placeholder="합격기준" disabled={!row.inspection_standard_id} />
)}
</TableCell>
<TableCell className="p-1 text-center"><Checkbox checked={row.is_required} onCheckedChange={(v) => updateCopyInspRow(key, row.id, "is_required", !!v)} /></TableCell>
<TableCell className="p-1 text-[10px] text-muted-foreground">{row.unit || "-"}</TableCell>
<TableCell className="p-1">
<Button type="button" variant="destructive" size="sm" className="h-6 w-6 p-0" onClick={() => removeCopyInspRow(key, row.id)}><Trash2 className="w-3 h-3" /></Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)}
</div>
))}
</div>
</div>
</div>
</div>
</>)}
)}
<DialogFooter className="shrink-0">
<Button variant="outline" onClick={() => setCopyModalOpen(false)} disabled={copying}></Button>
<Button onClick={handleCopy} disabled={copying || copyCheckedIds.length === 0}>
@@ -358,13 +358,15 @@ export default function LogisticsInfoPage() {
loadReferences();
}, [loadReferences]);
// 카테고리 옵션 로드
// 카테고리 옵션 로드 (관리자 계정일 때 filterCompanyCode 미제공 시 "*" 스코프로 빈 결과 반환됨)
const loadCategoryOptions = useCallback(async (tableColumn: string) => {
if (loadedCategories.current.has(tableColumn)) return;
loadedCategories.current.add(tableColumn);
const [tableName, columnName] = tableColumn.split(":");
try {
const res = await apiClient.get(`/table-categories/${tableName}/${columnName}/values`);
const res = await apiClient.get(
`/table-categories/${tableName}/${columnName}/values?filterCompanyCode=COMPANY_16`
);
const data = res.data?.data || [];
setCategoryOptions((prev) => ({
...prev,
@@ -823,13 +825,24 @@ export default function LogisticsInfoPage() {
{/* 테이블 영역 */}
<div className="flex-1 overflow-auto">
<EDataTable
columns={getVisibleColumns(tab.key).map((col): EDataTableColumn => ({
key: col.key,
label: col.label,
align: col.align,
formatNumber: col.formatNumber,
truncate: true,
}))}
columns={getVisibleColumns(tab.key).map((col): EDataTableColumn => {
// 같은 key의 formField에 categoryKey가 있으면 코드→라벨 변환
const formField = tab.formFields.find((f) => f.key === col.key && f.categoryKey);
return {
key: col.key,
label: col.label,
align: col.align,
formatNumber: col.formatNumber,
truncate: true,
render: formField?.categoryKey
? (value: any) => {
const opts = categoryOptions[formField.categoryKey!] || [];
const matched = opts.find((o: any) => o.value === value);
return matched?.label || value || "-";
}
: undefined,
};
})}
data={tsMap[tab.key].groupData(displayData)}
rowKey={(row: any) => String(row.id)}
loading={tabLoading[tab.key]}
@@ -186,12 +186,12 @@ export default function InventoryStatusPage() {
};
load();
// 사용자 목록 로드
apiClient.get("/admin/users", { params: { size: 9999 } }).then((res) => {
const users = res.data?.data || res.data || [];
apiClient.get("/admin/users/name-map").then((res) => {
const users = res.data?.data || [];
const map: Record<string, string> = {};
for (const u of users) {
const id = u.userId || u.user_id || u.id;
const name = u.user_name || u.name || id;
const id = u.user_id;
const name = u.user_name || id;
if (id) map[id] = name;
}
setUserMap(map);
@@ -628,7 +628,7 @@ export default function MaterialStatusPage() {
className="inline-flex items-center gap-1 rounded bg-muted/40 px-2 py-0.5 text-xs transition-colors hover:bg-muted/60"
>
<span className="font-semibold font-mono text-primary">
{loc.location || loc.warehouse}
{loc.warehouse_name || loc.location || loc.warehouse}
</span>
<span className="font-semibold">
{loc.qty.toLocaleString()}
@@ -158,6 +158,10 @@ export default function WarehouseManagementPage() {
const [rackStatus, setRackStatus] = useState("");
const [rackPreview, setRackPreview] = useState<any[]>([]);
const [rackSaving, setRackSaving] = useState(false);
// 위치명 접미사 (자동 조립: {zone}{구역접미사}-{row}{열접미사}-{level}{단접미사})
const [rackZoneLabel, setRackZoneLabel] = useState("구역");
const [rackRowLabel, setRackRowLabel] = useState("열");
const [rackLevelLabel, setRackLevelLabel] = useState("단");
// 카테고리 옵션
const [categoryOptions, setCategoryOptions] = useState<
@@ -636,7 +640,7 @@ export default function WarehouseManagementPage() {
duplicates.push(locationCode);
continue;
}
const locationName = `${zoneCode}구역-${rowStr}열-${level}`;
const locationName = `${zoneCode}${rackZoneLabel}-${rowStr}${rackRowLabel}-${level}${rackLevelLabel}`;
items.push({
location_code: locationCode,
location_name: locationName,
@@ -1502,6 +1506,38 @@ export default function WarehouseManagementPage() {
</div>
</div>
{/* 위치명 형식 — 구역/열/단 뒤에 붙일 표현만 자유 입력 */}
<div className="rounded-lg border p-3 bg-muted/30 space-y-2">
<Label className="text-xs font-semibold"> </Label>
<div className="flex items-center gap-1 text-xs flex-wrap">
<span className="font-mono text-muted-foreground">A</span>
<Input
value={rackZoneLabel}
onChange={(e) => setRackZoneLabel(e.target.value)}
placeholder="구역"
className="h-8 w-20 text-xs"
/>
<span className="font-mono text-muted-foreground">- 01</span>
<Input
value={rackRowLabel}
onChange={(e) => setRackRowLabel(e.target.value)}
placeholder="열"
className="h-8 w-20 text-xs"
/>
<span className="font-mono text-muted-foreground">- 1</span>
<Input
value={rackLevelLabel}
onChange={(e) => setRackLevelLabel(e.target.value)}
placeholder="단"
className="h-8 w-20 text-xs"
/>
</div>
<p className="text-[11px] text-muted-foreground">
: <span className="font-mono font-semibold">A{rackZoneLabel}-01{rackRowLabel}-1{rackLevelLabel}</span>
{" "} // , .
</p>
</div>
{/* 등록 미리보기 */}
<div>
<div className="flex items-center justify-between mb-3">
@@ -59,6 +59,7 @@ import {
Settings2,
Save,
Package,
Pencil,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
@@ -355,7 +356,13 @@ export default function BomManagementPage() {
sort: { columnName: "created_at", order: "desc" },
});
const rows = res.data?.data?.data || res.data?.data?.rows || [];
// DB 컬럼이 item_type/expired_date → 프론트 내부에서는 bom_type/expiry_date로 통일
const rawRows = res.data?.data?.data || res.data?.data?.rows || [];
const rows = rawRows.map((r: any) => ({
...r,
bom_type: r.bom_type ?? r.item_type,
expiry_date: r.expiry_date ?? r.expired_date,
}));
setBomList(rows);
setTotalCount(rows.length);
} catch (err: any) {
@@ -452,9 +459,16 @@ export default function BomManagementPage() {
const fetchBomDetail = useCallback(async (bomId: string) => {
setDetailLoading(true);
try {
// 헤더 조회
// 헤더 조회 (DB 컬럼 item_type/expired_date → bom_type/expiry_date로 매핑)
const headerRes = await apiClient.get(`/bom/${bomId}/header`);
const header = headerRes.data?.data || headerRes.data;
const rawHeader = headerRes.data?.data || headerRes.data;
const header = rawHeader
? {
...rawHeader,
bom_type: rawHeader.bom_type ?? rawHeader.item_type,
expiry_date: rawHeader.expiry_date ?? rawHeader.expired_date,
}
: null;
setBomHeader(header);
setCurrentVersionId(header?.current_version_id || null);
@@ -1100,17 +1114,18 @@ export default function BomManagementPage() {
setSaving(true);
try {
// DB 실제 컬럼: item_type / expired_date (프론트 내부 bom_type/expiry_date와 다름)
const bomFields: Record<string, any> = {
item_id: masterForm.item_id,
item_code: masterForm.item_code,
item_name: masterForm.item_name,
bom_type: masterForm.bom_type,
item_type: masterForm.bom_type,
base_qty: masterForm.base_qty || "1",
unit: masterForm.unit || "",
version: masterForm.version || "1.0",
status: masterForm.status || "draft",
effective_date: masterForm.effective_date || null,
expiry_date: masterForm.expiry_date || null,
expired_date: masterForm.expiry_date || null,
remark: masterForm.remark || "",
writer: user?.userId || "",
company_code: user?.company_code || "",
@@ -1482,6 +1497,21 @@ export default function BomManagementPage() {
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
if (!selectedBomId || !bomHeader) {
toast.error("수정할 BOM을 선택해주세요");
return;
}
openEditModal();
}}
disabled={!selectedBomId || !bomHeader}
>
<Pencil className="w-3.5 h-3.5 mr-1" />
</Button>
<div className="w-px h-4 bg-border mx-0.5" />
<Button
size="sm"
@@ -12,6 +12,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
import {
@@ -91,8 +92,8 @@ export function ItemRoutingTab() {
const [formFixedOrder, setFormFixedOrder] = useState("Y");
const [formWorkType, setFormWorkType] = useState("내부");
const [formStandardTime, setFormStandardTime] = useState("");
const [formOutsource, setFormOutsource] = useState("");
const [subcontractorOptions, setSubcontractorOptions] = useState<{ code: string; name: string }[]>([]);
const [formOutsources, setFormOutsources] = useState<string[]>([]);
const [subcontractorOptions, setSubcontractorOptions] = useState<{ id: string; code: string; name: string }[]>([]);
const [detailSubmitting, setDetailSubmitting] = useState(false);
const [registerDialogOpen, setRegisterDialogOpen] = useState(false);
@@ -116,7 +117,7 @@ export function ItemRoutingTab() {
page: 1, size: 500, autoFilter: true,
});
const rows = res.data?.data?.data || res.data?.data?.rows || [];
setSubcontractorOptions(rows.map((r: any) => ({ code: r.subcontractor_code || r.id, name: r.subcontractor_name || "" })));
setSubcontractorOptions(rows.map((r: any) => ({ id: r.id, code: r.subcontractor_code || "", name: r.subcontractor_name || "" })));
} catch { /* skip */ }
})();
}, []);
@@ -281,7 +282,7 @@ export function ItemRoutingTab() {
setFormFixedOrder("Y");
setFormWorkType("내부");
setFormStandardTime("");
setFormOutsource("");
setFormOutsources([]);
setDetailDialogOpen(true);
};
@@ -308,7 +309,19 @@ export function ItemRoutingTab() {
setFormFixedOrder(row.is_fixed_order === "N" ? "N" : "Y");
setFormWorkType(row.work_type || "내부");
setFormStandardTime(row.standard_time || "");
setFormOutsource(row.outsource_supplier || "");
// 우선순위: id 배열 → legacy code 배열(id로 역변환) → legacy 단일 code(id로 역변환)
let loadedIds: string[] = [];
if (Array.isArray(row.outsource_supplier_ids) && row.outsource_supplier_ids.length > 0) {
loadedIds = row.outsource_supplier_ids;
} else {
const legacyCodes = Array.isArray(row.outsource_supplier_list) && row.outsource_supplier_list.length > 0
? row.outsource_supplier_list
: (row.outsource_supplier ? [row.outsource_supplier] : []);
loadedIds = legacyCodes
.map((c: string) => subcontractorOptions.find((s) => s.code === c)?.id)
.filter((v): v is string => Boolean(v));
}
setFormOutsources(loadedIds);
setDetailDialogOpen(true);
};
@@ -329,7 +342,10 @@ export function ItemRoutingTab() {
return;
}
const proc = processes.find((p) => p.process_code === formProcessCode);
const outsource = showOutsourceField ? formOutsource.trim() : "";
const outsourceIds = showOutsourceField ? formOutsources.filter((s) => s && s.trim() !== "") : [];
const outsourcePrimaryCode = outsourceIds.length > 0
? (subcontractorOptions.find((s) => s.id === outsourceIds[0])?.code || "")
: "";
setDetailSubmitting(true);
try {
@@ -344,7 +360,8 @@ export function ItemRoutingTab() {
is_fixed_order: formFixedOrder,
work_type: formWorkType,
standard_time: st || "0",
outsource_supplier: outsource,
outsource_supplier: outsourcePrimaryCode,
outsource_supplier_ids: outsourceIds,
};
setDetails((prev) => sortDetailsBySeq([...prev, newRow]));
toast.success("공정이 추가되었어요. 저장을 눌러 반영해주세요");
@@ -362,7 +379,8 @@ export function ItemRoutingTab() {
is_fixed_order: formFixedOrder,
work_type: formWorkType,
standard_time: st || "0",
outsource_supplier: outsource,
outsource_supplier: outsourcePrimaryCode,
outsource_supplier_ids: outsourceIds,
}
: d,
),
@@ -399,6 +417,7 @@ export function ItemRoutingTab() {
work_type: d.work_type || "내부",
standard_time: String(d.standard_time ?? "0"),
outsource_supplier: d.outsource_supplier || "",
outsource_supplier_ids: d.outsource_supplier_ids || [],
}));
setSaving(true);
@@ -480,11 +499,23 @@ export function ItemRoutingTab() {
const detailsGridData = useMemo(
() =>
details.map((d) => ({
...d,
process_display: d.process_name || d.process_code,
outsource_display: subcontractorOptions.find((s) => s.code === d.outsource_supplier)?.name || d.outsource_supplier || "—",
})),
details.map((d) => {
const ids = Array.isArray(d.outsource_supplier_ids) && d.outsource_supplier_ids.length > 0
? d.outsource_supplier_ids
: [];
let names = ids
.map((i) => subcontractorOptions.find((s) => s.id === i)?.name)
.filter((v): v is string => Boolean(v));
// 레거시 폴백: id 매핑 없을 때 단일 code로 표시
if (names.length === 0 && d.outsource_supplier) {
names = [subcontractorOptions.find((s) => s.code === d.outsource_supplier)?.name || d.outsource_supplier];
}
return {
...d,
process_display: d.process_name || d.process_code,
outsource_display: names.length === 0 ? "—" : names.join(", "),
};
}),
[details, subcontractorOptions],
);
@@ -909,15 +940,46 @@ export function ItemRoutingTab() {
</div>
{showOutsourceField && (
<div className="space-y-1">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Select value={formOutsource || ""} onValueChange={setFormOutsource}>
<SelectTrigger className="h-9"><SelectValue placeholder="외주업체 선택" /></SelectTrigger>
<SelectContent>
{subcontractorOptions.map((s) => (
<SelectItem key={s.code} value={s.code}>{s.name}</SelectItem>
))}
</SelectContent>
</Select>
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"> ( )</Label>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className="h-9 w-full justify-between font-normal">
<span className="truncate text-left text-sm">
{formOutsources.length === 0
? "외주업체 선택"
: formOutsources
.map((i) => subcontractorOptions.find((s) => s.id === i)?.name || i)
.join(", ")}
</span>
<Badge variant="secondary" className="ml-2 shrink-0">{formOutsources.length}</Badge>
</Button>
</PopoverTrigger>
<PopoverContent className="w-[320px] p-0" align="start">
<div className="max-h-64 overflow-y-auto p-2 space-y-1">
{subcontractorOptions.length === 0 ? (
<div className="text-xs text-muted-foreground px-2 py-3"> </div>
) : subcontractorOptions.map((s) => {
const checked = formOutsources.includes(s.id);
return (
<label
key={s.id}
className="flex items-center gap-2 rounded px-2 py-1.5 text-sm cursor-pointer hover:bg-muted"
>
<Checkbox
checked={checked}
onCheckedChange={(v) => {
setFormOutsources((prev) =>
v ? [...prev, s.id] : prev.filter((i) => i !== s.id),
);
}}
/>
<span className="truncate">{s.name}</span>
</label>
);
})}
</div>
</PopoverContent>
</Popover>
</div>
)}
</div>
@@ -52,6 +52,7 @@ const INSPECTION_COLUMNS = [
{ key: "inspection_code", label: "검사코드" },
{ key: "inspection_type", label: "검사유형" },
{ key: "inspection_criteria", label: "검사기준" },
{ key: "criteria_detail", label: "기준상세" },
{ key: "inspection_item", label: "검사항목" },
{ key: "inspection_method", label: "검사방법" },
{ key: "judgment_criteria", label: "판단기준" },
@@ -43,6 +43,7 @@ type InspectionRow = {
inspection_detail: string;
inspection_method: string;
apply_process: string;
classification: string;
acceptance_criteria: string;
is_required: boolean;
judgment_criteria?: string; // 판단기준 라벨 (수치(범위)/텍스트입력/O·X/선택형)
@@ -472,7 +473,13 @@ export default function ItemInspectionInfoPage() {
// 선택된 탭의 검사항목 행
const selectedTabRows = useMemo(() => {
if (!selectedGroup || !selectedTypeTab) return [];
return selectedGroup.rows.filter((r: any) => r.inspection_type === selectedTypeTab && r.inspection_standard_id);
const filtered = selectedGroup.rows.filter((r: any) => r.inspection_type === selectedTypeTab && r.inspection_standard_id);
return [...filtered].sort((a: any, b: any) => {
const av = parseInt(String(a.sort_order || "9999"), 10);
const bv = parseInt(String(b.sort_order || "9999"), 10);
if (av === bv) return String(a.id).localeCompare(String(b.id));
return av - bv;
});
}, [selectedGroup, selectedTypeTab]);
// 검사기준 ID → 라벨
@@ -506,6 +513,13 @@ export default function ItemInspectionInfoPage() {
autoFilter: true,
});
const allRows = res.data?.data?.data || res.data?.data?.rows || [];
// sort_order 기준 오름차순 정렬 (varchar이므로 숫자 파싱 후 비교)
allRows.sort((a: any, b: any) => {
const av = parseInt(String(a.sort_order || "9999"), 10);
const bv = parseInt(String(b.sort_order || "9999"), 10);
if (av === bv) return String(a.id).localeCompare(String(b.id));
return av - bv;
});
const rowMap: Record<string, InspectionRow[]> = {};
const typeFlags: Record<string, boolean> = {};
@@ -532,7 +546,8 @@ export default function ItemInspectionInfoPage() {
inspection_standard_id: r.inspection_standard_id || "",
inspection_detail: r.inspection_item_name || r.inspection_standard || "",
inspection_method: mLabel,
apply_process: "",
apply_process: r.apply_process || "",
classification: r.classification || "",
acceptance_criteria: r.pass_criteria || "",
is_required: r.is_required === "true" || r.is_required === true,
judgment_criteria: jcLabel,
@@ -550,7 +565,7 @@ export default function ItemInspectionInfoPage() {
const addInspRow = (typeKey: string) => {
setInspectionRows(prev => ({
...prev,
[typeKey]: [...(prev[typeKey] || []), { id: crypto.randomUUID(), inspection_standard_id: "", inspection_detail: "", inspection_method: "", apply_process: "", acceptance_criteria: "", is_required: false }],
[typeKey]: [...(prev[typeKey] || []), { id: crypto.randomUUID(), inspection_standard_id: "", inspection_detail: "", inspection_method: "", apply_process: "", classification: "", acceptance_criteria: "", is_required: false }],
}));
};
const removeInspRow = (typeKey: string, rowId: string) => {
@@ -652,18 +667,23 @@ export default function ItemInspectionInfoPage() {
}
const enabledTypes = INSPECTION_TYPES.filter(t => !!form[t.key]);
const rows: any[] = [];
let globalOrder = 0;
for (const t of enabledTypes) {
const typeRows = inspectionRows[t.key] || [];
if (typeRows.length === 0) {
rows.push({ id: crypto.randomUUID(), item_code: form.item_code, item_name: form.item_name, inspection_type: t.label, is_active: form.is_active || "사용", manager_id: form.manager_id || "", memo: form.remarks || "" });
globalOrder += 1;
rows.push({ id: crypto.randomUUID(), item_code: form.item_code, item_name: form.item_name, inspection_type: t.label, is_active: form.is_active || "사용", manager_id: form.manager_id || "", memo: form.remarks || "", sort_order: String(globalOrder).padStart(4, "0") });
} else {
for (const r of typeRows) {
globalOrder += 1;
rows.push({
id: crypto.randomUUID(), item_code: form.item_code, item_name: form.item_name, inspection_type: t.label,
inspection_standard_id: r.inspection_standard_id || "", inspection_item_name: r.inspection_detail || "",
inspection_method: r.inspection_method || "", pass_criteria: r.acceptance_criteria || "",
apply_process: r.apply_process || "", classification: r.classification || "",
is_required: r.is_required ? "true" : "false", is_active: form.is_active || "사용",
manager_id: form.manager_id || "", memo: form.remarks || "",
sort_order: String(globalOrder).padStart(4, "0"),
});
}
}
@@ -1084,6 +1104,7 @@ export default function ItemInspectionInfoPage() {
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8 w-[50px]"></TableHead>
@@ -1093,7 +1114,7 @@ export default function ItemInspectionInfoPage() {
<TableBody>
{selectedTabRows.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="text-center py-8 text-xs text-muted-foreground"> </TableCell>
<TableCell colSpan={9} className="text-center py-8 text-xs text-muted-foreground"> </TableCell>
</TableRow>
) : selectedTabRows.map((row: any) => (
<TableRow key={row.id}>
@@ -1112,6 +1133,7 @@ export default function ItemInspectionInfoPage() {
const proc = processOptions.find(p => p.code === code);
return proc?.name || code;
})()}</TableCell>
<TableCell className="text-xs py-2">{row.classification || "-"}</TableCell>
<TableCell className="text-xs py-2">
{(() => {
const insp = inspOptions.find(o => o.code === row.inspection_standard_id);
@@ -1295,6 +1317,7 @@ export default function ItemInspectionInfoPage() {
<TableHead className="text-[10px] font-bold w-[130px]"> </TableHead>
<TableHead className="text-[10px] font-bold w-[90px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[90px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[100px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[80px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[200px]"> ()</TableHead>
<TableHead className="text-[10px] font-bold w-[40px]"></TableHead>
@@ -1304,7 +1327,7 @@ export default function ItemInspectionInfoPage() {
</TableHeader>
<TableBody>
{(!inspectionRows[key] || inspectionRows[key].length === 0) ? (
<TableRow><TableCell colSpan={9} className="text-center py-4 text-xs text-muted-foreground"> </TableCell></TableRow>
<TableRow><TableCell colSpan={10} className="text-center py-4 text-xs text-muted-foreground"> </TableCell></TableRow>
) : inspectionRows[key].map((row) => (
<TableRow key={row.id}>
<TableCell className="p-1">
@@ -1329,6 +1352,9 @@ export default function ItemInspectionInfoPage() {
<Input className="h-8 text-xs" value={row.apply_process} onChange={(e) => updateInspRow(key, row.id, "apply_process", e.target.value)} placeholder="공정" />
)}
</TableCell>
<TableCell className="p-1">
<Input className="h-8 text-xs" value={row.classification || ""} onChange={(e) => updateInspRow(key, row.id, "classification", e.target.value)} placeholder="구분 입력" />
</TableCell>
<TableCell className="p-1 text-center">
{row.judgment_criteria ? <Badge variant="outline" className="text-[10px]">{row.judgment_criteria}</Badge> : <span className="text-[10px] text-muted-foreground">-</span>}
</TableCell>
@@ -1560,6 +1586,7 @@ export default function ItemInspectionInfoPage() {
<TableHead className="text-[10px] font-bold w-[110px]"> </TableHead>
<TableHead className="text-[10px] font-bold w-[80px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[80px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[80px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[70px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[180px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[36px]"></TableHead>
@@ -1569,7 +1596,7 @@ export default function ItemInspectionInfoPage() {
</TableHeader>
<TableBody>
{(!copyInspectionRows[key] || copyInspectionRows[key].length === 0) ? (
<TableRow><TableCell colSpan={9} className="text-center py-3 text-[10px] text-muted-foreground"> </TableCell></TableRow>
<TableRow><TableCell colSpan={10} className="text-center py-3 text-[10px] text-muted-foreground"> </TableCell></TableRow>
) : copyInspectionRows[key].map((row) => (
<TableRow key={row.id}>
<TableCell className="p-1">
@@ -1594,6 +1621,9 @@ export default function ItemInspectionInfoPage() {
<Input className="h-7 text-[10px]" value={row.apply_process} onChange={(e) => updateCopyInspRow(key, row.id, "apply_process", e.target.value)} placeholder="공정" />
)}
</TableCell>
<TableCell className="p-1">
<Input className="h-7 text-[10px]" value={row.classification || ""} onChange={(e) => updateCopyInspRow(key, row.id, "classification", e.target.value)} placeholder="구분" />
</TableCell>
<TableCell className="p-1 text-center">
{row.judgment_criteria ? <Badge variant="outline" className="text-[9px]">{row.judgment_criteria}</Badge> : <span className="text-[9px] text-muted-foreground">-</span>}
</TableCell>
@@ -358,13 +358,15 @@ export default function LogisticsInfoPage() {
loadReferences();
}, [loadReferences]);
// 카테고리 옵션 로드
// 카테고리 옵션 로드 (관리자 계정일 때 filterCompanyCode 미제공 시 "*" 스코프로 빈 결과 반환됨)
const loadCategoryOptions = useCallback(async (tableColumn: string) => {
if (loadedCategories.current.has(tableColumn)) return;
loadedCategories.current.add(tableColumn);
const [tableName, columnName] = tableColumn.split(":");
try {
const res = await apiClient.get(`/table-categories/${tableName}/${columnName}/values`);
const res = await apiClient.get(
`/table-categories/${tableName}/${columnName}/values?filterCompanyCode=COMPANY_29`
);
const data = res.data?.data || [];
setCategoryOptions((prev) => ({
...prev,
@@ -823,13 +825,24 @@ export default function LogisticsInfoPage() {
{/* 테이블 영역 */}
<div className="flex-1 overflow-auto">
<EDataTable
columns={getVisibleColumns(tab.key).map((col): EDataTableColumn => ({
key: col.key,
label: col.label,
align: col.align,
formatNumber: col.formatNumber,
truncate: true,
}))}
columns={getVisibleColumns(tab.key).map((col): EDataTableColumn => {
// 같은 key의 formField에 categoryKey가 있으면 코드→라벨 변환
const formField = tab.formFields.find((f) => f.key === col.key && f.categoryKey);
return {
key: col.key,
label: col.label,
align: col.align,
formatNumber: col.formatNumber,
truncate: true,
render: formField?.categoryKey
? (value: any) => {
const opts = categoryOptions[formField.categoryKey!] || [];
const matched = opts.find((o: any) => o.value === value);
return matched?.label || value || "-";
}
: undefined,
};
})}
data={tsMap[tab.key].groupData(displayData)}
rowKey={(row: any) => String(row.id)}
loading={tabLoading[tab.key]}
@@ -186,12 +186,12 @@ export default function InventoryStatusPage() {
};
load();
// 사용자 목록 로드
apiClient.get("/admin/users", { params: { size: 9999 } }).then((res) => {
const users = res.data?.data || res.data || [];
apiClient.get("/admin/users/name-map").then((res) => {
const users = res.data?.data || [];
const map: Record<string, string> = {};
for (const u of users) {
const id = u.userId || u.user_id || u.id;
const name = u.user_name || u.name || id;
const id = u.user_id;
const name = u.user_name || id;
if (id) map[id] = name;
}
setUserMap(map);
@@ -628,7 +628,7 @@ export default function MaterialStatusPage() {
className="inline-flex items-center gap-1 rounded bg-muted/40 px-2 py-0.5 text-xs transition-colors hover:bg-muted/60"
>
<span className="font-semibold font-mono text-primary">
{loc.location || loc.warehouse}
{loc.warehouse_name || loc.location || loc.warehouse}
</span>
<span className="font-semibold">
{loc.qty.toLocaleString()}
@@ -158,6 +158,10 @@ export default function WarehouseManagementPage() {
const [rackStatus, setRackStatus] = useState("");
const [rackPreview, setRackPreview] = useState<any[]>([]);
const [rackSaving, setRackSaving] = useState(false);
// 위치명 접미사 (자동 조립: {zone}{구역접미사}-{row}{열접미사}-{level}{단접미사})
const [rackZoneLabel, setRackZoneLabel] = useState("구역");
const [rackRowLabel, setRackRowLabel] = useState("열");
const [rackLevelLabel, setRackLevelLabel] = useState("단");
// 카테고리 옵션
const [categoryOptions, setCategoryOptions] = useState<
@@ -636,7 +640,7 @@ export default function WarehouseManagementPage() {
duplicates.push(locationCode);
continue;
}
const locationName = `${zoneCode}구역-${rowStr}열-${level}`;
const locationName = `${zoneCode}${rackZoneLabel}-${rowStr}${rackRowLabel}-${level}${rackLevelLabel}`;
items.push({
location_code: locationCode,
location_name: locationName,
@@ -1502,6 +1506,38 @@ export default function WarehouseManagementPage() {
</div>
</div>
{/* 위치명 형식 — 구역/열/단 뒤에 붙일 표현만 자유 입력 */}
<div className="rounded-lg border p-3 bg-muted/30 space-y-2">
<Label className="text-xs font-semibold"> </Label>
<div className="flex items-center gap-1 text-xs flex-wrap">
<span className="font-mono text-muted-foreground">A</span>
<Input
value={rackZoneLabel}
onChange={(e) => setRackZoneLabel(e.target.value)}
placeholder="구역"
className="h-8 w-20 text-xs"
/>
<span className="font-mono text-muted-foreground">- 01</span>
<Input
value={rackRowLabel}
onChange={(e) => setRackRowLabel(e.target.value)}
placeholder="열"
className="h-8 w-20 text-xs"
/>
<span className="font-mono text-muted-foreground">- 1</span>
<Input
value={rackLevelLabel}
onChange={(e) => setRackLevelLabel(e.target.value)}
placeholder="단"
className="h-8 w-20 text-xs"
/>
</div>
<p className="text-[11px] text-muted-foreground">
: <span className="font-mono font-semibold">A{rackZoneLabel}-01{rackRowLabel}-1{rackLevelLabel}</span>
{" "} // , .
</p>
</div>
{/* 등록 미리보기 */}
<div>
<div className="flex items-center justify-between mb-3">
@@ -59,6 +59,7 @@ import {
Settings2,
Save,
Package,
Pencil,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
@@ -355,7 +356,13 @@ export default function BomManagementPage() {
sort: { columnName: "created_at", order: "desc" },
});
const rows = res.data?.data?.data || res.data?.data?.rows || [];
// DB 컬럼이 item_type/expired_date → 프론트 내부에서는 bom_type/expiry_date로 통일
const rawRows = res.data?.data?.data || res.data?.data?.rows || [];
const rows = rawRows.map((r: any) => ({
...r,
bom_type: r.bom_type ?? r.item_type,
expiry_date: r.expiry_date ?? r.expired_date,
}));
setBomList(rows);
setTotalCount(rows.length);
} catch (err: any) {
@@ -452,9 +459,16 @@ export default function BomManagementPage() {
const fetchBomDetail = useCallback(async (bomId: string) => {
setDetailLoading(true);
try {
// 헤더 조회
// 헤더 조회 (DB 컬럼 item_type/expired_date → bom_type/expiry_date로 매핑)
const headerRes = await apiClient.get(`/bom/${bomId}/header`);
const header = headerRes.data?.data || headerRes.data;
const rawHeader = headerRes.data?.data || headerRes.data;
const header = rawHeader
? {
...rawHeader,
bom_type: rawHeader.bom_type ?? rawHeader.item_type,
expiry_date: rawHeader.expiry_date ?? rawHeader.expired_date,
}
: null;
setBomHeader(header);
setCurrentVersionId(header?.current_version_id || null);
@@ -1100,17 +1114,18 @@ export default function BomManagementPage() {
setSaving(true);
try {
// DB 실제 컬럼: item_type / expired_date (프론트 내부 bom_type/expiry_date와 다름)
const bomFields: Record<string, any> = {
item_id: masterForm.item_id,
item_code: masterForm.item_code,
item_name: masterForm.item_name,
bom_type: masterForm.bom_type,
item_type: masterForm.bom_type,
base_qty: masterForm.base_qty || "1",
unit: masterForm.unit || "",
version: masterForm.version || "1.0",
status: masterForm.status || "draft",
effective_date: masterForm.effective_date || null,
expiry_date: masterForm.expiry_date || null,
expired_date: masterForm.expiry_date || null,
remark: masterForm.remark || "",
writer: user?.userId || "",
company_code: user?.company_code || "",
@@ -1482,6 +1497,21 @@ export default function BomManagementPage() {
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
if (!selectedBomId || !bomHeader) {
toast.error("수정할 BOM을 선택해주세요");
return;
}
openEditModal();
}}
disabled={!selectedBomId || !bomHeader}
>
<Pencil className="w-3.5 h-3.5 mr-1" />
</Button>
<div className="w-px h-4 bg-border mx-0.5" />
<Button
size="sm"
@@ -12,6 +12,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
import {
@@ -91,8 +92,8 @@ export function ItemRoutingTab() {
const [formFixedOrder, setFormFixedOrder] = useState("Y");
const [formWorkType, setFormWorkType] = useState("내부");
const [formStandardTime, setFormStandardTime] = useState("");
const [formOutsource, setFormOutsource] = useState("");
const [subcontractorOptions, setSubcontractorOptions] = useState<{ code: string; name: string }[]>([]);
const [formOutsources, setFormOutsources] = useState<string[]>([]);
const [subcontractorOptions, setSubcontractorOptions] = useState<{ id: string; code: string; name: string }[]>([]);
const [detailSubmitting, setDetailSubmitting] = useState(false);
const [registerDialogOpen, setRegisterDialogOpen] = useState(false);
@@ -116,7 +117,7 @@ export function ItemRoutingTab() {
page: 1, size: 500, autoFilter: true,
});
const rows = res.data?.data?.data || res.data?.data?.rows || [];
setSubcontractorOptions(rows.map((r: any) => ({ code: r.subcontractor_code || r.id, name: r.subcontractor_name || "" })));
setSubcontractorOptions(rows.map((r: any) => ({ id: r.id, code: r.subcontractor_code || "", name: r.subcontractor_name || "" })));
} catch { /* skip */ }
})();
}, []);
@@ -281,7 +282,7 @@ export function ItemRoutingTab() {
setFormFixedOrder("Y");
setFormWorkType("내부");
setFormStandardTime("");
setFormOutsource("");
setFormOutsources([]);
setDetailDialogOpen(true);
};
@@ -308,7 +309,19 @@ export function ItemRoutingTab() {
setFormFixedOrder(row.is_fixed_order === "N" ? "N" : "Y");
setFormWorkType(row.work_type || "내부");
setFormStandardTime(row.standard_time || "");
setFormOutsource(row.outsource_supplier || "");
// 우선순위: id 배열 → legacy code 배열(id로 역변환) → legacy 단일 code(id로 역변환)
let loadedIds: string[] = [];
if (Array.isArray(row.outsource_supplier_ids) && row.outsource_supplier_ids.length > 0) {
loadedIds = row.outsource_supplier_ids;
} else {
const legacyCodes = Array.isArray(row.outsource_supplier_list) && row.outsource_supplier_list.length > 0
? row.outsource_supplier_list
: (row.outsource_supplier ? [row.outsource_supplier] : []);
loadedIds = legacyCodes
.map((c: string) => subcontractorOptions.find((s) => s.code === c)?.id)
.filter((v): v is string => Boolean(v));
}
setFormOutsources(loadedIds);
setDetailDialogOpen(true);
};
@@ -329,7 +342,10 @@ export function ItemRoutingTab() {
return;
}
const proc = processes.find((p) => p.process_code === formProcessCode);
const outsource = showOutsourceField ? formOutsource.trim() : "";
const outsourceIds = showOutsourceField ? formOutsources.filter((s) => s && s.trim() !== "") : [];
const outsourcePrimaryCode = outsourceIds.length > 0
? (subcontractorOptions.find((s) => s.id === outsourceIds[0])?.code || "")
: "";
setDetailSubmitting(true);
try {
@@ -344,7 +360,8 @@ export function ItemRoutingTab() {
is_fixed_order: formFixedOrder,
work_type: formWorkType,
standard_time: st || "0",
outsource_supplier: outsource,
outsource_supplier: outsourcePrimaryCode,
outsource_supplier_ids: outsourceIds,
};
setDetails((prev) => sortDetailsBySeq([...prev, newRow]));
toast.success("공정이 추가되었어요. 저장을 눌러 반영해주세요");
@@ -362,7 +379,8 @@ export function ItemRoutingTab() {
is_fixed_order: formFixedOrder,
work_type: formWorkType,
standard_time: st || "0",
outsource_supplier: outsource,
outsource_supplier: outsourcePrimaryCode,
outsource_supplier_ids: outsourceIds,
}
: d,
),
@@ -399,6 +417,7 @@ export function ItemRoutingTab() {
work_type: d.work_type || "내부",
standard_time: String(d.standard_time ?? "0"),
outsource_supplier: d.outsource_supplier || "",
outsource_supplier_ids: d.outsource_supplier_ids || [],
}));
setSaving(true);
@@ -480,11 +499,23 @@ export function ItemRoutingTab() {
const detailsGridData = useMemo(
() =>
details.map((d) => ({
...d,
process_display: d.process_name || d.process_code,
outsource_display: subcontractorOptions.find((s) => s.code === d.outsource_supplier)?.name || d.outsource_supplier || "—",
})),
details.map((d) => {
const ids = Array.isArray(d.outsource_supplier_ids) && d.outsource_supplier_ids.length > 0
? d.outsource_supplier_ids
: [];
let names = ids
.map((i) => subcontractorOptions.find((s) => s.id === i)?.name)
.filter((v): v is string => Boolean(v));
// 레거시 폴백: id 매핑 없을 때 단일 code로 표시
if (names.length === 0 && d.outsource_supplier) {
names = [subcontractorOptions.find((s) => s.code === d.outsource_supplier)?.name || d.outsource_supplier];
}
return {
...d,
process_display: d.process_name || d.process_code,
outsource_display: names.length === 0 ? "—" : names.join(", "),
};
}),
[details, subcontractorOptions],
);
@@ -909,15 +940,46 @@ export function ItemRoutingTab() {
</div>
{showOutsourceField && (
<div className="space-y-1">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Select value={formOutsource || ""} onValueChange={setFormOutsource}>
<SelectTrigger className="h-9"><SelectValue placeholder="외주업체 선택" /></SelectTrigger>
<SelectContent>
{subcontractorOptions.map((s) => (
<SelectItem key={s.code} value={s.code}>{s.name}</SelectItem>
))}
</SelectContent>
</Select>
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"> ( )</Label>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className="h-9 w-full justify-between font-normal">
<span className="truncate text-left text-sm">
{formOutsources.length === 0
? "외주업체 선택"
: formOutsources
.map((i) => subcontractorOptions.find((s) => s.id === i)?.name || i)
.join(", ")}
</span>
<Badge variant="secondary" className="ml-2 shrink-0">{formOutsources.length}</Badge>
</Button>
</PopoverTrigger>
<PopoverContent className="w-[320px] p-0" align="start">
<div className="max-h-64 overflow-y-auto p-2 space-y-1">
{subcontractorOptions.length === 0 ? (
<div className="text-xs text-muted-foreground px-2 py-3"> </div>
) : subcontractorOptions.map((s) => {
const checked = formOutsources.includes(s.id);
return (
<label
key={s.id}
className="flex items-center gap-2 rounded px-2 py-1.5 text-sm cursor-pointer hover:bg-muted"
>
<Checkbox
checked={checked}
onCheckedChange={(v) => {
setFormOutsources((prev) =>
v ? [...prev, s.id] : prev.filter((i) => i !== s.id),
);
}}
/>
<span className="truncate">{s.name}</span>
</label>
);
})}
</div>
</PopoverContent>
</Popover>
</div>
)}
</div>
@@ -52,6 +52,7 @@ const INSPECTION_COLUMNS = [
{ key: "inspection_code", label: "검사코드" },
{ key: "inspection_type", label: "검사유형" },
{ key: "inspection_criteria", label: "검사기준" },
{ key: "criteria_detail", label: "기준상세" },
{ key: "inspection_item", label: "검사항목" },
{ key: "inspection_method", label: "검사방법" },
{ key: "judgment_criteria", label: "판단기준" },
@@ -43,6 +43,7 @@ type InspectionRow = {
inspection_detail: string;
inspection_method: string;
apply_process: string;
classification: string;
acceptance_criteria: string;
is_required: boolean;
judgment_criteria?: string; // 판단기준 라벨 (수치(범위)/텍스트입력/O·X/선택형)
@@ -253,6 +254,11 @@ export default function ItemInspectionInfoPage() {
loadProcessOptions(item.code);
};
// 복사 모달: 편집 가능한 기준 데이터 상태 (등록/수정 폼과 평행 구조)
const [copyForm, setCopyForm] = useState<Record<string, any>>({});
const [copyInspectionRows, setCopyInspectionRows] = useState<Record<string, InspectionRow[]>>({});
const [copyCollapsedTypes, setCopyCollapsedTypes] = useState<Record<string, boolean>>({});
/* ═══════════════════ 복사 모달 (기준 품목 검사정보 → 다른 품목들) ═══════════════════ */
const [copyModalOpen, setCopyModalOpen] = useState(false);
const [copySearchKeyword, setCopySearchKeyword] = useState("");
@@ -294,11 +300,63 @@ export default function ItemInspectionInfoPage() {
setCopyTotal(resData?.total || resData?.totalCount || rows.length);
} catch { /* skip */ } finally { setCopySearchLoading(false); }
};
const openCopyModal = () => {
const openCopyModal = async () => {
if (!selectedItemCode) { toast.error("복사 기준 품목을 먼저 선택해주세요"); return; }
const srcGroup = groupedData.find(g => g.item_code === selectedItemCode);
if (!srcGroup || srcGroup.rows.length === 0) { toast.error("선택한 품목에 복사할 검사정보가 없어요"); return; }
setCopySearchKeyword(""); setCopyPage(1); setCopyCheckedIds([]);
// 기준 품목 데이터를 편집용 상태로 복제 (openEdit과 동일한 변환 로직)
const baseRow = srcGroup.rows[0];
try {
const res = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, {
page: 1, size: 0,
dataFilter: { enabled: true, filters: [{ columnName: "item_code", operator: "equals", value: selectedItemCode }] },
autoFilter: true,
});
const allRows = res.data?.data?.data || res.data?.data?.rows || [];
const rowMap: Record<string, InspectionRow[]> = {};
const typeFlags: Record<string, boolean> = {};
for (const r of allRows) {
const inspType = r.inspection_type || "";
const matched = INSPECTION_TYPES.find(t =>
t.matchLabels.some(ml => inspType.includes(ml)) ||
inspTypeCatOptions.some(cat => inspType.includes(cat.code) && t.matchLabels.some(ml => cat.label.includes(ml)))
);
const typeKey = matched?.key || "";
if (!typeKey) continue;
typeFlags[typeKey] = true;
if (!rowMap[typeKey]) rowMap[typeKey] = [];
const mCode = r.inspection_method || "";
const mLabel = inspMethodCatOptions.find(o => o.code === mCode)?.label || mCode;
const inspOpt = inspOptions.find(o => o.code === r.inspection_standard_id);
const jcCode = inspOpt?.judgment_criteria || "";
const jcLabel = judgmentCatOptions.find(c => c.code === jcCode)?.label || jcCode;
const unitCode = inspOpt?.unit || "";
const unitLabel = inspUnitCatOptions.find(c => c.code === unitCode)?.label || unitCode;
rowMap[typeKey].push({
id: crypto.randomUUID(), // 복사본은 새 id 부여 (원본과 분리)
inspection_standard_id: r.inspection_standard_id || "",
inspection_detail: r.inspection_item_name || r.inspection_standard || "",
inspection_method: mLabel,
apply_process: r.apply_process || "",
classification: r.classification || "",
acceptance_criteria: r.pass_criteria || "",
is_required: r.is_required === "true" || r.is_required === true,
judgment_criteria: jcLabel,
selection_options: inspOpt?.selection_options || "",
unit: unitLabel,
});
}
setCopyInspectionRows(rowMap);
setCopyForm({ ...baseRow, ...typeFlags });
setCopyCollapsedTypes({});
} catch {
setCopyInspectionRows({});
setCopyForm({ ...baseRow });
setCopyCollapsedTypes({});
}
setCopyModalOpen(true);
searchCopyTargets(1);
};
@@ -309,10 +367,18 @@ export default function ItemInspectionInfoPage() {
const handleCopy = async () => {
if (!selectedItemCode) { toast.error("복사 기준 품목이 없어요"); return; }
if (copyCheckedIds.length === 0) { toast.error("붙여넣을 품목을 선택해주세요"); return; }
const sourceGroup = groupedData.find(g => g.item_code === selectedItemCode);
if (!sourceGroup || sourceGroup.rows.length === 0) { toast.error("복사할 검사정보가 없어요"); return; }
// 편집된 rows를 평탄화 (선택된 검사유형의 rows만)
const enabledTypes = INSPECTION_TYPES.filter(t => !!copyForm[t.key]);
const flatRows: Array<{ row: InspectionRow; typeLabel: string }> = [];
for (const t of enabledTypes) {
const rows = copyInspectionRows[t.key] || [];
for (const r of rows) flatRows.push({ row: r, typeLabel: t.label });
}
if (flatRows.length === 0) { toast.error("복사할 검사항목이 없어요"); return; }
const ok = await confirm(
`선택한 ${copyCheckedIds.length}개 품목에 검사정보를 복사할까요?`,
`선택한 ${copyCheckedIds.length}개 품목에 편집된 검사정보(${flatRows.length}개 행)를 복사할까요?`,
{ description: "대상 품목의 기존 검사정보는 삭제 후 교체됩니다.", variant: "info", confirmText: "복사" }
);
if (!ok) return;
@@ -333,13 +399,26 @@ export default function ItemInspectionInfoPage() {
if (existing.length > 0) {
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, { data: existing.map((r: any) => ({ id: r.id })) });
}
for (const r of sourceGroup.rows) {
const { id: _id, created_at: _c, updated_at: _u, ...rest } = r;
let orderSeq = 0;
for (const { row: r, typeLabel } of flatRows) {
orderSeq += 1;
await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, {
...rest,
id: crypto.randomUUID(),
item_code: targetCode,
item_name: targetName,
inspection_type: typeLabel,
inspection_standard_id: r.inspection_standard_id || "",
inspection_item_name: r.inspection_detail || "",
inspection_method: r.inspection_method || "",
apply_process: r.apply_process || "",
classification: r.classification || "",
pass_criteria: r.acceptance_criteria || "",
is_required: r.is_required ? "true" : "false",
is_active: copyForm.is_active || "사용",
manager: copyForm.manager || "",
manager_id: copyForm.manager_id || "",
memo: copyForm.remarks || "",
sort_order: String(orderSeq).padStart(4, "0"),
});
}
setCopyProgress({ current: i + 1, total: copyCheckedIds.length });
@@ -402,7 +481,13 @@ export default function ItemInspectionInfoPage() {
// 선택된 탭의 검사항목 행
const selectedTabRows = useMemo(() => {
if (!selectedGroup || !selectedTypeTab) return [];
return selectedGroup.rows.filter((r: any) => r.inspection_type === selectedTypeTab && r.inspection_standard_id);
const filtered = selectedGroup.rows.filter((r: any) => r.inspection_type === selectedTypeTab && r.inspection_standard_id);
return [...filtered].sort((a: any, b: any) => {
const av = parseInt(String(a.sort_order || "9999"), 10);
const bv = parseInt(String(b.sort_order || "9999"), 10);
if (av === bv) return String(a.id).localeCompare(String(b.id));
return av - bv;
});
}, [selectedGroup, selectedTypeTab]);
// 검사기준 ID → 라벨
@@ -436,6 +521,13 @@ export default function ItemInspectionInfoPage() {
autoFilter: true,
});
const allRows = res.data?.data?.data || res.data?.data?.rows || [];
// sort_order 기준 오름차순 정렬 (varchar이므로 숫자 파싱 후 비교)
allRows.sort((a: any, b: any) => {
const av = parseInt(String(a.sort_order || "9999"), 10);
const bv = parseInt(String(b.sort_order || "9999"), 10);
if (av === bv) return String(a.id).localeCompare(String(b.id));
return av - bv;
});
const rowMap: Record<string, InspectionRow[]> = {};
const typeFlags: Record<string, boolean> = {};
@@ -462,7 +554,8 @@ export default function ItemInspectionInfoPage() {
inspection_standard_id: r.inspection_standard_id || "",
inspection_detail: r.inspection_item_name || r.inspection_standard || "",
inspection_method: mLabel,
apply_process: "",
apply_process: r.apply_process || "",
classification: r.classification || "",
acceptance_criteria: r.pass_criteria || "",
is_required: r.is_required === "true" || r.is_required === true,
judgment_criteria: jcLabel,
@@ -480,7 +573,7 @@ export default function ItemInspectionInfoPage() {
const addInspRow = (typeKey: string) => {
setInspectionRows(prev => ({
...prev,
[typeKey]: [...(prev[typeKey] || []), { id: crypto.randomUUID(), inspection_standard_id: "", inspection_detail: "", inspection_method: "", apply_process: "", acceptance_criteria: "", is_required: false }],
[typeKey]: [...(prev[typeKey] || []), { id: crypto.randomUUID(), inspection_standard_id: "", inspection_detail: "", inspection_method: "", apply_process: "", classification: "", acceptance_criteria: "", is_required: false }],
}));
};
const removeInspRow = (typeKey: string, rowId: string) => {
@@ -525,6 +618,46 @@ export default function ItemInspectionInfoPage() {
};
const toggleCollapse = (typeKey: string) => { setCollapsedTypes(prev => ({ ...prev, [typeKey]: !prev[typeKey] })); };
/* ═══════════════════ 복사 모달용 검사항목 행 관리 (등록 폼과 평행) ═══════════════════ */
const addCopyInspRow = (typeKey: string) => {
setCopyInspectionRows(prev => ({
...prev,
[typeKey]: [...(prev[typeKey] || []), { id: crypto.randomUUID(), inspection_standard_id: "", inspection_detail: "", inspection_method: "", apply_process: "", classification: "", acceptance_criteria: "", is_required: false }],
}));
};
const removeCopyInspRow = (typeKey: string, rowId: string) => {
setCopyInspectionRows(prev => ({ ...prev, [typeKey]: (prev[typeKey] || []).filter(r => r.id !== rowId) }));
};
const updateCopyInspRow = (typeKey: string, rowId: string, field: string, value: any) => {
setCopyInspectionRows(prev => ({
...prev,
[typeKey]: (prev[typeKey] || []).map(r => {
if (r.id !== rowId) return r;
if (field === "inspection_standard_id") {
const opt = inspOptions.find(o => o.code === value);
const methodCode = opt?.method || "";
const methodLabel = inspMethodCatOptions.find(o => o.code === methodCode)?.label || methodCode;
const jcCode = opt?.judgment_criteria || "";
const jcLabel = judgmentCatOptions.find(c => c.code === jcCode)?.label || jcCode;
const unitCode = opt?.unit || "";
const unitLabel = inspUnitCatOptions.find(c => c.code === unitCode)?.label || unitCode;
return {
...r,
inspection_standard_id: value,
inspection_detail: opt?.detail || "",
inspection_method: methodLabel,
judgment_criteria: jcLabel,
selection_options: opt?.selection_options || "",
unit: unitLabel,
acceptance_criteria: "",
};
}
return { ...r, [field]: value };
}),
}));
};
const toggleCopyCollapse = (typeKey: string) => { setCopyCollapsedTypes(prev => ({ ...prev, [typeKey]: !prev[typeKey] })); };
const handleSave = async () => {
if (!form.item_code) { toast.error("품목코드는 필수예요"); return; }
setSaving(true);
@@ -542,18 +675,23 @@ export default function ItemInspectionInfoPage() {
}
const enabledTypes = INSPECTION_TYPES.filter(t => !!form[t.key]);
const rows: any[] = [];
let globalOrder = 0;
for (const t of enabledTypes) {
const typeRows = inspectionRows[t.key] || [];
if (typeRows.length === 0) {
rows.push({ id: crypto.randomUUID(), item_code: form.item_code, item_name: form.item_name, inspection_type: t.label, is_active: form.is_active || "사용", manager_id: form.manager_id || "", memo: form.remarks || "" });
globalOrder += 1;
rows.push({ id: crypto.randomUUID(), item_code: form.item_code, item_name: form.item_name, inspection_type: t.label, is_active: form.is_active || "사용", manager_id: form.manager_id || "", memo: form.remarks || "", sort_order: String(globalOrder).padStart(4, "0") });
} else {
for (const r of typeRows) {
globalOrder += 1;
rows.push({
id: crypto.randomUUID(), item_code: form.item_code, item_name: form.item_name, inspection_type: t.label,
inspection_standard_id: r.inspection_standard_id || "", inspection_item_name: r.inspection_detail || "",
inspection_method: r.inspection_method || "", pass_criteria: r.acceptance_criteria || "",
apply_process: r.apply_process || "", classification: r.classification || "",
is_required: r.is_required ? "true" : "false", is_active: form.is_active || "사용",
manager_id: form.manager_id || "", memo: form.remarks || "",
sort_order: String(globalOrder).padStart(4, "0"),
});
}
}
@@ -974,6 +1112,7 @@ export default function ItemInspectionInfoPage() {
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8 w-[50px]"></TableHead>
@@ -983,7 +1122,7 @@ export default function ItemInspectionInfoPage() {
<TableBody>
{selectedTabRows.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="text-center py-8 text-xs text-muted-foreground"> </TableCell>
<TableCell colSpan={9} className="text-center py-8 text-xs text-muted-foreground"> </TableCell>
</TableRow>
) : selectedTabRows.map((row: any) => (
<TableRow key={row.id}>
@@ -1002,6 +1141,7 @@ export default function ItemInspectionInfoPage() {
const proc = processOptions.find(p => p.code === code);
return proc?.name || code;
})()}</TableCell>
<TableCell className="text-xs py-2">{row.classification || "-"}</TableCell>
<TableCell className="text-xs py-2">
{(() => {
const insp = inspOptions.find(o => o.code === row.inspection_standard_id);
@@ -1185,6 +1325,7 @@ export default function ItemInspectionInfoPage() {
<TableHead className="text-[10px] font-bold w-[130px]"> </TableHead>
<TableHead className="text-[10px] font-bold w-[90px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[90px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[100px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[80px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[200px]"> ()</TableHead>
<TableHead className="text-[10px] font-bold w-[40px]"></TableHead>
@@ -1194,7 +1335,7 @@ export default function ItemInspectionInfoPage() {
</TableHeader>
<TableBody>
{(!inspectionRows[key] || inspectionRows[key].length === 0) ? (
<TableRow><TableCell colSpan={9} className="text-center py-4 text-xs text-muted-foreground"> </TableCell></TableRow>
<TableRow><TableCell colSpan={10} className="text-center py-4 text-xs text-muted-foreground"> </TableCell></TableRow>
) : inspectionRows[key].map((row) => (
<TableRow key={row.id}>
<TableCell className="p-1">
@@ -1219,6 +1360,9 @@ export default function ItemInspectionInfoPage() {
<Input className="h-8 text-xs" value={row.apply_process} onChange={(e) => updateInspRow(key, row.id, "apply_process", e.target.value)} placeholder="공정" />
)}
</TableCell>
<TableCell className="p-1">
<Input className="h-8 text-xs" value={row.classification || ""} onChange={(e) => updateInspRow(key, row.id, "classification", e.target.value)} placeholder="구분 입력" />
</TableCell>
<TableCell className="p-1 text-center">
{row.judgment_criteria ? <Badge variant="outline" className="text-[10px]">{row.judgment_criteria}</Badge> : <span className="text-[10px] text-muted-foreground">-</span>}
</TableCell>
@@ -1285,20 +1429,20 @@ export default function ItemInspectionInfoPage() {
</DialogContent>
</Dialog>
{/* ═══════════════════ 복사 모달 ═══════════════════ */}
{/* ═══════════════════ 복사 모달 (2단 분할: 좌 대상 / 우 편집) ═══════════════════ */}
<Dialog open={copyModalOpen} onOpenChange={(v) => { if (!copying) setCopyModalOpen(v); }}>
<DialogContent
className="max-w-3xl w-[95vw] max-h-[85vh] flex flex-col overflow-hidden"
className="max-w-[95vw] sm:max-w-[1400px] w-[95vw] max-h-[90vh] flex flex-col overflow-hidden"
onPointerDownOutside={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => { if (copying) e.preventDefault(); }}
>
<DialogHeader>
<DialogHeader className="shrink-0">
<DialogTitle>{copying ? "검사정보 복사 중..." : "검사정보 복사"}</DialogTitle>
<DialogDescription>
<span className="font-medium text-foreground">{selectedGroup?.item_name || "-"}</span>
<span className="text-muted-foreground"> ({selectedItemCode})</span>
<span>{copying ? " 의 검사정보를 복사하고 있습니다" : " 의 검사정보를 아래 선택한 품목들에 복사합니다"}</span>
<span>{copying ? " 의 검사정보를 복사하고 있습니다" : " 의 검사정보를 편집해서 선택한 품목들에 복사합니다. 기준 품목은 변경되지 않아요"}</span>
</DialogDescription>
</DialogHeader>
{copying ? (
@@ -1322,81 +1466,229 @@ export default function ItemInspectionInfoPage() {
</p>
</div>
</div>
) : (<>
<div className="flex gap-2 shrink-0">
<Input className="h-9 flex-1" placeholder="품목코드 또는 품목명" value={copySearchKeyword}
onChange={(e) => setCopySearchKeyword(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") handleCopySearch(); }} />
<Button size="sm" className="h-9" onClick={handleCopySearch} disabled={copySearchLoading}>
{copySearchLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <><Search className="w-4 h-4 mr-1" /></>}
</Button>
</div>
<div className="flex-1 border rounded-lg overflow-auto">
<Table>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-[40px] text-center">
<Checkbox
checked={copyFilteredItems.length > 0 && copyFilteredItems.every(i => copyCheckedIds.includes(i.code))}
onCheckedChange={(v) => {
if (v) setCopyCheckedIds(prev => Array.from(new Set([...prev, ...copyFilteredItems.map(i => i.code)])));
else setCopyCheckedIds(prev => prev.filter(c => !copyFilteredItems.some(i => i.code === c)));
}}
/>
</TableHead>
<TableHead className="text-[11px] font-bold w-[140px]"></TableHead>
<TableHead className="text-[11px] font-bold"></TableHead>
<TableHead className="text-[11px] font-bold w-[100px]"></TableHead>
<TableHead className="text-[11px] font-bold w-[80px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{copyFilteredItems.length === 0 ? (
<TableRow><TableCell colSpan={5} className="text-center py-8 text-muted-foreground text-sm">
{copySearchLoading ? "검색 중..." : "검색 결과가 없어요"}
</TableCell></TableRow>
) : copyFilteredItems.map((item) => (
<TableRow key={item.code} className="cursor-pointer hover:bg-primary/5"
onClick={() => toggleCopyChecked(item.code)}>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<Checkbox checked={copyCheckedIds.includes(item.code)} onCheckedChange={() => toggleCopyChecked(item.code)} />
</TableCell>
<TableCell className="text-sm font-mono">{item.code}</TableCell>
<TableCell className="text-sm">{item.name}</TableCell>
<TableCell className="text-sm">{item.item_type}</TableCell>
<TableCell className="text-sm">{item.unit}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="shrink-0 flex items-center justify-between border-t px-2 py-2 text-xs text-muted-foreground">
<span>
<span className="font-medium text-foreground">{copyTotal.toLocaleString()}</span>
{copyCheckedIds.length > 0 && <span className="ml-2"> <span className="font-medium text-primary">{copyCheckedIds.length}</span></span>}
</span>
<div className="flex items-center gap-0.5">
<button onClick={() => { setCopyPage(1); searchCopyTargets(1); }} disabled={copyPage <= 1}
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronsLeft className="h-3.5 w-3.5" /></button>
<button onClick={() => { const p = copyPage - 1; setCopyPage(p); searchCopyTargets(p); }} disabled={copyPage <= 1}
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronLeft className="h-3.5 w-3.5" /></button>
{Array.from({ length: Math.min(5, copyTotalPages) }, (_, i) => {
const start = Math.max(1, Math.min(copyPage - 2, copyTotalPages - 4));
const p = start + i;
if (p > copyTotalPages) return null;
return (
<button key={p} onClick={() => { setCopyPage(p); searchCopyTargets(p); }}
className={cn("h-7 w-7 flex items-center justify-center rounded text-xs",
p === copyPage ? "bg-primary text-primary-foreground font-medium" : "hover:bg-muted")}>{p}</button>
);
})}
<button onClick={() => { const p = copyPage + 1; setCopyPage(p); searchCopyTargets(p); }} disabled={copyPage >= copyTotalPages}
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronRight className="h-3.5 w-3.5" /></button>
<button onClick={() => { setCopyPage(copyTotalPages); searchCopyTargets(copyTotalPages); }} disabled={copyPage >= copyTotalPages}
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronsRight className="h-3.5 w-3.5" /></button>
) : (
<div className="flex-1 grid grid-cols-[420px_1fr] gap-4 overflow-hidden">
{/* 좌측: 복사 대상 품목 선택 */}
<div className="flex flex-col overflow-hidden border rounded-lg">
<div className="flex items-center justify-between border-b bg-muted/50 px-3 py-2">
<span className="text-xs font-semibold"> </span>
{copyCheckedIds.length > 0 && <span className="text-[10px] text-primary"> {copyCheckedIds.length}</span>}
</div>
<div className="flex gap-2 px-2 pt-2">
<Input className="h-8 flex-1 text-xs" placeholder="품목코드 또는 품목명" value={copySearchKeyword}
onChange={(e) => setCopySearchKeyword(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") handleCopySearch(); }} />
<Button size="sm" className="h-8 text-xs" onClick={handleCopySearch} disabled={copySearchLoading}>
{copySearchLoading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Search className="w-3.5 h-3.5" />}
</Button>
</div>
<div className="flex-1 overflow-auto mt-2">
<Table>
<TableHeader className="sticky top-0 z-10 bg-background">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-[36px] text-center text-[10px]">
<Checkbox
checked={copyFilteredItems.length > 0 && copyFilteredItems.every(i => copyCheckedIds.includes(i.code))}
onCheckedChange={(v) => {
if (v) setCopyCheckedIds(prev => Array.from(new Set([...prev, ...copyFilteredItems.map(i => i.code)])));
else setCopyCheckedIds(prev => prev.filter(c => !copyFilteredItems.some(i => i.code === c)));
}}
/>
</TableHead>
<TableHead className="text-[10px] font-bold w-[120px]"></TableHead>
<TableHead className="text-[10px] font-bold"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{copyFilteredItems.length === 0 ? (
<TableRow><TableCell colSpan={3} className="text-center py-6 text-muted-foreground text-xs">
{copySearchLoading ? "검색 중..." : "검색 결과가 없어요"}
</TableCell></TableRow>
) : copyFilteredItems.map((item) => (
<TableRow key={item.code} className="cursor-pointer hover:bg-primary/5"
onClick={() => toggleCopyChecked(item.code)}>
<TableCell className="text-center p-1" onClick={(e) => e.stopPropagation()}>
<Checkbox checked={copyCheckedIds.includes(item.code)} onCheckedChange={() => toggleCopyChecked(item.code)} />
</TableCell>
<TableCell className="text-xs font-mono p-1">{item.code}</TableCell>
<TableCell className="text-xs p-1 truncate">{item.name}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="border-t flex items-center justify-between px-2 py-1 text-[10px] text-muted-foreground">
<span> <span className="font-medium text-foreground">{copyTotal.toLocaleString()}</span></span>
<div className="flex items-center gap-0.5">
<button onClick={() => { setCopyPage(1); searchCopyTargets(1); }} disabled={copyPage <= 1}
className="h-6 w-6 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronsLeft className="h-3 w-3" /></button>
<button onClick={() => { const p = copyPage - 1; setCopyPage(p); searchCopyTargets(p); }} disabled={copyPage <= 1}
className="h-6 w-6 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronLeft className="h-3 w-3" /></button>
<span className="text-[10px] mx-1">{copyPage}/{copyTotalPages}</span>
<button onClick={() => { const p = copyPage + 1; setCopyPage(p); searchCopyTargets(p); }} disabled={copyPage >= copyTotalPages}
className="h-6 w-6 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronRight className="h-3 w-3" /></button>
<button onClick={() => { setCopyPage(copyTotalPages); searchCopyTargets(copyTotalPages); }} disabled={copyPage >= copyTotalPages}
className="h-6 w-6 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronsRight className="h-3 w-3" /></button>
</div>
</div>
</div>
{/* 우측: 편집 폼 (등록/수정 폼과 동일 구조) */}
<div className="flex flex-col overflow-hidden border rounded-lg">
<div className="border-b bg-muted/50 px-3 py-2">
<span className="text-xs font-semibold"> (: {selectedItemCode})</span>
</div>
<div className="flex-1 overflow-y-auto p-3 space-y-4">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-muted-foreground"></Label>
<Select value={copyForm.is_active === false || copyForm.is_active === "N" ? "N" : "Y"} onValueChange={(v) => setCopyForm(p => ({ ...p, is_active: v === "Y" ? "사용" : "미사용" }))}>
<SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="Y"></SelectItem>
<SelectItem value="N"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-muted-foreground"></Label>
<Select value={copyForm.manager || ""} onValueChange={(v) => setCopyForm(p => ({ ...p, manager: v }))}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="관리자 선택" /></SelectTrigger>
<SelectContent>{userOptions.map(o => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<h4 className="text-xs font-semibold"> </h4>
<div className="flex flex-wrap gap-3">
{INSPECTION_TYPES.map(({ key, label }) => (
<div key={key} className="flex items-center gap-1.5">
<Checkbox checked={!!copyForm[key]} onCheckedChange={(v) => setCopyForm(p => ({ ...p, [key]: !!v }))} />
<Label className="text-xs cursor-pointer">{label}</Label>
</div>
))}
</div>
</div>
{INSPECTION_TYPES.filter(t => !!copyForm[t.key]).map(({ key, label }) => (
<div key={key} className="space-y-1.5">
<button type="button" className="w-full flex items-center gap-2 py-1.5 px-2 rounded-md border bg-muted/50 hover:bg-muted text-left" onClick={() => toggleCopyCollapse(key)}>
<Badge variant="default" className="text-[10px]">{label}</Badge>
<span className="text-xs font-medium"> </span>
<span className="text-[10px] text-muted-foreground ml-auto">{(copyInspectionRows[key] || []).length}</span>
</button>
{!copyCollapsedTypes[key] && (
<div className="space-y-1.5 pl-1">
<div className="flex items-center justify-between">
<span className="text-[10px] font-semibold text-muted-foreground"> </span>
<Button type="button" size="sm" variant="outline" className="h-6 text-[10px]" onClick={() => addCopyInspRow(key)}>
<Plus className="w-3 h-3 mr-1" />
</Button>
</div>
<div className="border rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow className="bg-muted/50 hover:bg-muted/50">
<TableHead className="text-[10px] font-bold w-[150px]"> </TableHead>
<TableHead className="text-[10px] font-bold w-[110px]"> </TableHead>
<TableHead className="text-[10px] font-bold w-[80px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[80px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[90px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[70px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[180px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[36px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[60px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[32px]" />
</TableRow>
</TableHeader>
<TableBody>
{(!copyInspectionRows[key] || copyInspectionRows[key].length === 0) ? (
<TableRow><TableCell colSpan={10} className="text-center py-3 text-[10px] text-muted-foreground"> </TableCell></TableRow>
) : copyInspectionRows[key].map((row) => (
<TableRow key={row.id}>
<TableCell className="p-1">
<Select value={row.inspection_standard_id || ""} onValueChange={(v) => updateCopyInspRow(key, row.id, "inspection_standard_id", v)}>
<SelectTrigger className="h-7 text-[10px]"><SelectValue placeholder="검사기준" /></SelectTrigger>
<SelectContent>{getFilteredInspOptions(key).map(o => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}</SelectContent>
</Select>
</TableCell>
<TableCell className="p-1"><Input className="h-7 text-[10px] bg-muted" value={row.inspection_detail} readOnly placeholder="자동" /></TableCell>
<TableCell className="p-1"><Input className="h-7 text-[10px] bg-muted" value={row.inspection_method} readOnly placeholder="자동" /></TableCell>
<TableCell className="p-1">
{processOptions.length > 0 ? (
<Select value={row.apply_process || ""} onValueChange={(v) => updateCopyInspRow(key, row.id, "apply_process", v)}>
<SelectTrigger className="h-7 text-[10px]"><SelectValue placeholder="공정" /></SelectTrigger>
<SelectContent>
{processOptions.map((p) => (
<SelectItem key={p.code} value={p.code} className="text-xs">{p.name}</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input className="h-7 text-[10px]" value={row.apply_process} onChange={(e) => updateCopyInspRow(key, row.id, "apply_process", e.target.value)} placeholder="공정" />
)}
</TableCell>
<TableCell className="p-1">
<Input className="h-7 text-[10px]" value={row.classification || ""} onChange={(e) => updateCopyInspRow(key, row.id, "classification", e.target.value)} placeholder="구분" />
</TableCell>
<TableCell className="p-1 text-center">
{row.judgment_criteria ? <Badge variant="outline" className="text-[9px]">{row.judgment_criteria}</Badge> : <span className="text-[9px] text-muted-foreground">-</span>}
</TableCell>
<TableCell className="p-1">
{row.judgment_criteria === "선택형" && row.selection_options ? (
<Select value={row.acceptance_criteria || ""} onValueChange={(v) => updateCopyInspRow(key, row.id, "acceptance_criteria", v)} disabled={!row.inspection_standard_id}>
<SelectTrigger className="h-7 text-[10px]"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{row.selection_options.split(",").filter(Boolean).map((opt) => (
<SelectItem key={opt} value={opt} className="text-xs">{opt}</SelectItem>
))}
</SelectContent>
</Select>
) : row.judgment_criteria === "O/X" ? (
<Select value={row.acceptance_criteria || ""} onValueChange={(v) => updateCopyInspRow(key, row.id, "acceptance_criteria", v)} disabled={!row.inspection_standard_id}>
<SelectTrigger className="h-7 text-[10px]"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
<SelectItem value="O" className="text-xs">O ()</SelectItem>
<SelectItem value="X" className="text-xs">X ()</SelectItem>
</SelectContent>
</Select>
) : row.judgment_criteria === "수치(범위)" ? (
<div className="flex items-center gap-1">
<Input className="h-7 text-[10px] w-14" value={row.acceptance_criteria?.split("|")[0] || ""} onChange={(e) => {
const parts = (row.acceptance_criteria || "||").split("|");
parts[0] = e.target.value;
updateCopyInspRow(key, row.id, "acceptance_criteria", parts.join("|"));
}} placeholder="기준" disabled={!row.inspection_standard_id} />
<span className="text-[9px] text-muted-foreground">±</span>
<Input className="h-7 text-[10px] w-10" value={row.acceptance_criteria?.split("|")[1] || ""} onChange={(e) => {
const parts = (row.acceptance_criteria || "||").split("|");
parts[1] = e.target.value;
updateCopyInspRow(key, row.id, "acceptance_criteria", parts.join("|"));
}} placeholder="±" disabled={!row.inspection_standard_id} />
</div>
) : (
<Input className="h-7 text-[10px]" value={row.acceptance_criteria} onChange={(e) => updateCopyInspRow(key, row.id, "acceptance_criteria", e.target.value)} placeholder="합격기준" disabled={!row.inspection_standard_id} />
)}
</TableCell>
<TableCell className="p-1 text-center"><Checkbox checked={row.is_required} onCheckedChange={(v) => updateCopyInspRow(key, row.id, "is_required", !!v)} /></TableCell>
<TableCell className="p-1 text-[10px] text-muted-foreground">{row.unit || "-"}</TableCell>
<TableCell className="p-1">
<Button type="button" variant="destructive" size="sm" className="h-6 w-6 p-0" onClick={() => removeCopyInspRow(key, row.id)}><Trash2 className="w-3 h-3" /></Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)}
</div>
))}
</div>
</div>
</div>
</div>
</>)}
)}
<DialogFooter className="shrink-0">
<Button variant="outline" onClick={() => setCopyModalOpen(false)} disabled={copying}></Button>
<Button onClick={handleCopy} disabled={copying || copyCheckedIds.length === 0}>
@@ -358,13 +358,15 @@ export default function LogisticsInfoPage() {
loadReferences();
}, [loadReferences]);
// 카테고리 옵션 로드
// 카테고리 옵션 로드 (관리자 계정일 때 filterCompanyCode 미제공 시 "*" 스코프로 빈 결과 반환됨)
const loadCategoryOptions = useCallback(async (tableColumn: string) => {
if (loadedCategories.current.has(tableColumn)) return;
loadedCategories.current.add(tableColumn);
const [tableName, columnName] = tableColumn.split(":");
try {
const res = await apiClient.get(`/table-categories/${tableName}/${columnName}/values`);
const res = await apiClient.get(
`/table-categories/${tableName}/${columnName}/values?filterCompanyCode=COMPANY_30`
);
const data = res.data?.data || [];
setCategoryOptions((prev) => ({
...prev,
@@ -823,13 +825,24 @@ export default function LogisticsInfoPage() {
{/* 테이블 영역 */}
<div className="flex-1 overflow-auto">
<EDataTable
columns={getVisibleColumns(tab.key).map((col): EDataTableColumn => ({
key: col.key,
label: col.label,
align: col.align,
formatNumber: col.formatNumber,
truncate: true,
}))}
columns={getVisibleColumns(tab.key).map((col): EDataTableColumn => {
// 같은 key의 formField에 categoryKey가 있으면 코드→라벨 변환
const formField = tab.formFields.find((f) => f.key === col.key && f.categoryKey);
return {
key: col.key,
label: col.label,
align: col.align,
formatNumber: col.formatNumber,
truncate: true,
render: formField?.categoryKey
? (value: any) => {
const opts = categoryOptions[formField.categoryKey!] || [];
const matched = opts.find((o: any) => o.value === value);
return matched?.label || value || "-";
}
: undefined,
};
})}
data={tsMap[tab.key].groupData(displayData)}
rowKey={(row: any) => String(row.id)}
loading={tabLoading[tab.key]}
@@ -189,12 +189,12 @@ export default function InventoryStatusPage() {
};
load();
// 사용자 목록 로드
apiClient.get("/admin/users", { params: { size: 9999 } }).then((res) => {
const users = res.data?.data || res.data || [];
apiClient.get("/admin/users/name-map").then((res) => {
const users = res.data?.data || [];
const map: Record<string, string> = {};
for (const u of users) {
const id = u.userId || u.user_id || u.id;
const name = u.user_name || u.name || id;
const id = u.user_id;
const name = u.user_name || id;
if (id) map[id] = name;
}
setUserMap(map);
@@ -648,7 +648,7 @@ export default function MaterialStatusPage() {
className="inline-flex items-center gap-1 rounded bg-muted/40 px-2 py-0.5 text-xs transition-colors hover:bg-muted/60"
>
<span className="font-semibold font-mono text-primary">
{loc.location || loc.warehouse}
{loc.warehouse_name || loc.location || loc.warehouse}
</span>
<span className="font-semibold">
{loc.qty.toLocaleString()}
@@ -158,6 +158,10 @@ export default function WarehouseManagementPage() {
const [rackStatus, setRackStatus] = useState("");
const [rackPreview, setRackPreview] = useState<any[]>([]);
const [rackSaving, setRackSaving] = useState(false);
// 위치명 접미사 (자동 조립: {zone}{구역접미사}-{row}{열접미사}-{level}{단접미사})
const [rackZoneLabel, setRackZoneLabel] = useState("구역");
const [rackRowLabel, setRackRowLabel] = useState("열");
const [rackLevelLabel, setRackLevelLabel] = useState("단");
// 카테고리 옵션
const [categoryOptions, setCategoryOptions] = useState<
@@ -636,7 +640,7 @@ export default function WarehouseManagementPage() {
duplicates.push(locationCode);
continue;
}
const locationName = `${zoneCode}구역-${rowStr}열-${level}`;
const locationName = `${zoneCode}${rackZoneLabel}-${rowStr}${rackRowLabel}-${level}${rackLevelLabel}`;
items.push({
location_code: locationCode,
location_name: locationName,
@@ -1502,6 +1506,38 @@ export default function WarehouseManagementPage() {
</div>
</div>
{/* 위치명 형식 — 구역/열/단 뒤에 붙일 표현만 자유 입력 */}
<div className="rounded-lg border p-3 bg-muted/30 space-y-2">
<Label className="text-xs font-semibold"> </Label>
<div className="flex items-center gap-1 text-xs flex-wrap">
<span className="font-mono text-muted-foreground">A</span>
<Input
value={rackZoneLabel}
onChange={(e) => setRackZoneLabel(e.target.value)}
placeholder="구역"
className="h-8 w-20 text-xs"
/>
<span className="font-mono text-muted-foreground">- 01</span>
<Input
value={rackRowLabel}
onChange={(e) => setRackRowLabel(e.target.value)}
placeholder="열"
className="h-8 w-20 text-xs"
/>
<span className="font-mono text-muted-foreground">- 1</span>
<Input
value={rackLevelLabel}
onChange={(e) => setRackLevelLabel(e.target.value)}
placeholder="단"
className="h-8 w-20 text-xs"
/>
</div>
<p className="text-[11px] text-muted-foreground">
: <span className="font-mono font-semibold">A{rackZoneLabel}-01{rackRowLabel}-1{rackLevelLabel}</span>
{" "} // , .
</p>
</div>
{/* 등록 미리보기 */}
<div>
<div className="flex items-center justify-between mb-3">
@@ -59,6 +59,7 @@ import {
Settings2,
Save,
Package,
Pencil,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
@@ -359,7 +360,13 @@ export default function BomManagementPage() {
sort: { columnName: "created_at", order: "desc" },
});
const rows = res.data?.data?.data || res.data?.data?.rows || [];
// DB 컬럼이 item_type/expired_date → 프론트 내부에서는 bom_type/expiry_date로 통일
const rawRows = res.data?.data?.data || res.data?.data?.rows || [];
const rows = rawRows.map((r: any) => ({
...r,
bom_type: r.bom_type ?? r.item_type,
expiry_date: r.expiry_date ?? r.expired_date,
}));
setBomList(rows);
setTotalCount(rows.length);
} catch (err: any) {
@@ -456,9 +463,16 @@ export default function BomManagementPage() {
const fetchBomDetail = useCallback(async (bomId: string) => {
setDetailLoading(true);
try {
// 헤더 조회
// 헤더 조회 (DB 컬럼 item_type/expired_date → bom_type/expiry_date로 매핑)
const headerRes = await apiClient.get(`/bom/${bomId}/header`);
const header = headerRes.data?.data || headerRes.data;
const rawHeader = headerRes.data?.data || headerRes.data;
const header = rawHeader
? {
...rawHeader,
bom_type: rawHeader.bom_type ?? rawHeader.item_type,
expiry_date: rawHeader.expiry_date ?? rawHeader.expired_date,
}
: null;
setBomHeader(header);
setCurrentVersionId(header?.current_version_id || null);
@@ -1107,17 +1121,18 @@ export default function BomManagementPage() {
setSaving(true);
try {
// DB 실제 컬럼: item_type / expired_date (프론트 내부 bom_type/expiry_date와 다름)
const bomFields: Record<string, any> = {
item_id: masterForm.item_id,
item_code: masterForm.item_code,
item_name: masterForm.item_name,
bom_type: masterForm.bom_type,
item_type: masterForm.bom_type,
base_qty: masterForm.base_qty || "1",
unit: masterForm.unit || "",
version: masterForm.version || "1.0",
status: masterForm.status || "draft",
effective_date: masterForm.effective_date || null,
expiry_date: masterForm.expiry_date || null,
expired_date: masterForm.expiry_date || null,
remark: masterForm.remark || "",
writer: user?.userId || "",
company_code: user?.company_code || "",
@@ -1514,6 +1529,21 @@ export default function BomManagementPage() {
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
if (!selectedBomId || !bomHeader) {
toast.error("수정할 BOM을 선택해주세요");
return;
}
openEditModal();
}}
disabled={!selectedBomId || !bomHeader}
>
<Pencil className="w-3.5 h-3.5 mr-1" />
</Button>
<div className="w-px h-4 bg-border mx-0.5" />
<Button
size="sm"
@@ -12,6 +12,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
import {
@@ -91,8 +92,8 @@ export function ItemRoutingTab() {
const [formFixedOrder, setFormFixedOrder] = useState("Y");
const [formWorkType, setFormWorkType] = useState("내부");
const [formStandardTime, setFormStandardTime] = useState("");
const [formOutsource, setFormOutsource] = useState("");
const [subcontractorOptions, setSubcontractorOptions] = useState<{ code: string; name: string }[]>([]);
const [formOutsources, setFormOutsources] = useState<string[]>([]);
const [subcontractorOptions, setSubcontractorOptions] = useState<{ id: string; code: string; name: string }[]>([]);
const [detailSubmitting, setDetailSubmitting] = useState(false);
const [registerDialogOpen, setRegisterDialogOpen] = useState(false);
@@ -116,7 +117,7 @@ export function ItemRoutingTab() {
page: 1, size: 500, autoFilter: true,
});
const rows = res.data?.data?.data || res.data?.data?.rows || [];
setSubcontractorOptions(rows.map((r: any) => ({ code: r.subcontractor_code || r.id, name: r.subcontractor_name || "" })));
setSubcontractorOptions(rows.map((r: any) => ({ id: r.id, code: r.subcontractor_code || "", name: r.subcontractor_name || "" })));
} catch { /* skip */ }
})();
}, []);
@@ -281,7 +282,7 @@ export function ItemRoutingTab() {
setFormFixedOrder("Y");
setFormWorkType("내부");
setFormStandardTime("");
setFormOutsource("");
setFormOutsources([]);
setDetailDialogOpen(true);
};
@@ -308,7 +309,19 @@ export function ItemRoutingTab() {
setFormFixedOrder(row.is_fixed_order === "N" ? "N" : "Y");
setFormWorkType(row.work_type || "내부");
setFormStandardTime(row.standard_time || "");
setFormOutsource(row.outsource_supplier || "");
// 우선순위: id 배열 → legacy code 배열(id로 역변환) → legacy 단일 code(id로 역변환)
let loadedIds: string[] = [];
if (Array.isArray(row.outsource_supplier_ids) && row.outsource_supplier_ids.length > 0) {
loadedIds = row.outsource_supplier_ids;
} else {
const legacyCodes = Array.isArray(row.outsource_supplier_list) && row.outsource_supplier_list.length > 0
? row.outsource_supplier_list
: (row.outsource_supplier ? [row.outsource_supplier] : []);
loadedIds = legacyCodes
.map((c: string) => subcontractorOptions.find((s) => s.code === c)?.id)
.filter((v): v is string => Boolean(v));
}
setFormOutsources(loadedIds);
setDetailDialogOpen(true);
};
@@ -329,7 +342,10 @@ export function ItemRoutingTab() {
return;
}
const proc = processes.find((p) => p.process_code === formProcessCode);
const outsource = showOutsourceField ? formOutsource.trim() : "";
const outsourceIds = showOutsourceField ? formOutsources.filter((s) => s && s.trim() !== "") : [];
const outsourcePrimaryCode = outsourceIds.length > 0
? (subcontractorOptions.find((s) => s.id === outsourceIds[0])?.code || "")
: "";
setDetailSubmitting(true);
try {
@@ -344,7 +360,8 @@ export function ItemRoutingTab() {
is_fixed_order: formFixedOrder,
work_type: formWorkType,
standard_time: st || "0",
outsource_supplier: outsource,
outsource_supplier: outsourcePrimaryCode,
outsource_supplier_ids: outsourceIds,
};
setDetails((prev) => sortDetailsBySeq([...prev, newRow]));
toast.success("공정이 추가되었어요. 저장을 눌러 반영해주세요");
@@ -362,7 +379,8 @@ export function ItemRoutingTab() {
is_fixed_order: formFixedOrder,
work_type: formWorkType,
standard_time: st || "0",
outsource_supplier: outsource,
outsource_supplier: outsourcePrimaryCode,
outsource_supplier_ids: outsourceIds,
}
: d,
),
@@ -399,6 +417,7 @@ export function ItemRoutingTab() {
work_type: d.work_type || "내부",
standard_time: String(d.standard_time ?? "0"),
outsource_supplier: d.outsource_supplier || "",
outsource_supplier_ids: d.outsource_supplier_ids || [],
}));
setSaving(true);
@@ -480,11 +499,23 @@ export function ItemRoutingTab() {
const detailsGridData = useMemo(
() =>
details.map((d) => ({
...d,
process_display: d.process_name || d.process_code,
outsource_display: subcontractorOptions.find((s) => s.code === d.outsource_supplier)?.name || d.outsource_supplier || "—",
})),
details.map((d) => {
const ids = Array.isArray(d.outsource_supplier_ids) && d.outsource_supplier_ids.length > 0
? d.outsource_supplier_ids
: [];
let names = ids
.map((i) => subcontractorOptions.find((s) => s.id === i)?.name)
.filter((v): v is string => Boolean(v));
// 레거시 폴백: id 매핑 없을 때 단일 code로 표시
if (names.length === 0 && d.outsource_supplier) {
names = [subcontractorOptions.find((s) => s.code === d.outsource_supplier)?.name || d.outsource_supplier];
}
return {
...d,
process_display: d.process_name || d.process_code,
outsource_display: names.length === 0 ? "—" : names.join(", "),
};
}),
[details, subcontractorOptions],
);
@@ -909,15 +940,46 @@ export function ItemRoutingTab() {
</div>
{showOutsourceField && (
<div className="space-y-1">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Select value={formOutsource || ""} onValueChange={setFormOutsource}>
<SelectTrigger className="h-9"><SelectValue placeholder="외주업체 선택" /></SelectTrigger>
<SelectContent>
{subcontractorOptions.map((s) => (
<SelectItem key={s.code} value={s.code}>{s.name}</SelectItem>
))}
</SelectContent>
</Select>
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"> ( )</Label>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className="h-9 w-full justify-between font-normal">
<span className="truncate text-left text-sm">
{formOutsources.length === 0
? "외주업체 선택"
: formOutsources
.map((i) => subcontractorOptions.find((s) => s.id === i)?.name || i)
.join(", ")}
</span>
<Badge variant="secondary" className="ml-2 shrink-0">{formOutsources.length}</Badge>
</Button>
</PopoverTrigger>
<PopoverContent className="w-[320px] p-0" align="start">
<div className="max-h-64 overflow-y-auto p-2 space-y-1">
{subcontractorOptions.length === 0 ? (
<div className="text-xs text-muted-foreground px-2 py-3"> </div>
) : subcontractorOptions.map((s) => {
const checked = formOutsources.includes(s.id);
return (
<label
key={s.id}
className="flex items-center gap-2 rounded px-2 py-1.5 text-sm cursor-pointer hover:bg-muted"
>
<Checkbox
checked={checked}
onCheckedChange={(v) => {
setFormOutsources((prev) =>
v ? [...prev, s.id] : prev.filter((i) => i !== s.id),
);
}}
/>
<span className="truncate">{s.name}</span>
</label>
);
})}
</div>
</PopoverContent>
</Popover>
</div>
)}
</div>
@@ -52,6 +52,7 @@ const INSPECTION_COLUMNS = [
{ key: "inspection_code", label: "검사코드" },
{ key: "inspection_type", label: "검사유형" },
{ key: "inspection_criteria", label: "검사기준" },
{ key: "criteria_detail", label: "기준상세" },
{ key: "inspection_item", label: "검사항목" },
{ key: "inspection_method", label: "검사방법" },
{ key: "judgment_criteria", label: "판단기준" },
@@ -43,6 +43,7 @@ type InspectionRow = {
inspection_detail: string;
inspection_method: string;
apply_process: string;
classification: string;
acceptance_criteria: string;
is_required: boolean;
judgment_criteria?: string; // 판단기준 라벨 (수치(범위)/텍스트입력/O·X/선택형)
@@ -253,6 +254,11 @@ export default function ItemInspectionInfoPage() {
loadProcessOptions(item.code);
};
// 복사 모달: 편집 가능한 기준 데이터 상태 (등록/수정 폼과 평행 구조)
const [copyForm, setCopyForm] = useState<Record<string, any>>({});
const [copyInspectionRows, setCopyInspectionRows] = useState<Record<string, InspectionRow[]>>({});
const [copyCollapsedTypes, setCopyCollapsedTypes] = useState<Record<string, boolean>>({});
/* ═══════════════════ 복사 모달 (기준 품목 검사정보 → 다른 품목들) ═══════════════════ */
const [copyModalOpen, setCopyModalOpen] = useState(false);
const [copySearchKeyword, setCopySearchKeyword] = useState("");
@@ -294,11 +300,63 @@ export default function ItemInspectionInfoPage() {
setCopyTotal(resData?.total || resData?.totalCount || rows.length);
} catch { /* skip */ } finally { setCopySearchLoading(false); }
};
const openCopyModal = () => {
const openCopyModal = async () => {
if (!selectedItemCode) { toast.error("복사 기준 품목을 먼저 선택해주세요"); return; }
const srcGroup = groupedData.find(g => g.item_code === selectedItemCode);
if (!srcGroup || srcGroup.rows.length === 0) { toast.error("선택한 품목에 복사할 검사정보가 없어요"); return; }
setCopySearchKeyword(""); setCopyPage(1); setCopyCheckedIds([]);
// 기준 품목 데이터를 편집용 상태로 복제 (openEdit과 동일한 변환 로직)
const baseRow = srcGroup.rows[0];
try {
const res = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, {
page: 1, size: 0,
dataFilter: { enabled: true, filters: [{ columnName: "item_code", operator: "equals", value: selectedItemCode }] },
autoFilter: true,
});
const allRows = res.data?.data?.data || res.data?.data?.rows || [];
const rowMap: Record<string, InspectionRow[]> = {};
const typeFlags: Record<string, boolean> = {};
for (const r of allRows) {
const inspType = r.inspection_type || "";
const matched = INSPECTION_TYPES.find(t =>
t.matchLabels.some(ml => inspType.includes(ml)) ||
inspTypeCatOptions.some(cat => inspType.includes(cat.code) && t.matchLabels.some(ml => cat.label.includes(ml)))
);
const typeKey = matched?.key || "";
if (!typeKey) continue;
typeFlags[typeKey] = true;
if (!rowMap[typeKey]) rowMap[typeKey] = [];
const mCode = r.inspection_method || "";
const mLabel = inspMethodCatOptions.find(o => o.code === mCode)?.label || mCode;
const inspOpt = inspOptions.find(o => o.code === r.inspection_standard_id);
const jcCode = inspOpt?.judgment_criteria || "";
const jcLabel = judgmentCatOptions.find(c => c.code === jcCode)?.label || jcCode;
const unitCode = inspOpt?.unit || "";
const unitLabel = inspUnitCatOptions.find(c => c.code === unitCode)?.label || unitCode;
rowMap[typeKey].push({
id: crypto.randomUUID(), // 복사본은 새 id 부여 (원본과 분리)
inspection_standard_id: r.inspection_standard_id || "",
inspection_detail: r.inspection_item_name || r.inspection_standard || "",
inspection_method: mLabel,
apply_process: r.apply_process || "",
classification: r.classification || "",
acceptance_criteria: r.pass_criteria || "",
is_required: r.is_required === "true" || r.is_required === true,
judgment_criteria: jcLabel,
selection_options: inspOpt?.selection_options || "",
unit: unitLabel,
});
}
setCopyInspectionRows(rowMap);
setCopyForm({ ...baseRow, ...typeFlags });
setCopyCollapsedTypes({});
} catch {
setCopyInspectionRows({});
setCopyForm({ ...baseRow });
setCopyCollapsedTypes({});
}
setCopyModalOpen(true);
searchCopyTargets(1);
};
@@ -309,10 +367,18 @@ export default function ItemInspectionInfoPage() {
const handleCopy = async () => {
if (!selectedItemCode) { toast.error("복사 기준 품목이 없어요"); return; }
if (copyCheckedIds.length === 0) { toast.error("붙여넣을 품목을 선택해주세요"); return; }
const sourceGroup = groupedData.find(g => g.item_code === selectedItemCode);
if (!sourceGroup || sourceGroup.rows.length === 0) { toast.error("복사할 검사정보가 없어요"); return; }
// 편집된 rows를 평탄화 (선택된 검사유형의 rows만)
const enabledTypes = INSPECTION_TYPES.filter(t => !!copyForm[t.key]);
const flatRows: Array<{ row: InspectionRow; typeLabel: string }> = [];
for (const t of enabledTypes) {
const rows = copyInspectionRows[t.key] || [];
for (const r of rows) flatRows.push({ row: r, typeLabel: t.label });
}
if (flatRows.length === 0) { toast.error("복사할 검사항목이 없어요"); return; }
const ok = await confirm(
`선택한 ${copyCheckedIds.length}개 품목에 검사정보를 복사할까요?`,
`선택한 ${copyCheckedIds.length}개 품목에 편집된 검사정보(${flatRows.length}개 행)를 복사할까요?`,
{ description: "대상 품목의 기존 검사정보는 삭제 후 교체됩니다.", variant: "info", confirmText: "복사" }
);
if (!ok) return;
@@ -325,7 +391,7 @@ export default function ItemInspectionInfoPage() {
const target = copyFilteredItems.find(o => o.code === targetCode) || itemOptions.find(o => o.code === targetCode);
const targetName = target?.name || "";
const existRes = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: { enabled: true, filters: [{ columnName: "item_code", operator: "equals", value: targetCode }] },
autoFilter: true,
});
@@ -333,13 +399,26 @@ export default function ItemInspectionInfoPage() {
if (existing.length > 0) {
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, { data: existing.map((r: any) => ({ id: r.id })) });
}
for (const r of sourceGroup.rows) {
const { id: _id, created_at: _c, updated_at: _u, ...rest } = r;
let orderSeq = 0;
for (const { row: r, typeLabel } of flatRows) {
orderSeq += 1;
await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, {
...rest,
id: crypto.randomUUID(),
item_code: targetCode,
item_name: targetName,
inspection_type: typeLabel,
inspection_standard_id: r.inspection_standard_id || "",
inspection_item_name: r.inspection_detail || "",
inspection_method: r.inspection_method || "",
apply_process: r.apply_process || "",
classification: r.classification || "",
pass_criteria: r.acceptance_criteria || "",
is_required: r.is_required ? "true" : "false",
is_active: copyForm.is_active || "사용",
manager: copyForm.manager || "",
manager_id: copyForm.manager_id || "",
memo: copyForm.remarks || "",
sort_order: String(orderSeq).padStart(4, "0"),
});
}
setCopyProgress({ current: i + 1, total: copyCheckedIds.length });
@@ -402,7 +481,13 @@ export default function ItemInspectionInfoPage() {
// 선택된 탭의 검사항목 행
const selectedTabRows = useMemo(() => {
if (!selectedGroup || !selectedTypeTab) return [];
return selectedGroup.rows.filter((r: any) => r.inspection_type === selectedTypeTab && r.inspection_standard_id);
const filtered = selectedGroup.rows.filter((r: any) => r.inspection_type === selectedTypeTab && r.inspection_standard_id);
return [...filtered].sort((a: any, b: any) => {
const av = parseInt(String(a.sort_order || "9999"), 10);
const bv = parseInt(String(b.sort_order || "9999"), 10);
if (av === bv) return String(a.id).localeCompare(String(b.id));
return av - bv;
});
}, [selectedGroup, selectedTypeTab]);
// 검사기준 ID → 라벨
@@ -436,6 +521,13 @@ export default function ItemInspectionInfoPage() {
autoFilter: true,
});
const allRows = res.data?.data?.data || res.data?.data?.rows || [];
// sort_order 기준 오름차순 정렬 (varchar이므로 숫자 파싱 후 비교)
allRows.sort((a: any, b: any) => {
const av = parseInt(String(a.sort_order || "9999"), 10);
const bv = parseInt(String(b.sort_order || "9999"), 10);
if (av === bv) return String(a.id).localeCompare(String(b.id));
return av - bv;
});
const rowMap: Record<string, InspectionRow[]> = {};
const typeFlags: Record<string, boolean> = {};
@@ -462,7 +554,8 @@ export default function ItemInspectionInfoPage() {
inspection_standard_id: r.inspection_standard_id || "",
inspection_detail: r.inspection_item_name || r.inspection_standard || "",
inspection_method: mLabel,
apply_process: "",
apply_process: r.apply_process || "",
classification: r.classification || "",
acceptance_criteria: r.pass_criteria || "",
is_required: r.is_required === "true" || r.is_required === true,
judgment_criteria: jcLabel,
@@ -480,7 +573,7 @@ export default function ItemInspectionInfoPage() {
const addInspRow = (typeKey: string) => {
setInspectionRows(prev => ({
...prev,
[typeKey]: [...(prev[typeKey] || []), { id: crypto.randomUUID(), inspection_standard_id: "", inspection_detail: "", inspection_method: "", apply_process: "", acceptance_criteria: "", is_required: false }],
[typeKey]: [...(prev[typeKey] || []), { id: crypto.randomUUID(), inspection_standard_id: "", inspection_detail: "", inspection_method: "", apply_process: "", classification: "", acceptance_criteria: "", is_required: false }],
}));
};
const removeInspRow = (typeKey: string, rowId: string) => {
@@ -525,6 +618,46 @@ export default function ItemInspectionInfoPage() {
};
const toggleCollapse = (typeKey: string) => { setCollapsedTypes(prev => ({ ...prev, [typeKey]: !prev[typeKey] })); };
/* ═══════════════════ 복사 모달용 검사항목 행 관리 (등록 폼과 평행) ═══════════════════ */
const addCopyInspRow = (typeKey: string) => {
setCopyInspectionRows(prev => ({
...prev,
[typeKey]: [...(prev[typeKey] || []), { id: crypto.randomUUID(), inspection_standard_id: "", inspection_detail: "", inspection_method: "", apply_process: "", classification: "", acceptance_criteria: "", is_required: false }],
}));
};
const removeCopyInspRow = (typeKey: string, rowId: string) => {
setCopyInspectionRows(prev => ({ ...prev, [typeKey]: (prev[typeKey] || []).filter(r => r.id !== rowId) }));
};
const updateCopyInspRow = (typeKey: string, rowId: string, field: string, value: any) => {
setCopyInspectionRows(prev => ({
...prev,
[typeKey]: (prev[typeKey] || []).map(r => {
if (r.id !== rowId) return r;
if (field === "inspection_standard_id") {
const opt = inspOptions.find(o => o.code === value);
const methodCode = opt?.method || "";
const methodLabel = inspMethodCatOptions.find(o => o.code === methodCode)?.label || methodCode;
const jcCode = opt?.judgment_criteria || "";
const jcLabel = judgmentCatOptions.find(c => c.code === jcCode)?.label || jcCode;
const unitCode = opt?.unit || "";
const unitLabel = inspUnitCatOptions.find(c => c.code === unitCode)?.label || unitCode;
return {
...r,
inspection_standard_id: value,
inspection_detail: opt?.detail || "",
inspection_method: methodLabel,
judgment_criteria: jcLabel,
selection_options: opt?.selection_options || "",
unit: unitLabel,
acceptance_criteria: "",
};
}
return { ...r, [field]: value };
}),
}));
};
const toggleCopyCollapse = (typeKey: string) => { setCopyCollapsedTypes(prev => ({ ...prev, [typeKey]: !prev[typeKey] })); };
const handleSave = async () => {
if (!form.item_code) { toast.error("품목코드는 필수예요"); return; }
setSaving(true);
@@ -542,18 +675,23 @@ export default function ItemInspectionInfoPage() {
}
const enabledTypes = INSPECTION_TYPES.filter(t => !!form[t.key]);
const rows: any[] = [];
let globalOrder = 0;
for (const t of enabledTypes) {
const typeRows = inspectionRows[t.key] || [];
if (typeRows.length === 0) {
rows.push({ id: crypto.randomUUID(), item_code: form.item_code, item_name: form.item_name, inspection_type: t.label, is_active: form.is_active || "사용", manager_id: form.manager_id || "", memo: form.remarks || "" });
globalOrder += 1;
rows.push({ id: crypto.randomUUID(), item_code: form.item_code, item_name: form.item_name, inspection_type: t.label, is_active: form.is_active || "사용", manager_id: form.manager_id || "", memo: form.remarks || "", sort_order: String(globalOrder).padStart(4, "0") });
} else {
for (const r of typeRows) {
globalOrder += 1;
rows.push({
id: crypto.randomUUID(), item_code: form.item_code, item_name: form.item_name, inspection_type: t.label,
inspection_standard_id: r.inspection_standard_id || "", inspection_item_name: r.inspection_detail || "",
inspection_method: r.inspection_method || "", pass_criteria: r.acceptance_criteria || "",
apply_process: r.apply_process || "", classification: r.classification || "",
is_required: r.is_required ? "true" : "false", is_active: form.is_active || "사용",
manager_id: form.manager_id || "", memo: form.remarks || "",
sort_order: String(globalOrder).padStart(4, "0"),
});
}
}
@@ -974,6 +1112,7 @@ export default function ItemInspectionInfoPage() {
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8 w-[50px]"></TableHead>
@@ -983,7 +1122,7 @@ export default function ItemInspectionInfoPage() {
<TableBody>
{selectedTabRows.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="text-center py-8 text-xs text-muted-foreground"> </TableCell>
<TableCell colSpan={9} className="text-center py-8 text-xs text-muted-foreground"> </TableCell>
</TableRow>
) : selectedTabRows.map((row: any) => (
<TableRow key={row.id}>
@@ -1002,6 +1141,7 @@ export default function ItemInspectionInfoPage() {
const proc = processOptions.find(p => p.code === code);
return proc?.name || code;
})()}</TableCell>
<TableCell className="text-xs py-2">{row.classification || "-"}</TableCell>
<TableCell className="text-xs py-2">
{(() => {
const insp = inspOptions.find(o => o.code === row.inspection_standard_id);
@@ -1185,6 +1325,7 @@ export default function ItemInspectionInfoPage() {
<TableHead className="text-[10px] font-bold w-[130px]"> </TableHead>
<TableHead className="text-[10px] font-bold w-[90px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[90px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[100px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[80px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[200px]"> ()</TableHead>
<TableHead className="text-[10px] font-bold w-[40px]"></TableHead>
@@ -1194,7 +1335,7 @@ export default function ItemInspectionInfoPage() {
</TableHeader>
<TableBody>
{(!inspectionRows[key] || inspectionRows[key].length === 0) ? (
<TableRow><TableCell colSpan={9} className="text-center py-4 text-xs text-muted-foreground"> </TableCell></TableRow>
<TableRow><TableCell colSpan={10} className="text-center py-4 text-xs text-muted-foreground"> </TableCell></TableRow>
) : inspectionRows[key].map((row) => (
<TableRow key={row.id}>
<TableCell className="p-1">
@@ -1219,6 +1360,9 @@ export default function ItemInspectionInfoPage() {
<Input className="h-8 text-xs" value={row.apply_process} onChange={(e) => updateInspRow(key, row.id, "apply_process", e.target.value)} placeholder="공정" />
)}
</TableCell>
<TableCell className="p-1">
<Input className="h-8 text-xs" value={row.classification || ""} onChange={(e) => updateInspRow(key, row.id, "classification", e.target.value)} placeholder="구분 입력" />
</TableCell>
<TableCell className="p-1 text-center">
{row.judgment_criteria ? <Badge variant="outline" className="text-[10px]">{row.judgment_criteria}</Badge> : <span className="text-[10px] text-muted-foreground">-</span>}
</TableCell>
@@ -1285,20 +1429,20 @@ export default function ItemInspectionInfoPage() {
</DialogContent>
</Dialog>
{/* ═══════════════════ 복사 모달 ═══════════════════ */}
{/* ═══════════════════ 복사 모달 (2단 분할: 좌 대상 / 우 편집) ═══════════════════ */}
<Dialog open={copyModalOpen} onOpenChange={(v) => { if (!copying) setCopyModalOpen(v); }}>
<DialogContent
className="max-w-3xl w-[95vw] max-h-[85vh] flex flex-col overflow-hidden"
className="max-w-[95vw] sm:max-w-[1400px] w-[95vw] max-h-[90vh] flex flex-col overflow-hidden"
onPointerDownOutside={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => { if (copying) e.preventDefault(); }}
>
<DialogHeader>
<DialogHeader className="shrink-0">
<DialogTitle>{copying ? "검사정보 복사 중..." : "검사정보 복사"}</DialogTitle>
<DialogDescription>
<span className="font-medium text-foreground">{selectedGroup?.item_name || "-"}</span>
<span className="text-muted-foreground"> ({selectedItemCode})</span>
<span>{copying ? " 의 검사정보를 복사하고 있습니다" : " 의 검사정보를 아래 선택한 품목들에 복사합니다"}</span>
<span>{copying ? " 의 검사정보를 복사하고 있습니다" : " 의 검사정보를 편집해서 선택한 품목들에 복사합니다. 기준 품목은 변경되지 않아요"}</span>
</DialogDescription>
</DialogHeader>
{copying ? (
@@ -1322,81 +1466,229 @@ export default function ItemInspectionInfoPage() {
</p>
</div>
</div>
) : (<>
<div className="flex gap-2 shrink-0">
<Input className="h-9 flex-1" placeholder="품목코드 또는 품목명" value={copySearchKeyword}
onChange={(e) => setCopySearchKeyword(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") handleCopySearch(); }} />
<Button size="sm" className="h-9" onClick={handleCopySearch} disabled={copySearchLoading}>
{copySearchLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <><Search className="w-4 h-4 mr-1" /></>}
</Button>
</div>
<div className="flex-1 border rounded-lg overflow-auto">
<Table>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-[40px] text-center">
<Checkbox
checked={copyFilteredItems.length > 0 && copyFilteredItems.every(i => copyCheckedIds.includes(i.code))}
onCheckedChange={(v) => {
if (v) setCopyCheckedIds(prev => Array.from(new Set([...prev, ...copyFilteredItems.map(i => i.code)])));
else setCopyCheckedIds(prev => prev.filter(c => !copyFilteredItems.some(i => i.code === c)));
}}
/>
</TableHead>
<TableHead className="text-[11px] font-bold w-[140px]"></TableHead>
<TableHead className="text-[11px] font-bold"></TableHead>
<TableHead className="text-[11px] font-bold w-[100px]"></TableHead>
<TableHead className="text-[11px] font-bold w-[80px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{copyFilteredItems.length === 0 ? (
<TableRow><TableCell colSpan={5} className="text-center py-8 text-muted-foreground text-sm">
{copySearchLoading ? "검색 중..." : "검색 결과가 없어요"}
</TableCell></TableRow>
) : copyFilteredItems.map((item) => (
<TableRow key={item.code} className="cursor-pointer hover:bg-primary/5"
onClick={() => toggleCopyChecked(item.code)}>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<Checkbox checked={copyCheckedIds.includes(item.code)} onCheckedChange={() => toggleCopyChecked(item.code)} />
</TableCell>
<TableCell className="text-sm font-mono">{item.code}</TableCell>
<TableCell className="text-sm">{item.name}</TableCell>
<TableCell className="text-sm">{item.item_type}</TableCell>
<TableCell className="text-sm">{item.unit}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="shrink-0 flex items-center justify-between border-t px-2 py-2 text-xs text-muted-foreground">
<span>
<span className="font-medium text-foreground">{copyTotal.toLocaleString()}</span>
{copyCheckedIds.length > 0 && <span className="ml-2"> <span className="font-medium text-primary">{copyCheckedIds.length}</span></span>}
</span>
<div className="flex items-center gap-0.5">
<button onClick={() => { setCopyPage(1); searchCopyTargets(1); }} disabled={copyPage <= 1}
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronsLeft className="h-3.5 w-3.5" /></button>
<button onClick={() => { const p = copyPage - 1; setCopyPage(p); searchCopyTargets(p); }} disabled={copyPage <= 1}
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronLeft className="h-3.5 w-3.5" /></button>
{Array.from({ length: Math.min(5, copyTotalPages) }, (_, i) => {
const start = Math.max(1, Math.min(copyPage - 2, copyTotalPages - 4));
const p = start + i;
if (p > copyTotalPages) return null;
return (
<button key={p} onClick={() => { setCopyPage(p); searchCopyTargets(p); }}
className={cn("h-7 w-7 flex items-center justify-center rounded text-xs",
p === copyPage ? "bg-primary text-primary-foreground font-medium" : "hover:bg-muted")}>{p}</button>
);
})}
<button onClick={() => { const p = copyPage + 1; setCopyPage(p); searchCopyTargets(p); }} disabled={copyPage >= copyTotalPages}
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronRight className="h-3.5 w-3.5" /></button>
<button onClick={() => { setCopyPage(copyTotalPages); searchCopyTargets(copyTotalPages); }} disabled={copyPage >= copyTotalPages}
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronsRight className="h-3.5 w-3.5" /></button>
) : (
<div className="flex-1 grid grid-cols-[420px_1fr] gap-4 overflow-hidden">
{/* 좌측: 복사 대상 품목 선택 */}
<div className="flex flex-col overflow-hidden border rounded-lg">
<div className="flex items-center justify-between border-b bg-muted/50 px-3 py-2">
<span className="text-xs font-semibold"> </span>
{copyCheckedIds.length > 0 && <span className="text-[10px] text-primary"> {copyCheckedIds.length}</span>}
</div>
<div className="flex gap-2 px-2 pt-2">
<Input className="h-8 flex-1 text-xs" placeholder="품목코드 또는 품목명" value={copySearchKeyword}
onChange={(e) => setCopySearchKeyword(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") handleCopySearch(); }} />
<Button size="sm" className="h-8 text-xs" onClick={handleCopySearch} disabled={copySearchLoading}>
{copySearchLoading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Search className="w-3.5 h-3.5" />}
</Button>
</div>
<div className="flex-1 overflow-auto mt-2">
<Table>
<TableHeader className="sticky top-0 z-10 bg-background">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-[36px] text-center text-[10px]">
<Checkbox
checked={copyFilteredItems.length > 0 && copyFilteredItems.every(i => copyCheckedIds.includes(i.code))}
onCheckedChange={(v) => {
if (v) setCopyCheckedIds(prev => Array.from(new Set([...prev, ...copyFilteredItems.map(i => i.code)])));
else setCopyCheckedIds(prev => prev.filter(c => !copyFilteredItems.some(i => i.code === c)));
}}
/>
</TableHead>
<TableHead className="text-[10px] font-bold w-[120px]"></TableHead>
<TableHead className="text-[10px] font-bold"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{copyFilteredItems.length === 0 ? (
<TableRow><TableCell colSpan={3} className="text-center py-6 text-muted-foreground text-xs">
{copySearchLoading ? "검색 중..." : "검색 결과가 없어요"}
</TableCell></TableRow>
) : copyFilteredItems.map((item) => (
<TableRow key={item.code} className="cursor-pointer hover:bg-primary/5"
onClick={() => toggleCopyChecked(item.code)}>
<TableCell className="text-center p-1" onClick={(e) => e.stopPropagation()}>
<Checkbox checked={copyCheckedIds.includes(item.code)} onCheckedChange={() => toggleCopyChecked(item.code)} />
</TableCell>
<TableCell className="text-xs font-mono p-1">{item.code}</TableCell>
<TableCell className="text-xs p-1 truncate">{item.name}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="border-t flex items-center justify-between px-2 py-1 text-[10px] text-muted-foreground">
<span> <span className="font-medium text-foreground">{copyTotal.toLocaleString()}</span></span>
<div className="flex items-center gap-0.5">
<button onClick={() => { setCopyPage(1); searchCopyTargets(1); }} disabled={copyPage <= 1}
className="h-6 w-6 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronsLeft className="h-3 w-3" /></button>
<button onClick={() => { const p = copyPage - 1; setCopyPage(p); searchCopyTargets(p); }} disabled={copyPage <= 1}
className="h-6 w-6 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronLeft className="h-3 w-3" /></button>
<span className="text-[10px] mx-1">{copyPage}/{copyTotalPages}</span>
<button onClick={() => { const p = copyPage + 1; setCopyPage(p); searchCopyTargets(p); }} disabled={copyPage >= copyTotalPages}
className="h-6 w-6 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronRight className="h-3 w-3" /></button>
<button onClick={() => { setCopyPage(copyTotalPages); searchCopyTargets(copyTotalPages); }} disabled={copyPage >= copyTotalPages}
className="h-6 w-6 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronsRight className="h-3 w-3" /></button>
</div>
</div>
</div>
{/* 우측: 편집 폼 (등록/수정 폼과 동일 구조) */}
<div className="flex flex-col overflow-hidden border rounded-lg">
<div className="border-b bg-muted/50 px-3 py-2">
<span className="text-xs font-semibold"> (: {selectedItemCode})</span>
</div>
<div className="flex-1 overflow-y-auto p-3 space-y-4">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-muted-foreground"></Label>
<Select value={copyForm.is_active === false || copyForm.is_active === "N" ? "N" : "Y"} onValueChange={(v) => setCopyForm(p => ({ ...p, is_active: v === "Y" ? "사용" : "미사용" }))}>
<SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="Y"></SelectItem>
<SelectItem value="N"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-muted-foreground"></Label>
<Select value={copyForm.manager || ""} onValueChange={(v) => setCopyForm(p => ({ ...p, manager: v }))}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="관리자 선택" /></SelectTrigger>
<SelectContent>{userOptions.map(o => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<h4 className="text-xs font-semibold"> </h4>
<div className="flex flex-wrap gap-3">
{INSPECTION_TYPES.map(({ key, label }) => (
<div key={key} className="flex items-center gap-1.5">
<Checkbox checked={!!copyForm[key]} onCheckedChange={(v) => setCopyForm(p => ({ ...p, [key]: !!v }))} />
<Label className="text-xs cursor-pointer">{label}</Label>
</div>
))}
</div>
</div>
{INSPECTION_TYPES.filter(t => !!copyForm[t.key]).map(({ key, label }) => (
<div key={key} className="space-y-1.5">
<button type="button" className="w-full flex items-center gap-2 py-1.5 px-2 rounded-md border bg-muted/50 hover:bg-muted text-left" onClick={() => toggleCopyCollapse(key)}>
<Badge variant="default" className="text-[10px]">{label}</Badge>
<span className="text-xs font-medium"> </span>
<span className="text-[10px] text-muted-foreground ml-auto">{(copyInspectionRows[key] || []).length}</span>
</button>
{!copyCollapsedTypes[key] && (
<div className="space-y-1.5 pl-1">
<div className="flex items-center justify-between">
<span className="text-[10px] font-semibold text-muted-foreground"> </span>
<Button type="button" size="sm" variant="outline" className="h-6 text-[10px]" onClick={() => addCopyInspRow(key)}>
<Plus className="w-3 h-3 mr-1" />
</Button>
</div>
<div className="border rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow className="bg-muted/50 hover:bg-muted/50">
<TableHead className="text-[10px] font-bold w-[150px]"> </TableHead>
<TableHead className="text-[10px] font-bold w-[110px]"> </TableHead>
<TableHead className="text-[10px] font-bold w-[80px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[80px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[90px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[70px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[180px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[36px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[60px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[32px]" />
</TableRow>
</TableHeader>
<TableBody>
{(!copyInspectionRows[key] || copyInspectionRows[key].length === 0) ? (
<TableRow><TableCell colSpan={10} className="text-center py-3 text-[10px] text-muted-foreground"> </TableCell></TableRow>
) : copyInspectionRows[key].map((row) => (
<TableRow key={row.id}>
<TableCell className="p-1">
<Select value={row.inspection_standard_id || ""} onValueChange={(v) => updateCopyInspRow(key, row.id, "inspection_standard_id", v)}>
<SelectTrigger className="h-7 text-[10px]"><SelectValue placeholder="검사기준" /></SelectTrigger>
<SelectContent>{getFilteredInspOptions(key).map(o => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}</SelectContent>
</Select>
</TableCell>
<TableCell className="p-1"><Input className="h-7 text-[10px] bg-muted" value={row.inspection_detail} readOnly placeholder="자동" /></TableCell>
<TableCell className="p-1"><Input className="h-7 text-[10px] bg-muted" value={row.inspection_method} readOnly placeholder="자동" /></TableCell>
<TableCell className="p-1">
{processOptions.length > 0 ? (
<Select value={row.apply_process || ""} onValueChange={(v) => updateCopyInspRow(key, row.id, "apply_process", v)}>
<SelectTrigger className="h-7 text-[10px]"><SelectValue placeholder="공정" /></SelectTrigger>
<SelectContent>
{processOptions.map((p) => (
<SelectItem key={p.code} value={p.code} className="text-xs">{p.name}</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input className="h-7 text-[10px]" value={row.apply_process} onChange={(e) => updateCopyInspRow(key, row.id, "apply_process", e.target.value)} placeholder="공정" />
)}
</TableCell>
<TableCell className="p-1">
<Input className="h-7 text-[10px]" value={row.classification || ""} onChange={(e) => updateCopyInspRow(key, row.id, "classification", e.target.value)} placeholder="구분" />
</TableCell>
<TableCell className="p-1 text-center">
{row.judgment_criteria ? <Badge variant="outline" className="text-[9px]">{row.judgment_criteria}</Badge> : <span className="text-[9px] text-muted-foreground">-</span>}
</TableCell>
<TableCell className="p-1">
{row.judgment_criteria === "선택형" && row.selection_options ? (
<Select value={row.acceptance_criteria || ""} onValueChange={(v) => updateCopyInspRow(key, row.id, "acceptance_criteria", v)} disabled={!row.inspection_standard_id}>
<SelectTrigger className="h-7 text-[10px]"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{row.selection_options.split(",").filter(Boolean).map((opt) => (
<SelectItem key={opt} value={opt} className="text-xs">{opt}</SelectItem>
))}
</SelectContent>
</Select>
) : row.judgment_criteria === "O/X" ? (
<Select value={row.acceptance_criteria || ""} onValueChange={(v) => updateCopyInspRow(key, row.id, "acceptance_criteria", v)} disabled={!row.inspection_standard_id}>
<SelectTrigger className="h-7 text-[10px]"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
<SelectItem value="O" className="text-xs">O ()</SelectItem>
<SelectItem value="X" className="text-xs">X ()</SelectItem>
</SelectContent>
</Select>
) : row.judgment_criteria === "수치(범위)" ? (
<div className="flex items-center gap-1">
<Input className="h-7 text-[10px] w-14" value={row.acceptance_criteria?.split("|")[0] || ""} onChange={(e) => {
const parts = (row.acceptance_criteria || "||").split("|");
parts[0] = e.target.value;
updateCopyInspRow(key, row.id, "acceptance_criteria", parts.join("|"));
}} placeholder="기준" disabled={!row.inspection_standard_id} />
<span className="text-[9px] text-muted-foreground">±</span>
<Input className="h-7 text-[10px] w-10" value={row.acceptance_criteria?.split("|")[1] || ""} onChange={(e) => {
const parts = (row.acceptance_criteria || "||").split("|");
parts[1] = e.target.value;
updateCopyInspRow(key, row.id, "acceptance_criteria", parts.join("|"));
}} placeholder="±" disabled={!row.inspection_standard_id} />
</div>
) : (
<Input className="h-7 text-[10px]" value={row.acceptance_criteria} onChange={(e) => updateCopyInspRow(key, row.id, "acceptance_criteria", e.target.value)} placeholder="합격기준" disabled={!row.inspection_standard_id} />
)}
</TableCell>
<TableCell className="p-1 text-center"><Checkbox checked={row.is_required} onCheckedChange={(v) => updateCopyInspRow(key, row.id, "is_required", !!v)} /></TableCell>
<TableCell className="p-1 text-[10px] text-muted-foreground">{row.unit || "-"}</TableCell>
<TableCell className="p-1">
<Button type="button" variant="destructive" size="sm" className="h-6 w-6 p-0" onClick={() => removeCopyInspRow(key, row.id)}><Trash2 className="w-3 h-3" /></Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)}
</div>
))}
</div>
</div>
</div>
</div>
</>)}
)}
<DialogFooter className="shrink-0">
<Button variant="outline" onClick={() => setCopyModalOpen(false)} disabled={copying}></Button>
<Button onClick={handleCopy} disabled={copying || copyCheckedIds.length === 0}>
@@ -358,13 +358,15 @@ export default function LogisticsInfoPage() {
loadReferences();
}, [loadReferences]);
// 카테고리 옵션 로드
// 카테고리 옵션 로드 (관리자 계정일 때 filterCompanyCode 미제공 시 "*" 스코프로 빈 결과 반환됨)
const loadCategoryOptions = useCallback(async (tableColumn: string) => {
if (loadedCategories.current.has(tableColumn)) return;
loadedCategories.current.add(tableColumn);
const [tableName, columnName] = tableColumn.split(":");
try {
const res = await apiClient.get(`/table-categories/${tableName}/${columnName}/values`);
const res = await apiClient.get(
`/table-categories/${tableName}/${columnName}/values?filterCompanyCode=COMPANY_7`
);
const data = res.data?.data || [];
setCategoryOptions((prev) => ({
...prev,
@@ -823,13 +825,24 @@ export default function LogisticsInfoPage() {
{/* 테이블 영역 */}
<div className="flex-1 overflow-auto">
<EDataTable
columns={getVisibleColumns(tab.key).map((col): EDataTableColumn => ({
key: col.key,
label: col.label,
align: col.align,
formatNumber: col.formatNumber,
truncate: true,
}))}
columns={getVisibleColumns(tab.key).map((col): EDataTableColumn => {
// 같은 key의 formField에 categoryKey가 있으면 코드→라벨 변환
const formField = tab.formFields.find((f) => f.key === col.key && f.categoryKey);
return {
key: col.key,
label: col.label,
align: col.align,
formatNumber: col.formatNumber,
truncate: true,
render: formField?.categoryKey
? (value: any) => {
const opts = categoryOptions[formField.categoryKey!] || [];
const matched = opts.find((o: any) => o.value === value);
return matched?.label || value || "-";
}
: undefined,
};
})}
data={tsMap[tab.key].groupData(displayData)}
rowKey={(row: any) => String(row.id)}
loading={tabLoading[tab.key]}
@@ -186,12 +186,12 @@ export default function InventoryStatusPage() {
};
load();
// 사용자 목록 로드
apiClient.get("/admin/users", { params: { size: 9999 } }).then((res) => {
const users = res.data?.data || res.data || [];
apiClient.get("/admin/users/name-map").then((res) => {
const users = res.data?.data || [];
const map: Record<string, string> = {};
for (const u of users) {
const id = u.userId || u.user_id || u.id;
const name = u.user_name || u.name || id;
const id = u.user_id;
const name = u.user_name || id;
if (id) map[id] = name;
}
setUserMap(map);
@@ -628,7 +628,7 @@ export default function MaterialStatusPage() {
className="inline-flex items-center gap-1 rounded bg-muted/40 px-2 py-0.5 text-xs transition-colors hover:bg-muted/60"
>
<span className="font-semibold font-mono text-primary">
{loc.location || loc.warehouse}
{loc.warehouse_name || loc.location || loc.warehouse}
</span>
<span className="font-semibold">
{loc.qty.toLocaleString()}
@@ -158,6 +158,10 @@ export default function WarehouseManagementPage() {
const [rackStatus, setRackStatus] = useState("");
const [rackPreview, setRackPreview] = useState<any[]>([]);
const [rackSaving, setRackSaving] = useState(false);
// 위치명 접미사 (자동 조립: {zone}{구역접미사}-{row}{열접미사}-{level}{단접미사})
const [rackZoneLabel, setRackZoneLabel] = useState("구역");
const [rackRowLabel, setRackRowLabel] = useState("열");
const [rackLevelLabel, setRackLevelLabel] = useState("단");
// 카테고리 옵션
const [categoryOptions, setCategoryOptions] = useState<
@@ -645,7 +649,7 @@ export default function WarehouseManagementPage() {
duplicates.push(locationCode);
continue;
}
const locationName = `${zoneCode}구역-${rowStr}열-${level}`;
const locationName = `${zoneCode}${rackZoneLabel}-${rowStr}${rackRowLabel}-${level}${rackLevelLabel}`;
items.push({
location_code: locationCode,
location_name: locationName,
@@ -1511,6 +1515,38 @@ export default function WarehouseManagementPage() {
</div>
</div>
{/* 위치명 형식 — 구역/열/단 뒤에 붙일 표현만 자유 입력 */}
<div className="rounded-lg border p-3 bg-muted/30 space-y-2">
<Label className="text-xs font-semibold"> </Label>
<div className="flex items-center gap-1 text-xs flex-wrap">
<span className="font-mono text-muted-foreground">A</span>
<Input
value={rackZoneLabel}
onChange={(e) => setRackZoneLabel(e.target.value)}
placeholder="구역"
className="h-8 w-20 text-xs"
/>
<span className="font-mono text-muted-foreground">- 01</span>
<Input
value={rackRowLabel}
onChange={(e) => setRackRowLabel(e.target.value)}
placeholder="열"
className="h-8 w-20 text-xs"
/>
<span className="font-mono text-muted-foreground">- 1</span>
<Input
value={rackLevelLabel}
onChange={(e) => setRackLevelLabel(e.target.value)}
placeholder="단"
className="h-8 w-20 text-xs"
/>
</div>
<p className="text-[11px] text-muted-foreground">
: <span className="font-mono font-semibold">A{rackZoneLabel}-01{rackRowLabel}-1{rackLevelLabel}</span>
{" "} // , .
</p>
</div>
{/* 등록 미리보기 */}
<div>
<div className="flex items-center justify-between mb-3">
@@ -356,7 +356,13 @@ export default function BomManagementPage() {
sort: { columnName: "created_at", order: "desc" },
});
const rows = res.data?.data?.data || res.data?.data?.rows || [];
// DB 컬럼이 item_type/expired_date → 프론트 내부에서는 bom_type/expiry_date로 통일
const rawRows = res.data?.data?.data || res.data?.data?.rows || [];
const rows = rawRows.map((r: any) => ({
...r,
bom_type: r.bom_type ?? r.item_type,
expiry_date: r.expiry_date ?? r.expired_date,
}));
setBomList(rows);
setTotalCount(rows.length);
} catch (err: any) {
@@ -453,9 +459,16 @@ export default function BomManagementPage() {
const fetchBomDetail = useCallback(async (bomId: string) => {
setDetailLoading(true);
try {
// 헤더 조회
// 헤더 조회 (DB 컬럼 item_type/expired_date → bom_type/expiry_date로 매핑)
const headerRes = await apiClient.get(`/bom/${bomId}/header`);
const header = headerRes.data?.data || headerRes.data;
const rawHeader = headerRes.data?.data || headerRes.data;
const header = rawHeader
? {
...rawHeader,
bom_type: rawHeader.bom_type ?? rawHeader.item_type,
expiry_date: rawHeader.expiry_date ?? rawHeader.expired_date,
}
: null;
setBomHeader(header);
setCurrentVersionId(header?.current_version_id || null);
@@ -1101,17 +1114,18 @@ export default function BomManagementPage() {
setSaving(true);
try {
// DB 실제 컬럼: item_type / expired_date (프론트 내부 bom_type/expiry_date와 다름)
const bomFields: Record<string, any> = {
item_id: masterForm.item_id,
item_code: masterForm.item_code,
item_name: masterForm.item_name,
bom_type: masterForm.bom_type,
item_type: masterForm.bom_type,
base_qty: masterForm.base_qty || "1",
unit: masterForm.unit || "",
version: masterForm.version || "1.0",
status: masterForm.status || "draft",
effective_date: masterForm.effective_date || null,
expiry_date: masterForm.expiry_date || null,
expired_date: masterForm.expiry_date || null,
remark: masterForm.remark || "",
writer: user?.userId || "",
company_code: user?.company_code || "",
@@ -1486,8 +1500,14 @@ export default function BomManagementPage() {
<Button
size="sm"
variant="outline"
onClick={openEditModal}
disabled={!selectedBomId}
onClick={() => {
if (!selectedBomId || !bomHeader) {
toast.error("수정할 BOM을 선택해주세요");
return;
}
openEditModal();
}}
disabled={!selectedBomId || !bomHeader}
>
<Pencil className="w-3.5 h-3.5 mr-1" />
@@ -284,6 +284,11 @@ export default function ItemInspectionInfoPage() {
loadProcessOptions(item.code);
};
// 복사 모달: 편집 가능한 기준 데이터 상태 (등록/수정 폼과 평행 구조)
const [copyForm, setCopyForm] = useState<Record<string, any>>({});
const [copyInspectionRows, setCopyInspectionRows] = useState<Record<string, InspectionRow[]>>({});
const [copyCollapsedTypes, setCopyCollapsedTypes] = useState<Record<string, boolean>>({});
/* ═══════════════════ 복사 모달 (기준 품목 검사정보 → 다른 품목들) ═══════════════════ */
const [copyModalOpen, setCopyModalOpen] = useState(false);
const [copySearchKeyword, setCopySearchKeyword] = useState("");
@@ -325,11 +330,63 @@ export default function ItemInspectionInfoPage() {
setCopyTotal(resData?.total || resData?.totalCount || rows.length);
} catch { /* skip */ } finally { setCopySearchLoading(false); }
};
const openCopyModal = () => {
const openCopyModal = async () => {
if (!selectedItemCode) { toast.error("복사 기준 품목을 먼저 선택해주세요"); return; }
const srcGroup = groupedData.find(g => g.item_code === selectedItemCode);
if (!srcGroup || srcGroup.rows.length === 0) { toast.error("선택한 품목에 복사할 검사정보가 없어요"); return; }
setCopySearchKeyword(""); setCopyPage(1); setCopyCheckedIds([]);
// 기준 품목 데이터를 편집용 상태로 복제 (openEdit과 동일한 변환 로직)
const baseRow = srcGroup.rows[0];
try {
const res = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, {
page: 1, size: 0,
dataFilter: { enabled: true, filters: [{ columnName: "item_code", operator: "equals", value: selectedItemCode }] },
autoFilter: true,
});
const allRows = res.data?.data?.data || res.data?.data?.rows || [];
const rowMap: Record<string, InspectionRow[]> = {};
const typeFlags: Record<string, boolean> = {};
for (const r of allRows) {
const inspType = r.inspection_type || "";
const matched = INSPECTION_TYPES.find(t =>
t.matchLabels.some(ml => inspType.includes(ml)) ||
inspTypeCatOptions.some(cat => inspType.includes(cat.code) && t.matchLabels.some(ml => cat.label.includes(ml)))
);
const typeKey = matched?.key || "";
if (!typeKey) continue;
typeFlags[typeKey] = true;
if (!rowMap[typeKey]) rowMap[typeKey] = [];
const mCode = r.inspection_method || "";
const mLabel = inspMethodCatOptions.find(o => o.code === mCode)?.label || mCode;
const inspOpt = inspOptions.find(o => o.code === r.inspection_standard_id);
const jcCode = inspOpt?.judgment_criteria || "";
const jcLabel = judgmentCatOptions.find(c => c.code === jcCode)?.label || jcCode;
const unitCode = inspOpt?.unit || "";
const unitLabel = inspUnitCatOptions.find(c => c.code === unitCode)?.label || unitCode;
rowMap[typeKey].push({
id: crypto.randomUUID(), // 복사본은 새 id 부여 (원본과 분리)
inspection_standard_id: r.inspection_standard_id || "",
inspection_detail: r.inspection_item_name || r.inspection_standard || "",
inspection_method: mLabel,
apply_process: r.apply_process || "",
classification: r.classification || "",
acceptance_criteria: r.pass_criteria || "",
is_required: r.is_required === "true" || r.is_required === true,
judgment_criteria: jcLabel,
selection_options: inspOpt?.selection_options || "",
unit: unitLabel,
});
}
setCopyInspectionRows(rowMap);
setCopyForm({ ...baseRow, ...typeFlags });
setCopyCollapsedTypes({});
} catch {
setCopyInspectionRows({});
setCopyForm({ ...baseRow });
setCopyCollapsedTypes({});
}
setCopyModalOpen(true);
searchCopyTargets(1);
};
@@ -340,10 +397,18 @@ export default function ItemInspectionInfoPage() {
const handleCopy = async () => {
if (!selectedItemCode) { toast.error("복사 기준 품목이 없어요"); return; }
if (copyCheckedIds.length === 0) { toast.error("붙여넣을 품목을 선택해주세요"); return; }
const sourceGroup = groupedData.find(g => g.item_code === selectedItemCode);
if (!sourceGroup || sourceGroup.rows.length === 0) { toast.error("복사할 검사정보가 없어요"); return; }
// 편집된 rows를 평탄화 (선택된 검사유형의 rows만)
const enabledTypes = INSPECTION_TYPES.filter(t => !!copyForm[t.key]);
const flatRows: Array<{ row: InspectionRow; typeLabel: string }> = [];
for (const t of enabledTypes) {
const rows = copyInspectionRows[t.key] || [];
for (const r of rows) flatRows.push({ row: r, typeLabel: t.label });
}
if (flatRows.length === 0) { toast.error("복사할 검사항목이 없어요"); return; }
const ok = await confirm(
`선택한 ${copyCheckedIds.length}개 품목에 검사정보를 복사할까요?`,
`선택한 ${copyCheckedIds.length}개 품목에 편집된 검사정보(${flatRows.length}개 행)를 복사할까요?`,
{ description: "대상 품목의 기존 검사정보는 삭제 후 교체됩니다.", variant: "info", confirmText: "복사" }
);
if (!ok) return;
@@ -364,13 +429,26 @@ export default function ItemInspectionInfoPage() {
if (existing.length > 0) {
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, { data: existing.map((r: any) => ({ id: r.id })) });
}
for (const r of sourceGroup.rows) {
const { id: _id, created_at: _c, updated_at: _u, ...rest } = r;
let orderSeq = 0;
for (const { row: r, typeLabel } of flatRows) {
orderSeq += 1;
await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, {
...rest,
id: crypto.randomUUID(),
item_code: targetCode,
item_name: targetName,
inspection_type: typeLabel,
inspection_standard_id: r.inspection_standard_id || "",
inspection_item_name: r.inspection_detail || "",
inspection_method: r.inspection_method || "",
apply_process: r.apply_process || "",
classification: r.classification || "",
pass_criteria: r.acceptance_criteria || "",
is_required: r.is_required ? "true" : "false",
is_active: copyForm.is_active || "사용",
manager: copyForm.manager || "",
manager_id: copyForm.manager_id || "",
memo: copyForm.remarks || "",
sort_order: String(orderSeq).padStart(4, "0"),
});
}
setCopyProgress({ current: i + 1, total: copyCheckedIds.length });
@@ -579,6 +657,46 @@ export default function ItemInspectionInfoPage() {
};
const toggleCollapse = (typeKey: string) => { setCollapsedTypes(prev => ({ ...prev, [typeKey]: !prev[typeKey] })); };
/* ═══════════════════ 복사 모달용 검사항목 행 관리 (등록 폼과 평행) ═══════════════════ */
const addCopyInspRow = (typeKey: string) => {
setCopyInspectionRows(prev => ({
...prev,
[typeKey]: [...(prev[typeKey] || []), { id: crypto.randomUUID(), inspection_standard_id: "", inspection_detail: "", inspection_method: "", apply_process: "", classification: "", acceptance_criteria: "", is_required: false }],
}));
};
const removeCopyInspRow = (typeKey: string, rowId: string) => {
setCopyInspectionRows(prev => ({ ...prev, [typeKey]: (prev[typeKey] || []).filter(r => r.id !== rowId) }));
};
const updateCopyInspRow = (typeKey: string, rowId: string, field: string, value: any) => {
setCopyInspectionRows(prev => ({
...prev,
[typeKey]: (prev[typeKey] || []).map(r => {
if (r.id !== rowId) return r;
if (field === "inspection_standard_id") {
const opt = inspOptions.find(o => o.code === value);
const methodCode = opt?.method || "";
const methodLabel = inspMethodCatOptions.find(o => o.code === methodCode)?.label || methodCode;
const jcCode = opt?.judgment_criteria || "";
const jcLabel = judgmentCatOptions.find(c => c.code === jcCode)?.label || jcCode;
const unitCode = opt?.unit || "";
const unitLabel = inspUnitCatOptions.find(c => c.code === unitCode)?.label || unitCode;
return {
...r,
inspection_standard_id: value,
inspection_detail: opt?.detail || "",
inspection_method: methodLabel,
judgment_criteria: jcLabel,
selection_options: opt?.selection_options || "",
unit: unitLabel,
acceptance_criteria: "",
};
}
return { ...r, [field]: value };
}),
}));
};
const toggleCopyCollapse = (typeKey: string) => { setCopyCollapsedTypes(prev => ({ ...prev, [typeKey]: !prev[typeKey] })); };
const handleSave = async () => {
if (!form.item_code) { toast.error("품목코드는 필수예요"); return; }
setSaving(true);
@@ -1369,20 +1487,20 @@ export default function ItemInspectionInfoPage() {
</DialogContent>
</Dialog>
{/* ═══════════════════ 복사 모달 ═══════════════════ */}
{/* ═══════════════════ 복사 모달 (2단 분할: 좌 대상 / 우 편집) ═══════════════════ */}
<Dialog open={copyModalOpen} onOpenChange={(v) => { if (!copying) setCopyModalOpen(v); }}>
<DialogContent
className="max-w-3xl w-[95vw] max-h-[85vh] flex flex-col overflow-hidden"
className="max-w-[95vw] sm:max-w-[1400px] w-[95vw] max-h-[90vh] flex flex-col overflow-hidden"
onPointerDownOutside={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => { if (copying) e.preventDefault(); }}
>
<DialogHeader>
<DialogHeader className="shrink-0">
<DialogTitle>{copying ? "검사정보 복사 중..." : "검사정보 복사"}</DialogTitle>
<DialogDescription>
<span className="font-medium text-foreground">{selectedGroup?.item_name || "-"}</span>
<span className="text-muted-foreground"> ({selectedItemCode})</span>
<span>{copying ? " 의 검사정보를 복사하고 있습니다" : " 의 검사정보를 아래 선택한 품목들에 복사합니다"}</span>
<span>{copying ? " 의 검사정보를 복사하고 있습니다" : " 의 검사정보를 편집해서 선택한 품목들에 복사합니다. 기준 품목은 변경되지 않아요"}</span>
</DialogDescription>
</DialogHeader>
{copying ? (
@@ -1406,81 +1524,229 @@ export default function ItemInspectionInfoPage() {
</p>
</div>
</div>
) : (<>
<div className="flex gap-2 shrink-0">
<Input className="h-9 flex-1" placeholder="품목코드 또는 품목명" value={copySearchKeyword}
onChange={(e) => setCopySearchKeyword(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") handleCopySearch(); }} />
<Button size="sm" className="h-9" onClick={handleCopySearch} disabled={copySearchLoading}>
{copySearchLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <><Search className="w-4 h-4 mr-1" /></>}
</Button>
</div>
<div className="flex-1 border rounded-lg overflow-auto">
<Table>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-[40px] text-center">
<Checkbox
checked={copyFilteredItems.length > 0 && copyFilteredItems.every(i => copyCheckedIds.includes(i.code))}
onCheckedChange={(v) => {
if (v) setCopyCheckedIds(prev => Array.from(new Set([...prev, ...copyFilteredItems.map(i => i.code)])));
else setCopyCheckedIds(prev => prev.filter(c => !copyFilteredItems.some(i => i.code === c)));
}}
/>
</TableHead>
<TableHead className="text-[11px] font-bold w-[140px]"></TableHead>
<TableHead className="text-[11px] font-bold"></TableHead>
<TableHead className="text-[11px] font-bold w-[100px]"></TableHead>
<TableHead className="text-[11px] font-bold w-[80px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{copyFilteredItems.length === 0 ? (
<TableRow><TableCell colSpan={5} className="text-center py-8 text-muted-foreground text-sm">
{copySearchLoading ? "검색 중..." : "검색 결과가 없어요"}
</TableCell></TableRow>
) : copyFilteredItems.map((item) => (
<TableRow key={item.code} className="cursor-pointer hover:bg-primary/5"
onClick={() => toggleCopyChecked(item.code)}>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<Checkbox checked={copyCheckedIds.includes(item.code)} onCheckedChange={() => toggleCopyChecked(item.code)} />
</TableCell>
<TableCell className="text-sm font-mono">{item.code}</TableCell>
<TableCell className="text-sm">{item.name}</TableCell>
<TableCell className="text-sm">{item.item_type}</TableCell>
<TableCell className="text-sm">{item.unit}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="shrink-0 flex items-center justify-between border-t px-2 py-2 text-xs text-muted-foreground">
<span>
<span className="font-medium text-foreground">{copyTotal.toLocaleString()}</span>
{copyCheckedIds.length > 0 && <span className="ml-2"> <span className="font-medium text-primary">{copyCheckedIds.length}</span></span>}
</span>
<div className="flex items-center gap-0.5">
<button onClick={() => { setCopyPage(1); searchCopyTargets(1); }} disabled={copyPage <= 1}
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronsLeft className="h-3.5 w-3.5" /></button>
<button onClick={() => { const p = copyPage - 1; setCopyPage(p); searchCopyTargets(p); }} disabled={copyPage <= 1}
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronLeft className="h-3.5 w-3.5" /></button>
{Array.from({ length: Math.min(5, copyTotalPages) }, (_, i) => {
const start = Math.max(1, Math.min(copyPage - 2, copyTotalPages - 4));
const p = start + i;
if (p > copyTotalPages) return null;
return (
<button key={p} onClick={() => { setCopyPage(p); searchCopyTargets(p); }}
className={cn("h-7 w-7 flex items-center justify-center rounded text-xs",
p === copyPage ? "bg-primary text-primary-foreground font-medium" : "hover:bg-muted")}>{p}</button>
);
})}
<button onClick={() => { const p = copyPage + 1; setCopyPage(p); searchCopyTargets(p); }} disabled={copyPage >= copyTotalPages}
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronRight className="h-3.5 w-3.5" /></button>
<button onClick={() => { setCopyPage(copyTotalPages); searchCopyTargets(copyTotalPages); }} disabled={copyPage >= copyTotalPages}
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronsRight className="h-3.5 w-3.5" /></button>
) : (
<div className="flex-1 grid grid-cols-[420px_1fr] gap-4 overflow-hidden">
{/* 좌측: 복사 대상 품목 선택 */}
<div className="flex flex-col overflow-hidden border rounded-lg">
<div className="flex items-center justify-between border-b bg-muted/50 px-3 py-2">
<span className="text-xs font-semibold"> </span>
{copyCheckedIds.length > 0 && <span className="text-[10px] text-primary"> {copyCheckedIds.length}</span>}
</div>
<div className="flex gap-2 px-2 pt-2">
<Input className="h-8 flex-1 text-xs" placeholder="품목코드 또는 품목명" value={copySearchKeyword}
onChange={(e) => setCopySearchKeyword(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") handleCopySearch(); }} />
<Button size="sm" className="h-8 text-xs" onClick={handleCopySearch} disabled={copySearchLoading}>
{copySearchLoading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Search className="w-3.5 h-3.5" />}
</Button>
</div>
<div className="flex-1 overflow-auto mt-2">
<Table>
<TableHeader className="sticky top-0 z-10 bg-background">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-[36px] text-center text-[10px]">
<Checkbox
checked={copyFilteredItems.length > 0 && copyFilteredItems.every(i => copyCheckedIds.includes(i.code))}
onCheckedChange={(v) => {
if (v) setCopyCheckedIds(prev => Array.from(new Set([...prev, ...copyFilteredItems.map(i => i.code)])));
else setCopyCheckedIds(prev => prev.filter(c => !copyFilteredItems.some(i => i.code === c)));
}}
/>
</TableHead>
<TableHead className="text-[10px] font-bold w-[120px]"></TableHead>
<TableHead className="text-[10px] font-bold"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{copyFilteredItems.length === 0 ? (
<TableRow><TableCell colSpan={3} className="text-center py-6 text-muted-foreground text-xs">
{copySearchLoading ? "검색 중..." : "검색 결과가 없어요"}
</TableCell></TableRow>
) : copyFilteredItems.map((item) => (
<TableRow key={item.code} className="cursor-pointer hover:bg-primary/5"
onClick={() => toggleCopyChecked(item.code)}>
<TableCell className="text-center p-1" onClick={(e) => e.stopPropagation()}>
<Checkbox checked={copyCheckedIds.includes(item.code)} onCheckedChange={() => toggleCopyChecked(item.code)} />
</TableCell>
<TableCell className="text-xs font-mono p-1">{item.code}</TableCell>
<TableCell className="text-xs p-1 truncate">{item.name}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="border-t flex items-center justify-between px-2 py-1 text-[10px] text-muted-foreground">
<span> <span className="font-medium text-foreground">{copyTotal.toLocaleString()}</span></span>
<div className="flex items-center gap-0.5">
<button onClick={() => { setCopyPage(1); searchCopyTargets(1); }} disabled={copyPage <= 1}
className="h-6 w-6 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronsLeft className="h-3 w-3" /></button>
<button onClick={() => { const p = copyPage - 1; setCopyPage(p); searchCopyTargets(p); }} disabled={copyPage <= 1}
className="h-6 w-6 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronLeft className="h-3 w-3" /></button>
<span className="text-[10px] mx-1">{copyPage}/{copyTotalPages}</span>
<button onClick={() => { const p = copyPage + 1; setCopyPage(p); searchCopyTargets(p); }} disabled={copyPage >= copyTotalPages}
className="h-6 w-6 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronRight className="h-3 w-3" /></button>
<button onClick={() => { setCopyPage(copyTotalPages); searchCopyTargets(copyTotalPages); }} disabled={copyPage >= copyTotalPages}
className="h-6 w-6 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronsRight className="h-3 w-3" /></button>
</div>
</div>
</div>
{/* 우측: 편집 폼 (등록/수정 폼과 동일 구조) */}
<div className="flex flex-col overflow-hidden border rounded-lg">
<div className="border-b bg-muted/50 px-3 py-2">
<span className="text-xs font-semibold"> (: {selectedItemCode})</span>
</div>
<div className="flex-1 overflow-y-auto p-3 space-y-4">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-muted-foreground"></Label>
<Select value={copyForm.is_active === false || copyForm.is_active === "N" ? "N" : "Y"} onValueChange={(v) => setCopyForm(p => ({ ...p, is_active: v === "Y" ? "사용" : "미사용" }))}>
<SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="Y"></SelectItem>
<SelectItem value="N"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-muted-foreground"></Label>
<Select value={copyForm.manager || ""} onValueChange={(v) => setCopyForm(p => ({ ...p, manager: v }))}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="관리자 선택" /></SelectTrigger>
<SelectContent>{userOptions.map(o => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<h4 className="text-xs font-semibold"> </h4>
<div className="flex flex-wrap gap-3">
{INSPECTION_TYPES.map(({ key, label }) => (
<div key={key} className="flex items-center gap-1.5">
<Checkbox checked={!!copyForm[key]} onCheckedChange={(v) => setCopyForm(p => ({ ...p, [key]: !!v }))} />
<Label className="text-xs cursor-pointer">{label}</Label>
</div>
))}
</div>
</div>
{INSPECTION_TYPES.filter(t => !!copyForm[t.key]).map(({ key, label }) => (
<div key={key} className="space-y-1.5">
<button type="button" className="w-full flex items-center gap-2 py-1.5 px-2 rounded-md border bg-muted/50 hover:bg-muted text-left" onClick={() => toggleCopyCollapse(key)}>
<Badge variant="default" className="text-[10px]">{label}</Badge>
<span className="text-xs font-medium"> </span>
<span className="text-[10px] text-muted-foreground ml-auto">{(copyInspectionRows[key] || []).length}</span>
</button>
{!copyCollapsedTypes[key] && (
<div className="space-y-1.5 pl-1">
<div className="flex items-center justify-between">
<span className="text-[10px] font-semibold text-muted-foreground"> </span>
<Button type="button" size="sm" variant="outline" className="h-6 text-[10px]" onClick={() => addCopyInspRow(key)}>
<Plus className="w-3 h-3 mr-1" />
</Button>
</div>
<div className="border rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow className="bg-muted/50 hover:bg-muted/50">
<TableHead className="text-[10px] font-bold w-[150px]"> </TableHead>
<TableHead className="text-[10px] font-bold w-[110px]"> </TableHead>
<TableHead className="text-[10px] font-bold w-[80px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[80px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[90px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[70px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[180px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[36px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[60px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[32px]" />
</TableRow>
</TableHeader>
<TableBody>
{(!copyInspectionRows[key] || copyInspectionRows[key].length === 0) ? (
<TableRow><TableCell colSpan={10} className="text-center py-3 text-[10px] text-muted-foreground"> </TableCell></TableRow>
) : copyInspectionRows[key].map((row) => (
<TableRow key={row.id}>
<TableCell className="p-1">
<Select value={row.inspection_standard_id || ""} onValueChange={(v) => updateCopyInspRow(key, row.id, "inspection_standard_id", v)}>
<SelectTrigger className="h-7 text-[10px]"><SelectValue placeholder="검사기준" /></SelectTrigger>
<SelectContent>{getFilteredInspOptions(key).map(o => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}</SelectContent>
</Select>
</TableCell>
<TableCell className="p-1"><Input className="h-7 text-[10px] bg-muted" value={row.inspection_detail} readOnly placeholder="자동" /></TableCell>
<TableCell className="p-1"><Input className="h-7 text-[10px] bg-muted" value={row.inspection_method} readOnly placeholder="자동" /></TableCell>
<TableCell className="p-1">
{processOptions.length > 0 ? (
<Select value={row.apply_process || ""} onValueChange={(v) => updateCopyInspRow(key, row.id, "apply_process", v)}>
<SelectTrigger className="h-7 text-[10px]"><SelectValue placeholder="공정" /></SelectTrigger>
<SelectContent>
{processOptions.map((p) => (
<SelectItem key={p.code} value={p.code} className="text-xs">{p.name}</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input className="h-7 text-[10px]" value={row.apply_process} onChange={(e) => updateCopyInspRow(key, row.id, "apply_process", e.target.value)} placeholder="공정" />
)}
</TableCell>
<TableCell className="p-1">
<Input className="h-7 text-[10px]" value={row.classification || ""} onChange={(e) => updateCopyInspRow(key, row.id, "classification", e.target.value)} placeholder="구분" />
</TableCell>
<TableCell className="p-1 text-center">
{row.judgment_criteria ? <Badge variant="outline" className="text-[9px]">{row.judgment_criteria}</Badge> : <span className="text-[9px] text-muted-foreground">-</span>}
</TableCell>
<TableCell className="p-1">
{row.judgment_criteria === "선택형" && row.selection_options ? (
<Select value={row.acceptance_criteria || ""} onValueChange={(v) => updateCopyInspRow(key, row.id, "acceptance_criteria", v)} disabled={!row.inspection_standard_id}>
<SelectTrigger className="h-7 text-[10px]"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{row.selection_options.split(",").filter(Boolean).map((opt) => (
<SelectItem key={opt} value={opt} className="text-xs">{opt}</SelectItem>
))}
</SelectContent>
</Select>
) : row.judgment_criteria === "O/X" ? (
<Select value={row.acceptance_criteria || ""} onValueChange={(v) => updateCopyInspRow(key, row.id, "acceptance_criteria", v)} disabled={!row.inspection_standard_id}>
<SelectTrigger className="h-7 text-[10px]"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
<SelectItem value="O" className="text-xs">O ()</SelectItem>
<SelectItem value="X" className="text-xs">X ()</SelectItem>
</SelectContent>
</Select>
) : row.judgment_criteria === "수치(범위)" ? (
<div className="flex items-center gap-1">
<Input className="h-7 text-[10px] w-14" value={row.acceptance_criteria?.split("|")[0] || ""} onChange={(e) => {
const parts = (row.acceptance_criteria || "||").split("|");
parts[0] = e.target.value;
updateCopyInspRow(key, row.id, "acceptance_criteria", parts.join("|"));
}} placeholder="기준" disabled={!row.inspection_standard_id} />
<span className="text-[9px] text-muted-foreground">±</span>
<Input className="h-7 text-[10px] w-10" value={row.acceptance_criteria?.split("|")[1] || ""} onChange={(e) => {
const parts = (row.acceptance_criteria || "||").split("|");
parts[1] = e.target.value;
updateCopyInspRow(key, row.id, "acceptance_criteria", parts.join("|"));
}} placeholder="±" disabled={!row.inspection_standard_id} />
</div>
) : (
<Input className="h-7 text-[10px]" value={row.acceptance_criteria} onChange={(e) => updateCopyInspRow(key, row.id, "acceptance_criteria", e.target.value)} placeholder="합격기준" disabled={!row.inspection_standard_id} />
)}
</TableCell>
<TableCell className="p-1 text-center"><Checkbox checked={row.is_required} onCheckedChange={(v) => updateCopyInspRow(key, row.id, "is_required", !!v)} /></TableCell>
<TableCell className="p-1 text-[10px] text-muted-foreground">{row.unit || "-"}</TableCell>
<TableCell className="p-1">
<Button type="button" variant="destructive" size="sm" className="h-6 w-6 p-0" onClick={() => removeCopyInspRow(key, row.id)}><Trash2 className="w-3 h-3" /></Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)}
</div>
))}
</div>
</div>
</div>
</div>
</>)}
)}
<DialogFooter className="shrink-0">
<Button variant="outline" onClick={() => setCopyModalOpen(false)} disabled={copying}></Button>
<Button onClick={handleCopy} disabled={copying || copyCheckedIds.length === 0}>
@@ -358,13 +358,15 @@ export default function LogisticsInfoPage() {
loadReferences();
}, [loadReferences]);
// 카테고리 옵션 로드
// 카테고리 옵션 로드 (관리자 계정일 때 filterCompanyCode 미제공 시 "*" 스코프로 빈 결과 반환됨)
const loadCategoryOptions = useCallback(async (tableColumn: string) => {
if (loadedCategories.current.has(tableColumn)) return;
loadedCategories.current.add(tableColumn);
const [tableName, columnName] = tableColumn.split(":");
try {
const res = await apiClient.get(`/table-categories/${tableName}/${columnName}/values`);
const res = await apiClient.get(
`/table-categories/${tableName}/${columnName}/values?filterCompanyCode=COMPANY_8`
);
const data = res.data?.data || [];
setCategoryOptions((prev) => ({
...prev,
@@ -823,13 +825,24 @@ export default function LogisticsInfoPage() {
{/* 테이블 영역 */}
<div className="flex-1 overflow-auto">
<EDataTable
columns={getVisibleColumns(tab.key).map((col): EDataTableColumn => ({
key: col.key,
label: col.label,
align: col.align,
formatNumber: col.formatNumber,
truncate: true,
}))}
columns={getVisibleColumns(tab.key).map((col): EDataTableColumn => {
// 같은 key의 formField에 categoryKey가 있으면 코드→라벨 변환
const formField = tab.formFields.find((f) => f.key === col.key && f.categoryKey);
return {
key: col.key,
label: col.label,
align: col.align,
formatNumber: col.formatNumber,
truncate: true,
render: formField?.categoryKey
? (value: any) => {
const opts = categoryOptions[formField.categoryKey!] || [];
const matched = opts.find((o: any) => o.value === value);
return matched?.label || value || "-";
}
: undefined,
};
})}
data={tsMap[tab.key].groupData(displayData)}
rowKey={(row: any) => String(row.id)}
loading={tabLoading[tab.key]}
@@ -186,12 +186,12 @@ export default function InventoryStatusPage() {
};
load();
// 사용자 목록 로드
apiClient.get("/admin/users", { params: { size: 9999 } }).then((res) => {
const users = res.data?.data || res.data || [];
apiClient.get("/admin/users/name-map").then((res) => {
const users = res.data?.data || [];
const map: Record<string, string> = {};
for (const u of users) {
const id = u.userId || u.user_id || u.id;
const name = u.user_name || u.name || id;
const id = u.user_id;
const name = u.user_name || id;
if (id) map[id] = name;
}
setUserMap(map);
@@ -628,7 +628,7 @@ export default function MaterialStatusPage() {
className="inline-flex items-center gap-1 rounded bg-muted/40 px-2 py-0.5 text-xs transition-colors hover:bg-muted/60"
>
<span className="font-semibold font-mono text-primary">
{loc.location || loc.warehouse}
{loc.warehouse_name || loc.location || loc.warehouse}
</span>
<span className="font-semibold">
{loc.qty.toLocaleString()}
@@ -158,6 +158,10 @@ export default function WarehouseManagementPage() {
const [rackStatus, setRackStatus] = useState("");
const [rackPreview, setRackPreview] = useState<any[]>([]);
const [rackSaving, setRackSaving] = useState(false);
// 위치명 접미사 (자동 조립: {zone}{구역접미사}-{row}{열접미사}-{level}{단접미사})
const [rackZoneLabel, setRackZoneLabel] = useState("구역");
const [rackRowLabel, setRackRowLabel] = useState("열");
const [rackLevelLabel, setRackLevelLabel] = useState("단");
// 카테고리 옵션
const [categoryOptions, setCategoryOptions] = useState<
@@ -636,7 +640,7 @@ export default function WarehouseManagementPage() {
duplicates.push(locationCode);
continue;
}
const locationName = `${zoneCode}구역-${rowStr}열-${level}`;
const locationName = `${zoneCode}${rackZoneLabel}-${rowStr}${rackRowLabel}-${level}${rackLevelLabel}`;
items.push({
location_code: locationCode,
location_name: locationName,
@@ -1502,6 +1506,38 @@ export default function WarehouseManagementPage() {
</div>
</div>
{/* 위치명 형식 — 구역/열/단 뒤에 붙일 표현만 자유 입력 */}
<div className="rounded-lg border p-3 bg-muted/30 space-y-2">
<Label className="text-xs font-semibold"> </Label>
<div className="flex items-center gap-1 text-xs flex-wrap">
<span className="font-mono text-muted-foreground">A</span>
<Input
value={rackZoneLabel}
onChange={(e) => setRackZoneLabel(e.target.value)}
placeholder="구역"
className="h-8 w-20 text-xs"
/>
<span className="font-mono text-muted-foreground">- 01</span>
<Input
value={rackRowLabel}
onChange={(e) => setRackRowLabel(e.target.value)}
placeholder="열"
className="h-8 w-20 text-xs"
/>
<span className="font-mono text-muted-foreground">- 1</span>
<Input
value={rackLevelLabel}
onChange={(e) => setRackLevelLabel(e.target.value)}
placeholder="단"
className="h-8 w-20 text-xs"
/>
</div>
<p className="text-[11px] text-muted-foreground">
: <span className="font-mono font-semibold">A{rackZoneLabel}-01{rackRowLabel}-1{rackLevelLabel}</span>
{" "} // , .
</p>
</div>
{/* 등록 미리보기 */}
<div>
<div className="flex items-center justify-between mb-3">
@@ -59,6 +59,7 @@ import {
Settings2,
Save,
Package,
Pencil,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
@@ -355,7 +356,13 @@ export default function BomManagementPage() {
sort: { columnName: "created_at", order: "desc" },
});
const rows = res.data?.data?.data || res.data?.data?.rows || [];
// DB 컬럼이 item_type/expired_date → 프론트 내부에서는 bom_type/expiry_date로 통일
const rawRows = res.data?.data?.data || res.data?.data?.rows || [];
const rows = rawRows.map((r: any) => ({
...r,
bom_type: r.bom_type ?? r.item_type,
expiry_date: r.expiry_date ?? r.expired_date,
}));
setBomList(rows);
setTotalCount(rows.length);
} catch (err: any) {
@@ -452,9 +459,16 @@ export default function BomManagementPage() {
const fetchBomDetail = useCallback(async (bomId: string) => {
setDetailLoading(true);
try {
// 헤더 조회
// 헤더 조회 (DB 컬럼 item_type/expired_date → bom_type/expiry_date로 매핑)
const headerRes = await apiClient.get(`/bom/${bomId}/header`);
const header = headerRes.data?.data || headerRes.data;
const rawHeader = headerRes.data?.data || headerRes.data;
const header = rawHeader
? {
...rawHeader,
bom_type: rawHeader.bom_type ?? rawHeader.item_type,
expiry_date: rawHeader.expiry_date ?? rawHeader.expired_date,
}
: null;
setBomHeader(header);
setCurrentVersionId(header?.current_version_id || null);
@@ -1100,17 +1114,18 @@ export default function BomManagementPage() {
setSaving(true);
try {
// DB 실제 컬럼: item_type / expired_date (프론트 내부 bom_type/expiry_date와 다름)
const bomFields: Record<string, any> = {
item_id: masterForm.item_id,
item_code: masterForm.item_code,
item_name: masterForm.item_name,
bom_type: masterForm.bom_type,
item_type: masterForm.bom_type,
base_qty: masterForm.base_qty || "1",
unit: masterForm.unit || "",
version: masterForm.version || "1.0",
status: masterForm.status || "draft",
effective_date: masterForm.effective_date || null,
expiry_date: masterForm.expiry_date || null,
expired_date: masterForm.expiry_date || null,
remark: masterForm.remark || "",
writer: user?.userId || "",
company_code: user?.company_code || "",
@@ -1482,6 +1497,21 @@ export default function BomManagementPage() {
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
if (!selectedBomId || !bomHeader) {
toast.error("수정할 BOM을 선택해주세요");
return;
}
openEditModal();
}}
disabled={!selectedBomId || !bomHeader}
>
<Pencil className="w-3.5 h-3.5 mr-1" />
</Button>
<div className="w-px h-4 bg-border mx-0.5" />
<Button
size="sm"
@@ -12,6 +12,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
import {
@@ -91,8 +92,8 @@ export function ItemRoutingTab() {
const [formFixedOrder, setFormFixedOrder] = useState("Y");
const [formWorkType, setFormWorkType] = useState("내부");
const [formStandardTime, setFormStandardTime] = useState("");
const [formOutsource, setFormOutsource] = useState("");
const [subcontractorOptions, setSubcontractorOptions] = useState<{ code: string; name: string }[]>([]);
const [formOutsources, setFormOutsources] = useState<string[]>([]);
const [subcontractorOptions, setSubcontractorOptions] = useState<{ id: string; code: string; name: string }[]>([]);
const [detailSubmitting, setDetailSubmitting] = useState(false);
const [registerDialogOpen, setRegisterDialogOpen] = useState(false);
@@ -116,7 +117,7 @@ export function ItemRoutingTab() {
page: 1, size: 500, autoFilter: true,
});
const rows = res.data?.data?.data || res.data?.data?.rows || [];
setSubcontractorOptions(rows.map((r: any) => ({ code: r.subcontractor_code || r.id, name: r.subcontractor_name || "" })));
setSubcontractorOptions(rows.map((r: any) => ({ id: r.id, code: r.subcontractor_code || "", name: r.subcontractor_name || "" })));
} catch { /* skip */ }
})();
}, []);
@@ -281,7 +282,7 @@ export function ItemRoutingTab() {
setFormFixedOrder("Y");
setFormWorkType("내부");
setFormStandardTime("");
setFormOutsource("");
setFormOutsources([]);
setDetailDialogOpen(true);
};
@@ -308,7 +309,19 @@ export function ItemRoutingTab() {
setFormFixedOrder(row.is_fixed_order === "N" ? "N" : "Y");
setFormWorkType(row.work_type || "내부");
setFormStandardTime(row.standard_time || "");
setFormOutsource(row.outsource_supplier || "");
// 우선순위: id 배열 → legacy code 배열(id로 역변환) → legacy 단일 code(id로 역변환)
let loadedIds: string[] = [];
if (Array.isArray(row.outsource_supplier_ids) && row.outsource_supplier_ids.length > 0) {
loadedIds = row.outsource_supplier_ids;
} else {
const legacyCodes = Array.isArray(row.outsource_supplier_list) && row.outsource_supplier_list.length > 0
? row.outsource_supplier_list
: (row.outsource_supplier ? [row.outsource_supplier] : []);
loadedIds = legacyCodes
.map((c: string) => subcontractorOptions.find((s) => s.code === c)?.id)
.filter((v): v is string => Boolean(v));
}
setFormOutsources(loadedIds);
setDetailDialogOpen(true);
};
@@ -329,7 +342,10 @@ export function ItemRoutingTab() {
return;
}
const proc = processes.find((p) => p.process_code === formProcessCode);
const outsource = showOutsourceField ? formOutsource.trim() : "";
const outsourceIds = showOutsourceField ? formOutsources.filter((s) => s && s.trim() !== "") : [];
const outsourcePrimaryCode = outsourceIds.length > 0
? (subcontractorOptions.find((s) => s.id === outsourceIds[0])?.code || "")
: "";
setDetailSubmitting(true);
try {
@@ -344,7 +360,8 @@ export function ItemRoutingTab() {
is_fixed_order: formFixedOrder,
work_type: formWorkType,
standard_time: st || "0",
outsource_supplier: outsource,
outsource_supplier: outsourcePrimaryCode,
outsource_supplier_ids: outsourceIds,
};
setDetails((prev) => sortDetailsBySeq([...prev, newRow]));
toast.success("공정이 추가되었어요. 저장을 눌러 반영해주세요");
@@ -362,7 +379,8 @@ export function ItemRoutingTab() {
is_fixed_order: formFixedOrder,
work_type: formWorkType,
standard_time: st || "0",
outsource_supplier: outsource,
outsource_supplier: outsourcePrimaryCode,
outsource_supplier_ids: outsourceIds,
}
: d,
),
@@ -399,6 +417,7 @@ export function ItemRoutingTab() {
work_type: d.work_type || "내부",
standard_time: String(d.standard_time ?? "0"),
outsource_supplier: d.outsource_supplier || "",
outsource_supplier_ids: d.outsource_supplier_ids || [],
}));
setSaving(true);
@@ -480,11 +499,23 @@ export function ItemRoutingTab() {
const detailsGridData = useMemo(
() =>
details.map((d) => ({
...d,
process_display: d.process_name || d.process_code,
outsource_display: subcontractorOptions.find((s) => s.code === d.outsource_supplier)?.name || d.outsource_supplier || "—",
})),
details.map((d) => {
const ids = Array.isArray(d.outsource_supplier_ids) && d.outsource_supplier_ids.length > 0
? d.outsource_supplier_ids
: [];
let names = ids
.map((i) => subcontractorOptions.find((s) => s.id === i)?.name)
.filter((v): v is string => Boolean(v));
// 레거시 폴백: id 매핑 없을 때 단일 code로 표시
if (names.length === 0 && d.outsource_supplier) {
names = [subcontractorOptions.find((s) => s.code === d.outsource_supplier)?.name || d.outsource_supplier];
}
return {
...d,
process_display: d.process_name || d.process_code,
outsource_display: names.length === 0 ? "—" : names.join(", "),
};
}),
[details, subcontractorOptions],
);
@@ -909,15 +940,46 @@ export function ItemRoutingTab() {
</div>
{showOutsourceField && (
<div className="space-y-1">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Select value={formOutsource || ""} onValueChange={setFormOutsource}>
<SelectTrigger className="h-9"><SelectValue placeholder="외주업체 선택" /></SelectTrigger>
<SelectContent>
{subcontractorOptions.map((s) => (
<SelectItem key={s.code} value={s.code}>{s.name}</SelectItem>
))}
</SelectContent>
</Select>
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"> ( )</Label>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className="h-9 w-full justify-between font-normal">
<span className="truncate text-left text-sm">
{formOutsources.length === 0
? "외주업체 선택"
: formOutsources
.map((i) => subcontractorOptions.find((s) => s.id === i)?.name || i)
.join(", ")}
</span>
<Badge variant="secondary" className="ml-2 shrink-0">{formOutsources.length}</Badge>
</Button>
</PopoverTrigger>
<PopoverContent className="w-[320px] p-0" align="start">
<div className="max-h-64 overflow-y-auto p-2 space-y-1">
{subcontractorOptions.length === 0 ? (
<div className="text-xs text-muted-foreground px-2 py-3"> </div>
) : subcontractorOptions.map((s) => {
const checked = formOutsources.includes(s.id);
return (
<label
key={s.id}
className="flex items-center gap-2 rounded px-2 py-1.5 text-sm cursor-pointer hover:bg-muted"
>
<Checkbox
checked={checked}
onCheckedChange={(v) => {
setFormOutsources((prev) =>
v ? [...prev, s.id] : prev.filter((i) => i !== s.id),
);
}}
/>
<span className="truncate">{s.name}</span>
</label>
);
})}
</div>
</PopoverContent>
</Popover>
</div>
)}
</div>
@@ -52,6 +52,7 @@ const INSPECTION_COLUMNS = [
{ key: "inspection_code", label: "검사코드" },
{ key: "inspection_type", label: "검사유형" },
{ key: "inspection_criteria", label: "검사기준" },
{ key: "criteria_detail", label: "기준상세" },
{ key: "inspection_item", label: "검사항목" },
{ key: "inspection_method", label: "검사방법" },
{ key: "judgment_criteria", label: "판단기준" },
@@ -43,6 +43,7 @@ type InspectionRow = {
inspection_detail: string;
inspection_method: string;
apply_process: string;
classification: string;
acceptance_criteria: string;
is_required: boolean;
judgment_criteria?: string; // 판단기준 라벨 (수치(범위)/텍스트입력/O·X/선택형)
@@ -253,6 +254,11 @@ export default function ItemInspectionInfoPage() {
loadProcessOptions(item.code);
};
// 복사 모달: 편집 가능한 기준 데이터 상태 (등록/수정 폼과 평행 구조)
const [copyForm, setCopyForm] = useState<Record<string, any>>({});
const [copyInspectionRows, setCopyInspectionRows] = useState<Record<string, InspectionRow[]>>({});
const [copyCollapsedTypes, setCopyCollapsedTypes] = useState<Record<string, boolean>>({});
/* ═══════════════════ 복사 모달 (기준 품목 검사정보 → 다른 품목들) ═══════════════════ */
const [copyModalOpen, setCopyModalOpen] = useState(false);
const [copySearchKeyword, setCopySearchKeyword] = useState("");
@@ -294,11 +300,63 @@ export default function ItemInspectionInfoPage() {
setCopyTotal(resData?.total || resData?.totalCount || rows.length);
} catch { /* skip */ } finally { setCopySearchLoading(false); }
};
const openCopyModal = () => {
const openCopyModal = async () => {
if (!selectedItemCode) { toast.error("복사 기준 품목을 먼저 선택해주세요"); return; }
const srcGroup = groupedData.find(g => g.item_code === selectedItemCode);
if (!srcGroup || srcGroup.rows.length === 0) { toast.error("선택한 품목에 복사할 검사정보가 없어요"); return; }
setCopySearchKeyword(""); setCopyPage(1); setCopyCheckedIds([]);
// 기준 품목 데이터를 편집용 상태로 복제 (openEdit과 동일한 변환 로직)
const baseRow = srcGroup.rows[0];
try {
const res = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, {
page: 1, size: 0,
dataFilter: { enabled: true, filters: [{ columnName: "item_code", operator: "equals", value: selectedItemCode }] },
autoFilter: true,
});
const allRows = res.data?.data?.data || res.data?.data?.rows || [];
const rowMap: Record<string, InspectionRow[]> = {};
const typeFlags: Record<string, boolean> = {};
for (const r of allRows) {
const inspType = r.inspection_type || "";
const matched = INSPECTION_TYPES.find(t =>
t.matchLabels.some(ml => inspType.includes(ml)) ||
inspTypeCatOptions.some(cat => inspType.includes(cat.code) && t.matchLabels.some(ml => cat.label.includes(ml)))
);
const typeKey = matched?.key || "";
if (!typeKey) continue;
typeFlags[typeKey] = true;
if (!rowMap[typeKey]) rowMap[typeKey] = [];
const mCode = r.inspection_method || "";
const mLabel = inspMethodCatOptions.find(o => o.code === mCode)?.label || mCode;
const inspOpt = inspOptions.find(o => o.code === r.inspection_standard_id);
const jcCode = inspOpt?.judgment_criteria || "";
const jcLabel = judgmentCatOptions.find(c => c.code === jcCode)?.label || jcCode;
const unitCode = inspOpt?.unit || "";
const unitLabel = inspUnitCatOptions.find(c => c.code === unitCode)?.label || unitCode;
rowMap[typeKey].push({
id: crypto.randomUUID(), // 복사본은 새 id 부여 (원본과 분리)
inspection_standard_id: r.inspection_standard_id || "",
inspection_detail: r.inspection_item_name || r.inspection_standard || "",
inspection_method: mLabel,
apply_process: r.apply_process || "",
classification: r.classification || "",
acceptance_criteria: r.pass_criteria || "",
is_required: r.is_required === "true" || r.is_required === true,
judgment_criteria: jcLabel,
selection_options: inspOpt?.selection_options || "",
unit: unitLabel,
});
}
setCopyInspectionRows(rowMap);
setCopyForm({ ...baseRow, ...typeFlags });
setCopyCollapsedTypes({});
} catch {
setCopyInspectionRows({});
setCopyForm({ ...baseRow });
setCopyCollapsedTypes({});
}
setCopyModalOpen(true);
searchCopyTargets(1);
};
@@ -309,10 +367,18 @@ export default function ItemInspectionInfoPage() {
const handleCopy = async () => {
if (!selectedItemCode) { toast.error("복사 기준 품목이 없어요"); return; }
if (copyCheckedIds.length === 0) { toast.error("붙여넣을 품목을 선택해주세요"); return; }
const sourceGroup = groupedData.find(g => g.item_code === selectedItemCode);
if (!sourceGroup || sourceGroup.rows.length === 0) { toast.error("복사할 검사정보가 없어요"); return; }
// 편집된 rows를 평탄화 (선택된 검사유형의 rows만)
const enabledTypes = INSPECTION_TYPES.filter(t => !!copyForm[t.key]);
const flatRows: Array<{ row: InspectionRow; typeLabel: string }> = [];
for (const t of enabledTypes) {
const rows = copyInspectionRows[t.key] || [];
for (const r of rows) flatRows.push({ row: r, typeLabel: t.label });
}
if (flatRows.length === 0) { toast.error("복사할 검사항목이 없어요"); return; }
const ok = await confirm(
`선택한 ${copyCheckedIds.length}개 품목에 검사정보를 복사할까요?`,
`선택한 ${copyCheckedIds.length}개 품목에 편집된 검사정보(${flatRows.length}개 행)를 복사할까요?`,
{ description: "대상 품목의 기존 검사정보는 삭제 후 교체됩니다.", variant: "info", confirmText: "복사" }
);
if (!ok) return;
@@ -333,13 +399,26 @@ export default function ItemInspectionInfoPage() {
if (existing.length > 0) {
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, { data: existing.map((r: any) => ({ id: r.id })) });
}
for (const r of sourceGroup.rows) {
const { id: _id, created_at: _c, updated_at: _u, ...rest } = r;
let orderSeq = 0;
for (const { row: r, typeLabel } of flatRows) {
orderSeq += 1;
await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, {
...rest,
id: crypto.randomUUID(),
item_code: targetCode,
item_name: targetName,
inspection_type: typeLabel,
inspection_standard_id: r.inspection_standard_id || "",
inspection_item_name: r.inspection_detail || "",
inspection_method: r.inspection_method || "",
apply_process: r.apply_process || "",
classification: r.classification || "",
pass_criteria: r.acceptance_criteria || "",
is_required: r.is_required ? "true" : "false",
is_active: copyForm.is_active || "사용",
manager: copyForm.manager || "",
manager_id: copyForm.manager_id || "",
memo: copyForm.remarks || "",
sort_order: String(orderSeq).padStart(4, "0"),
});
}
setCopyProgress({ current: i + 1, total: copyCheckedIds.length });
@@ -402,7 +481,13 @@ export default function ItemInspectionInfoPage() {
// 선택된 탭의 검사항목 행
const selectedTabRows = useMemo(() => {
if (!selectedGroup || !selectedTypeTab) return [];
return selectedGroup.rows.filter((r: any) => r.inspection_type === selectedTypeTab && r.inspection_standard_id);
const filtered = selectedGroup.rows.filter((r: any) => r.inspection_type === selectedTypeTab && r.inspection_standard_id);
return [...filtered].sort((a: any, b: any) => {
const av = parseInt(String(a.sort_order || "9999"), 10);
const bv = parseInt(String(b.sort_order || "9999"), 10);
if (av === bv) return String(a.id).localeCompare(String(b.id));
return av - bv;
});
}, [selectedGroup, selectedTypeTab]);
// 검사기준 ID → 라벨
@@ -436,6 +521,13 @@ export default function ItemInspectionInfoPage() {
autoFilter: true,
});
const allRows = res.data?.data?.data || res.data?.data?.rows || [];
// sort_order 기준 오름차순 정렬 (varchar이므로 숫자 파싱 후 비교)
allRows.sort((a: any, b: any) => {
const av = parseInt(String(a.sort_order || "9999"), 10);
const bv = parseInt(String(b.sort_order || "9999"), 10);
if (av === bv) return String(a.id).localeCompare(String(b.id));
return av - bv;
});
const rowMap: Record<string, InspectionRow[]> = {};
const typeFlags: Record<string, boolean> = {};
@@ -462,7 +554,8 @@ export default function ItemInspectionInfoPage() {
inspection_standard_id: r.inspection_standard_id || "",
inspection_detail: r.inspection_item_name || r.inspection_standard || "",
inspection_method: mLabel,
apply_process: "",
apply_process: r.apply_process || "",
classification: r.classification || "",
acceptance_criteria: r.pass_criteria || "",
is_required: r.is_required === "true" || r.is_required === true,
judgment_criteria: jcLabel,
@@ -480,7 +573,7 @@ export default function ItemInspectionInfoPage() {
const addInspRow = (typeKey: string) => {
setInspectionRows(prev => ({
...prev,
[typeKey]: [...(prev[typeKey] || []), { id: crypto.randomUUID(), inspection_standard_id: "", inspection_detail: "", inspection_method: "", apply_process: "", acceptance_criteria: "", is_required: false }],
[typeKey]: [...(prev[typeKey] || []), { id: crypto.randomUUID(), inspection_standard_id: "", inspection_detail: "", inspection_method: "", apply_process: "", classification: "", acceptance_criteria: "", is_required: false }],
}));
};
const removeInspRow = (typeKey: string, rowId: string) => {
@@ -525,6 +618,46 @@ export default function ItemInspectionInfoPage() {
};
const toggleCollapse = (typeKey: string) => { setCollapsedTypes(prev => ({ ...prev, [typeKey]: !prev[typeKey] })); };
/* ═══════════════════ 복사 모달용 검사항목 행 관리 (등록 폼과 평행) ═══════════════════ */
const addCopyInspRow = (typeKey: string) => {
setCopyInspectionRows(prev => ({
...prev,
[typeKey]: [...(prev[typeKey] || []), { id: crypto.randomUUID(), inspection_standard_id: "", inspection_detail: "", inspection_method: "", apply_process: "", classification: "", acceptance_criteria: "", is_required: false }],
}));
};
const removeCopyInspRow = (typeKey: string, rowId: string) => {
setCopyInspectionRows(prev => ({ ...prev, [typeKey]: (prev[typeKey] || []).filter(r => r.id !== rowId) }));
};
const updateCopyInspRow = (typeKey: string, rowId: string, field: string, value: any) => {
setCopyInspectionRows(prev => ({
...prev,
[typeKey]: (prev[typeKey] || []).map(r => {
if (r.id !== rowId) return r;
if (field === "inspection_standard_id") {
const opt = inspOptions.find(o => o.code === value);
const methodCode = opt?.method || "";
const methodLabel = inspMethodCatOptions.find(o => o.code === methodCode)?.label || methodCode;
const jcCode = opt?.judgment_criteria || "";
const jcLabel = judgmentCatOptions.find(c => c.code === jcCode)?.label || jcCode;
const unitCode = opt?.unit || "";
const unitLabel = inspUnitCatOptions.find(c => c.code === unitCode)?.label || unitCode;
return {
...r,
inspection_standard_id: value,
inspection_detail: opt?.detail || "",
inspection_method: methodLabel,
judgment_criteria: jcLabel,
selection_options: opt?.selection_options || "",
unit: unitLabel,
acceptance_criteria: "",
};
}
return { ...r, [field]: value };
}),
}));
};
const toggleCopyCollapse = (typeKey: string) => { setCopyCollapsedTypes(prev => ({ ...prev, [typeKey]: !prev[typeKey] })); };
const handleSave = async () => {
if (!form.item_code) { toast.error("품목코드는 필수예요"); return; }
setSaving(true);
@@ -542,18 +675,23 @@ export default function ItemInspectionInfoPage() {
}
const enabledTypes = INSPECTION_TYPES.filter(t => !!form[t.key]);
const rows: any[] = [];
let globalOrder = 0;
for (const t of enabledTypes) {
const typeRows = inspectionRows[t.key] || [];
if (typeRows.length === 0) {
rows.push({ id: crypto.randomUUID(), item_code: form.item_code, item_name: form.item_name, inspection_type: t.label, is_active: form.is_active || "사용", manager_id: form.manager_id || "", memo: form.remarks || "" });
globalOrder += 1;
rows.push({ id: crypto.randomUUID(), item_code: form.item_code, item_name: form.item_name, inspection_type: t.label, is_active: form.is_active || "사용", manager_id: form.manager_id || "", memo: form.remarks || "", sort_order: String(globalOrder).padStart(4, "0") });
} else {
for (const r of typeRows) {
globalOrder += 1;
rows.push({
id: crypto.randomUUID(), item_code: form.item_code, item_name: form.item_name, inspection_type: t.label,
inspection_standard_id: r.inspection_standard_id || "", inspection_item_name: r.inspection_detail || "",
inspection_method: r.inspection_method || "", pass_criteria: r.acceptance_criteria || "",
apply_process: r.apply_process || "", classification: r.classification || "",
is_required: r.is_required ? "true" : "false", is_active: form.is_active || "사용",
manager_id: form.manager_id || "", memo: form.remarks || "",
sort_order: String(globalOrder).padStart(4, "0"),
});
}
}
@@ -974,6 +1112,7 @@ export default function ItemInspectionInfoPage() {
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8 w-[50px]"></TableHead>
@@ -983,7 +1122,7 @@ export default function ItemInspectionInfoPage() {
<TableBody>
{selectedTabRows.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="text-center py-8 text-xs text-muted-foreground"> </TableCell>
<TableCell colSpan={9} className="text-center py-8 text-xs text-muted-foreground"> </TableCell>
</TableRow>
) : selectedTabRows.map((row: any) => (
<TableRow key={row.id}>
@@ -1002,6 +1141,7 @@ export default function ItemInspectionInfoPage() {
const proc = processOptions.find(p => p.code === code);
return proc?.name || code;
})()}</TableCell>
<TableCell className="text-xs py-2">{row.classification || "-"}</TableCell>
<TableCell className="text-xs py-2">
{(() => {
const insp = inspOptions.find(o => o.code === row.inspection_standard_id);
@@ -1185,6 +1325,7 @@ export default function ItemInspectionInfoPage() {
<TableHead className="text-[10px] font-bold w-[130px]"> </TableHead>
<TableHead className="text-[10px] font-bold w-[90px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[90px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[100px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[80px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[200px]"> ()</TableHead>
<TableHead className="text-[10px] font-bold w-[40px]"></TableHead>
@@ -1194,7 +1335,7 @@ export default function ItemInspectionInfoPage() {
</TableHeader>
<TableBody>
{(!inspectionRows[key] || inspectionRows[key].length === 0) ? (
<TableRow><TableCell colSpan={9} className="text-center py-4 text-xs text-muted-foreground"> </TableCell></TableRow>
<TableRow><TableCell colSpan={10} className="text-center py-4 text-xs text-muted-foreground"> </TableCell></TableRow>
) : inspectionRows[key].map((row) => (
<TableRow key={row.id}>
<TableCell className="p-1">
@@ -1219,6 +1360,9 @@ export default function ItemInspectionInfoPage() {
<Input className="h-8 text-xs" value={row.apply_process} onChange={(e) => updateInspRow(key, row.id, "apply_process", e.target.value)} placeholder="공정" />
)}
</TableCell>
<TableCell className="p-1">
<Input className="h-8 text-xs" value={row.classification || ""} onChange={(e) => updateInspRow(key, row.id, "classification", e.target.value)} placeholder="구분 입력" />
</TableCell>
<TableCell className="p-1 text-center">
{row.judgment_criteria ? <Badge variant="outline" className="text-[10px]">{row.judgment_criteria}</Badge> : <span className="text-[10px] text-muted-foreground">-</span>}
</TableCell>
@@ -1285,20 +1429,20 @@ export default function ItemInspectionInfoPage() {
</DialogContent>
</Dialog>
{/* ═══════════════════ 복사 모달 ═══════════════════ */}
{/* ═══════════════════ 복사 모달 (2단 분할: 좌 대상 / 우 편집) ═══════════════════ */}
<Dialog open={copyModalOpen} onOpenChange={(v) => { if (!copying) setCopyModalOpen(v); }}>
<DialogContent
className="max-w-3xl w-[95vw] max-h-[85vh] flex flex-col overflow-hidden"
className="max-w-[95vw] sm:max-w-[1400px] w-[95vw] max-h-[90vh] flex flex-col overflow-hidden"
onPointerDownOutside={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => { if (copying) e.preventDefault(); }}
>
<DialogHeader>
<DialogHeader className="shrink-0">
<DialogTitle>{copying ? "검사정보 복사 중..." : "검사정보 복사"}</DialogTitle>
<DialogDescription>
<span className="font-medium text-foreground">{selectedGroup?.item_name || "-"}</span>
<span className="text-muted-foreground"> ({selectedItemCode})</span>
<span>{copying ? " 의 검사정보를 복사하고 있습니다" : " 의 검사정보를 아래 선택한 품목들에 복사합니다"}</span>
<span>{copying ? " 의 검사정보를 복사하고 있습니다" : " 의 검사정보를 편집해서 선택한 품목들에 복사합니다. 기준 품목은 변경되지 않아요"}</span>
</DialogDescription>
</DialogHeader>
{copying ? (
@@ -1322,81 +1466,229 @@ export default function ItemInspectionInfoPage() {
</p>
</div>
</div>
) : (<>
<div className="flex gap-2 shrink-0">
<Input className="h-9 flex-1" placeholder="품목코드 또는 품목명" value={copySearchKeyword}
onChange={(e) => setCopySearchKeyword(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") handleCopySearch(); }} />
<Button size="sm" className="h-9" onClick={handleCopySearch} disabled={copySearchLoading}>
{copySearchLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <><Search className="w-4 h-4 mr-1" /></>}
</Button>
</div>
<div className="flex-1 border rounded-lg overflow-auto">
<Table>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-[40px] text-center">
<Checkbox
checked={copyFilteredItems.length > 0 && copyFilteredItems.every(i => copyCheckedIds.includes(i.code))}
onCheckedChange={(v) => {
if (v) setCopyCheckedIds(prev => Array.from(new Set([...prev, ...copyFilteredItems.map(i => i.code)])));
else setCopyCheckedIds(prev => prev.filter(c => !copyFilteredItems.some(i => i.code === c)));
}}
/>
</TableHead>
<TableHead className="text-[11px] font-bold w-[140px]"></TableHead>
<TableHead className="text-[11px] font-bold"></TableHead>
<TableHead className="text-[11px] font-bold w-[100px]"></TableHead>
<TableHead className="text-[11px] font-bold w-[80px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{copyFilteredItems.length === 0 ? (
<TableRow><TableCell colSpan={5} className="text-center py-8 text-muted-foreground text-sm">
{copySearchLoading ? "검색 중..." : "검색 결과가 없어요"}
</TableCell></TableRow>
) : copyFilteredItems.map((item) => (
<TableRow key={item.code} className="cursor-pointer hover:bg-primary/5"
onClick={() => toggleCopyChecked(item.code)}>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<Checkbox checked={copyCheckedIds.includes(item.code)} onCheckedChange={() => toggleCopyChecked(item.code)} />
</TableCell>
<TableCell className="text-sm font-mono">{item.code}</TableCell>
<TableCell className="text-sm">{item.name}</TableCell>
<TableCell className="text-sm">{item.item_type}</TableCell>
<TableCell className="text-sm">{item.unit}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="shrink-0 flex items-center justify-between border-t px-2 py-2 text-xs text-muted-foreground">
<span>
<span className="font-medium text-foreground">{copyTotal.toLocaleString()}</span>
{copyCheckedIds.length > 0 && <span className="ml-2"> <span className="font-medium text-primary">{copyCheckedIds.length}</span></span>}
</span>
<div className="flex items-center gap-0.5">
<button onClick={() => { setCopyPage(1); searchCopyTargets(1); }} disabled={copyPage <= 1}
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronsLeft className="h-3.5 w-3.5" /></button>
<button onClick={() => { const p = copyPage - 1; setCopyPage(p); searchCopyTargets(p); }} disabled={copyPage <= 1}
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronLeft className="h-3.5 w-3.5" /></button>
{Array.from({ length: Math.min(5, copyTotalPages) }, (_, i) => {
const start = Math.max(1, Math.min(copyPage - 2, copyTotalPages - 4));
const p = start + i;
if (p > copyTotalPages) return null;
return (
<button key={p} onClick={() => { setCopyPage(p); searchCopyTargets(p); }}
className={cn("h-7 w-7 flex items-center justify-center rounded text-xs",
p === copyPage ? "bg-primary text-primary-foreground font-medium" : "hover:bg-muted")}>{p}</button>
);
})}
<button onClick={() => { const p = copyPage + 1; setCopyPage(p); searchCopyTargets(p); }} disabled={copyPage >= copyTotalPages}
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronRight className="h-3.5 w-3.5" /></button>
<button onClick={() => { setCopyPage(copyTotalPages); searchCopyTargets(copyTotalPages); }} disabled={copyPage >= copyTotalPages}
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronsRight className="h-3.5 w-3.5" /></button>
) : (
<div className="flex-1 grid grid-cols-[420px_1fr] gap-4 overflow-hidden">
{/* 좌측: 복사 대상 품목 선택 */}
<div className="flex flex-col overflow-hidden border rounded-lg">
<div className="flex items-center justify-between border-b bg-muted/50 px-3 py-2">
<span className="text-xs font-semibold"> </span>
{copyCheckedIds.length > 0 && <span className="text-[10px] text-primary"> {copyCheckedIds.length}</span>}
</div>
<div className="flex gap-2 px-2 pt-2">
<Input className="h-8 flex-1 text-xs" placeholder="품목코드 또는 품목명" value={copySearchKeyword}
onChange={(e) => setCopySearchKeyword(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") handleCopySearch(); }} />
<Button size="sm" className="h-8 text-xs" onClick={handleCopySearch} disabled={copySearchLoading}>
{copySearchLoading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Search className="w-3.5 h-3.5" />}
</Button>
</div>
<div className="flex-1 overflow-auto mt-2">
<Table>
<TableHeader className="sticky top-0 z-10 bg-background">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-[36px] text-center text-[10px]">
<Checkbox
checked={copyFilteredItems.length > 0 && copyFilteredItems.every(i => copyCheckedIds.includes(i.code))}
onCheckedChange={(v) => {
if (v) setCopyCheckedIds(prev => Array.from(new Set([...prev, ...copyFilteredItems.map(i => i.code)])));
else setCopyCheckedIds(prev => prev.filter(c => !copyFilteredItems.some(i => i.code === c)));
}}
/>
</TableHead>
<TableHead className="text-[10px] font-bold w-[120px]"></TableHead>
<TableHead className="text-[10px] font-bold"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{copyFilteredItems.length === 0 ? (
<TableRow><TableCell colSpan={3} className="text-center py-6 text-muted-foreground text-xs">
{copySearchLoading ? "검색 중..." : "검색 결과가 없어요"}
</TableCell></TableRow>
) : copyFilteredItems.map((item) => (
<TableRow key={item.code} className="cursor-pointer hover:bg-primary/5"
onClick={() => toggleCopyChecked(item.code)}>
<TableCell className="text-center p-1" onClick={(e) => e.stopPropagation()}>
<Checkbox checked={copyCheckedIds.includes(item.code)} onCheckedChange={() => toggleCopyChecked(item.code)} />
</TableCell>
<TableCell className="text-xs font-mono p-1">{item.code}</TableCell>
<TableCell className="text-xs p-1 truncate">{item.name}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="border-t flex items-center justify-between px-2 py-1 text-[10px] text-muted-foreground">
<span> <span className="font-medium text-foreground">{copyTotal.toLocaleString()}</span></span>
<div className="flex items-center gap-0.5">
<button onClick={() => { setCopyPage(1); searchCopyTargets(1); }} disabled={copyPage <= 1}
className="h-6 w-6 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronsLeft className="h-3 w-3" /></button>
<button onClick={() => { const p = copyPage - 1; setCopyPage(p); searchCopyTargets(p); }} disabled={copyPage <= 1}
className="h-6 w-6 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronLeft className="h-3 w-3" /></button>
<span className="text-[10px] mx-1">{copyPage}/{copyTotalPages}</span>
<button onClick={() => { const p = copyPage + 1; setCopyPage(p); searchCopyTargets(p); }} disabled={copyPage >= copyTotalPages}
className="h-6 w-6 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronRight className="h-3 w-3" /></button>
<button onClick={() => { setCopyPage(copyTotalPages); searchCopyTargets(copyTotalPages); }} disabled={copyPage >= copyTotalPages}
className="h-6 w-6 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronsRight className="h-3 w-3" /></button>
</div>
</div>
</div>
{/* 우측: 편집 폼 (등록/수정 폼과 동일 구조) */}
<div className="flex flex-col overflow-hidden border rounded-lg">
<div className="border-b bg-muted/50 px-3 py-2">
<span className="text-xs font-semibold"> (: {selectedItemCode})</span>
</div>
<div className="flex-1 overflow-y-auto p-3 space-y-4">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-muted-foreground"></Label>
<Select value={copyForm.is_active === false || copyForm.is_active === "N" ? "N" : "Y"} onValueChange={(v) => setCopyForm(p => ({ ...p, is_active: v === "Y" ? "사용" : "미사용" }))}>
<SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="Y"></SelectItem>
<SelectItem value="N"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-muted-foreground"></Label>
<Select value={copyForm.manager || ""} onValueChange={(v) => setCopyForm(p => ({ ...p, manager: v }))}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="관리자 선택" /></SelectTrigger>
<SelectContent>{userOptions.map(o => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<h4 className="text-xs font-semibold"> </h4>
<div className="flex flex-wrap gap-3">
{INSPECTION_TYPES.map(({ key, label }) => (
<div key={key} className="flex items-center gap-1.5">
<Checkbox checked={!!copyForm[key]} onCheckedChange={(v) => setCopyForm(p => ({ ...p, [key]: !!v }))} />
<Label className="text-xs cursor-pointer">{label}</Label>
</div>
))}
</div>
</div>
{INSPECTION_TYPES.filter(t => !!copyForm[t.key]).map(({ key, label }) => (
<div key={key} className="space-y-1.5">
<button type="button" className="w-full flex items-center gap-2 py-1.5 px-2 rounded-md border bg-muted/50 hover:bg-muted text-left" onClick={() => toggleCopyCollapse(key)}>
<Badge variant="default" className="text-[10px]">{label}</Badge>
<span className="text-xs font-medium"> </span>
<span className="text-[10px] text-muted-foreground ml-auto">{(copyInspectionRows[key] || []).length}</span>
</button>
{!copyCollapsedTypes[key] && (
<div className="space-y-1.5 pl-1">
<div className="flex items-center justify-between">
<span className="text-[10px] font-semibold text-muted-foreground"> </span>
<Button type="button" size="sm" variant="outline" className="h-6 text-[10px]" onClick={() => addCopyInspRow(key)}>
<Plus className="w-3 h-3 mr-1" />
</Button>
</div>
<div className="border rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow className="bg-muted/50 hover:bg-muted/50">
<TableHead className="text-[10px] font-bold w-[150px]"> </TableHead>
<TableHead className="text-[10px] font-bold w-[110px]"> </TableHead>
<TableHead className="text-[10px] font-bold w-[80px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[80px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[90px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[70px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[180px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[36px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[60px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[32px]" />
</TableRow>
</TableHeader>
<TableBody>
{(!copyInspectionRows[key] || copyInspectionRows[key].length === 0) ? (
<TableRow><TableCell colSpan={10} className="text-center py-3 text-[10px] text-muted-foreground"> </TableCell></TableRow>
) : copyInspectionRows[key].map((row) => (
<TableRow key={row.id}>
<TableCell className="p-1">
<Select value={row.inspection_standard_id || ""} onValueChange={(v) => updateCopyInspRow(key, row.id, "inspection_standard_id", v)}>
<SelectTrigger className="h-7 text-[10px]"><SelectValue placeholder="검사기준" /></SelectTrigger>
<SelectContent>{getFilteredInspOptions(key).map(o => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}</SelectContent>
</Select>
</TableCell>
<TableCell className="p-1"><Input className="h-7 text-[10px] bg-muted" value={row.inspection_detail} readOnly placeholder="자동" /></TableCell>
<TableCell className="p-1"><Input className="h-7 text-[10px] bg-muted" value={row.inspection_method} readOnly placeholder="자동" /></TableCell>
<TableCell className="p-1">
{processOptions.length > 0 ? (
<Select value={row.apply_process || ""} onValueChange={(v) => updateCopyInspRow(key, row.id, "apply_process", v)}>
<SelectTrigger className="h-7 text-[10px]"><SelectValue placeholder="공정" /></SelectTrigger>
<SelectContent>
{processOptions.map((p) => (
<SelectItem key={p.code} value={p.code} className="text-xs">{p.name}</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input className="h-7 text-[10px]" value={row.apply_process} onChange={(e) => updateCopyInspRow(key, row.id, "apply_process", e.target.value)} placeholder="공정" />
)}
</TableCell>
<TableCell className="p-1">
<Input className="h-7 text-[10px]" value={row.classification || ""} onChange={(e) => updateCopyInspRow(key, row.id, "classification", e.target.value)} placeholder="구분" />
</TableCell>
<TableCell className="p-1 text-center">
{row.judgment_criteria ? <Badge variant="outline" className="text-[9px]">{row.judgment_criteria}</Badge> : <span className="text-[9px] text-muted-foreground">-</span>}
</TableCell>
<TableCell className="p-1">
{row.judgment_criteria === "선택형" && row.selection_options ? (
<Select value={row.acceptance_criteria || ""} onValueChange={(v) => updateCopyInspRow(key, row.id, "acceptance_criteria", v)} disabled={!row.inspection_standard_id}>
<SelectTrigger className="h-7 text-[10px]"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{row.selection_options.split(",").filter(Boolean).map((opt) => (
<SelectItem key={opt} value={opt} className="text-xs">{opt}</SelectItem>
))}
</SelectContent>
</Select>
) : row.judgment_criteria === "O/X" ? (
<Select value={row.acceptance_criteria || ""} onValueChange={(v) => updateCopyInspRow(key, row.id, "acceptance_criteria", v)} disabled={!row.inspection_standard_id}>
<SelectTrigger className="h-7 text-[10px]"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
<SelectItem value="O" className="text-xs">O ()</SelectItem>
<SelectItem value="X" className="text-xs">X ()</SelectItem>
</SelectContent>
</Select>
) : row.judgment_criteria === "수치(범위)" ? (
<div className="flex items-center gap-1">
<Input className="h-7 text-[10px] w-14" value={row.acceptance_criteria?.split("|")[0] || ""} onChange={(e) => {
const parts = (row.acceptance_criteria || "||").split("|");
parts[0] = e.target.value;
updateCopyInspRow(key, row.id, "acceptance_criteria", parts.join("|"));
}} placeholder="기준" disabled={!row.inspection_standard_id} />
<span className="text-[9px] text-muted-foreground">±</span>
<Input className="h-7 text-[10px] w-10" value={row.acceptance_criteria?.split("|")[1] || ""} onChange={(e) => {
const parts = (row.acceptance_criteria || "||").split("|");
parts[1] = e.target.value;
updateCopyInspRow(key, row.id, "acceptance_criteria", parts.join("|"));
}} placeholder="±" disabled={!row.inspection_standard_id} />
</div>
) : (
<Input className="h-7 text-[10px]" value={row.acceptance_criteria} onChange={(e) => updateCopyInspRow(key, row.id, "acceptance_criteria", e.target.value)} placeholder="합격기준" disabled={!row.inspection_standard_id} />
)}
</TableCell>
<TableCell className="p-1 text-center"><Checkbox checked={row.is_required} onCheckedChange={(v) => updateCopyInspRow(key, row.id, "is_required", !!v)} /></TableCell>
<TableCell className="p-1 text-[10px] text-muted-foreground">{row.unit || "-"}</TableCell>
<TableCell className="p-1">
<Button type="button" variant="destructive" size="sm" className="h-6 w-6 p-0" onClick={() => removeCopyInspRow(key, row.id)}><Trash2 className="w-3 h-3" /></Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)}
</div>
))}
</div>
</div>
</div>
</div>
</>)}
)}
<DialogFooter className="shrink-0">
<Button variant="outline" onClick={() => setCopyModalOpen(false)} disabled={copying}></Button>
<Button onClick={handleCopy} disabled={copying || copyCheckedIds.length === 0}>
@@ -358,13 +358,15 @@ export default function LogisticsInfoPage() {
loadReferences();
}, [loadReferences]);
// 카테고리 옵션 로드
// 카테고리 옵션 로드 (관리자 계정일 때 filterCompanyCode 미제공 시 "*" 스코프로 빈 결과 반환됨)
const loadCategoryOptions = useCallback(async (tableColumn: string) => {
if (loadedCategories.current.has(tableColumn)) return;
loadedCategories.current.add(tableColumn);
const [tableName, columnName] = tableColumn.split(":");
try {
const res = await apiClient.get(`/table-categories/${tableName}/${columnName}/values`);
const res = await apiClient.get(
`/table-categories/${tableName}/${columnName}/values?filterCompanyCode=COMPANY_9`
);
const data = res.data?.data || [];
setCategoryOptions((prev) => ({
...prev,
@@ -823,13 +825,24 @@ export default function LogisticsInfoPage() {
{/* 테이블 영역 */}
<div className="flex-1 overflow-auto">
<EDataTable
columns={getVisibleColumns(tab.key).map((col): EDataTableColumn => ({
key: col.key,
label: col.label,
align: col.align,
formatNumber: col.formatNumber,
truncate: true,
}))}
columns={getVisibleColumns(tab.key).map((col): EDataTableColumn => {
// 같은 key의 formField에 categoryKey가 있으면 코드→라벨 변환
const formField = tab.formFields.find((f) => f.key === col.key && f.categoryKey);
return {
key: col.key,
label: col.label,
align: col.align,
formatNumber: col.formatNumber,
truncate: true,
render: formField?.categoryKey
? (value: any) => {
const opts = categoryOptions[formField.categoryKey!] || [];
const matched = opts.find((o: any) => o.value === value);
return matched?.label || value || "-";
}
: undefined,
};
})}
data={tsMap[tab.key].groupData(displayData)}
rowKey={(row: any) => String(row.id)}
loading={tabLoading[tab.key]}
@@ -186,12 +186,12 @@ export default function InventoryStatusPage() {
};
load();
// 사용자 목록 로드
apiClient.get("/admin/users", { params: { size: 9999 } }).then((res) => {
const users = res.data?.data || res.data || [];
apiClient.get("/admin/users/name-map").then((res) => {
const users = res.data?.data || [];
const map: Record<string, string> = {};
for (const u of users) {
const id = u.userId || u.user_id || u.id;
const name = u.user_name || u.name || id;
const id = u.user_id;
const name = u.user_name || id;
if (id) map[id] = name;
}
setUserMap(map);
@@ -628,7 +628,7 @@ export default function MaterialStatusPage() {
className="inline-flex items-center gap-1 rounded bg-muted/40 px-2 py-0.5 text-xs transition-colors hover:bg-muted/60"
>
<span className="font-semibold font-mono text-primary">
{loc.location || loc.warehouse}
{loc.warehouse_name || loc.location || loc.warehouse}
</span>
<span className="font-semibold">
{loc.qty.toLocaleString()}
@@ -158,6 +158,10 @@ export default function WarehouseManagementPage() {
const [rackStatus, setRackStatus] = useState("");
const [rackPreview, setRackPreview] = useState<any[]>([]);
const [rackSaving, setRackSaving] = useState(false);
// 위치명 접미사 (자동 조립: {zone}{구역접미사}-{row}{열접미사}-{level}{단접미사})
const [rackZoneLabel, setRackZoneLabel] = useState("구역");
const [rackRowLabel, setRackRowLabel] = useState("열");
const [rackLevelLabel, setRackLevelLabel] = useState("단");
// 카테고리 옵션
const [categoryOptions, setCategoryOptions] = useState<
@@ -636,7 +640,7 @@ export default function WarehouseManagementPage() {
duplicates.push(locationCode);
continue;
}
const locationName = `${zoneCode}구역-${rowStr}열-${level}`;
const locationName = `${zoneCode}${rackZoneLabel}-${rowStr}${rackRowLabel}-${level}${rackLevelLabel}`;
items.push({
location_code: locationCode,
location_name: locationName,
@@ -1502,6 +1506,38 @@ export default function WarehouseManagementPage() {
</div>
</div>
{/* 위치명 형식 — 구역/열/단 뒤에 붙일 표현만 자유 입력 */}
<div className="rounded-lg border p-3 bg-muted/30 space-y-2">
<Label className="text-xs font-semibold"> </Label>
<div className="flex items-center gap-1 text-xs flex-wrap">
<span className="font-mono text-muted-foreground">A</span>
<Input
value={rackZoneLabel}
onChange={(e) => setRackZoneLabel(e.target.value)}
placeholder="구역"
className="h-8 w-20 text-xs"
/>
<span className="font-mono text-muted-foreground">- 01</span>
<Input
value={rackRowLabel}
onChange={(e) => setRackRowLabel(e.target.value)}
placeholder="열"
className="h-8 w-20 text-xs"
/>
<span className="font-mono text-muted-foreground">- 1</span>
<Input
value={rackLevelLabel}
onChange={(e) => setRackLevelLabel(e.target.value)}
placeholder="단"
className="h-8 w-20 text-xs"
/>
</div>
<p className="text-[11px] text-muted-foreground">
: <span className="font-mono font-semibold">A{rackZoneLabel}-01{rackRowLabel}-1{rackLevelLabel}</span>
{" "} // , .
</p>
</div>
{/* 등록 미리보기 */}
<div>
<div className="flex items-center justify-between mb-3">
@@ -59,6 +59,7 @@ import {
Settings2,
Save,
Package,
Pencil,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { apiClient } from "@/lib/api/client";
@@ -359,7 +360,13 @@ export default function BomManagementPage() {
sort: { columnName: "created_at", order: "desc" },
});
const rows = res.data?.data?.data || res.data?.data?.rows || [];
// DB 컬럼이 item_type/expired_date → 프론트 내부에서는 bom_type/expiry_date로 통일
const rawRows = res.data?.data?.data || res.data?.data?.rows || [];
const rows = rawRows.map((r: any) => ({
...r,
bom_type: r.bom_type ?? r.item_type,
expiry_date: r.expiry_date ?? r.expired_date,
}));
setBomList(rows);
setTotalCount(rows.length);
} catch (err: any) {
@@ -456,9 +463,16 @@ export default function BomManagementPage() {
const fetchBomDetail = useCallback(async (bomId: string) => {
setDetailLoading(true);
try {
// 헤더 조회
// 헤더 조회 (DB 컬럼 item_type/expired_date → bom_type/expiry_date로 매핑)
const headerRes = await apiClient.get(`/bom/${bomId}/header`);
const header = headerRes.data?.data || headerRes.data;
const rawHeader = headerRes.data?.data || headerRes.data;
const header = rawHeader
? {
...rawHeader,
bom_type: rawHeader.bom_type ?? rawHeader.item_type,
expiry_date: rawHeader.expiry_date ?? rawHeader.expired_date,
}
: null;
setBomHeader(header);
setCurrentVersionId(header?.current_version_id || null);
@@ -1107,17 +1121,18 @@ export default function BomManagementPage() {
setSaving(true);
try {
// DB 실제 컬럼: item_type / expired_date (프론트 내부 bom_type/expiry_date와 다름)
const bomFields: Record<string, any> = {
item_id: masterForm.item_id,
item_code: masterForm.item_code,
item_name: masterForm.item_name,
bom_type: masterForm.bom_type,
item_type: masterForm.bom_type,
base_qty: masterForm.base_qty || "1",
unit: masterForm.unit || "",
version: masterForm.version || "1.0",
status: masterForm.status || "draft",
effective_date: masterForm.effective_date || null,
expiry_date: masterForm.expiry_date || null,
expired_date: masterForm.expiry_date || null,
remark: masterForm.remark || "",
writer: user?.userId || "",
company_code: user?.company_code || "",
@@ -1510,6 +1525,21 @@ export default function BomManagementPage() {
<Plus className="w-3.5 h-3.5 mr-1" />
</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
if (!selectedBomId || !bomHeader) {
toast.error("수정할 BOM을 선택해주세요");
return;
}
openEditModal();
}}
disabled={!selectedBomId || !bomHeader}
>
<Pencil className="w-3.5 h-3.5 mr-1" />
</Button>
<div className="w-px h-4 bg-border mx-0.5" />
<Button
size="sm"
@@ -12,6 +12,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
import {
@@ -91,8 +92,8 @@ export function ItemRoutingTab() {
const [formFixedOrder, setFormFixedOrder] = useState("Y");
const [formWorkType, setFormWorkType] = useState("내부");
const [formStandardTime, setFormStandardTime] = useState("");
const [formOutsource, setFormOutsource] = useState("");
const [subcontractorOptions, setSubcontractorOptions] = useState<{ code: string; name: string }[]>([]);
const [formOutsources, setFormOutsources] = useState<string[]>([]);
const [subcontractorOptions, setSubcontractorOptions] = useState<{ id: string; code: string; name: string }[]>([]);
const [detailSubmitting, setDetailSubmitting] = useState(false);
const [registerDialogOpen, setRegisterDialogOpen] = useState(false);
@@ -116,7 +117,7 @@ export function ItemRoutingTab() {
page: 1, size: 500, autoFilter: true,
});
const rows = res.data?.data?.data || res.data?.data?.rows || [];
setSubcontractorOptions(rows.map((r: any) => ({ code: r.subcontractor_code || r.id, name: r.subcontractor_name || "" })));
setSubcontractorOptions(rows.map((r: any) => ({ id: r.id, code: r.subcontractor_code || "", name: r.subcontractor_name || "" })));
} catch { /* skip */ }
})();
}, []);
@@ -281,7 +282,7 @@ export function ItemRoutingTab() {
setFormFixedOrder("Y");
setFormWorkType("내부");
setFormStandardTime("");
setFormOutsource("");
setFormOutsources([]);
setDetailDialogOpen(true);
};
@@ -308,7 +309,19 @@ export function ItemRoutingTab() {
setFormFixedOrder(row.is_fixed_order === "N" ? "N" : "Y");
setFormWorkType(row.work_type || "내부");
setFormStandardTime(row.standard_time || "");
setFormOutsource(row.outsource_supplier || "");
// 우선순위: id 배열 → legacy code 배열(id로 역변환) → legacy 단일 code(id로 역변환)
let loadedIds: string[] = [];
if (Array.isArray(row.outsource_supplier_ids) && row.outsource_supplier_ids.length > 0) {
loadedIds = row.outsource_supplier_ids;
} else {
const legacyCodes = Array.isArray(row.outsource_supplier_list) && row.outsource_supplier_list.length > 0
? row.outsource_supplier_list
: (row.outsource_supplier ? [row.outsource_supplier] : []);
loadedIds = legacyCodes
.map((c: string) => subcontractorOptions.find((s) => s.code === c)?.id)
.filter((v): v is string => Boolean(v));
}
setFormOutsources(loadedIds);
setDetailDialogOpen(true);
};
@@ -329,7 +342,10 @@ export function ItemRoutingTab() {
return;
}
const proc = processes.find((p) => p.process_code === formProcessCode);
const outsource = showOutsourceField ? formOutsource.trim() : "";
const outsourceIds = showOutsourceField ? formOutsources.filter((s) => s && s.trim() !== "") : [];
const outsourcePrimaryCode = outsourceIds.length > 0
? (subcontractorOptions.find((s) => s.id === outsourceIds[0])?.code || "")
: "";
setDetailSubmitting(true);
try {
@@ -344,7 +360,8 @@ export function ItemRoutingTab() {
is_fixed_order: formFixedOrder,
work_type: formWorkType,
standard_time: st || "0",
outsource_supplier: outsource,
outsource_supplier: outsourcePrimaryCode,
outsource_supplier_ids: outsourceIds,
};
setDetails((prev) => sortDetailsBySeq([...prev, newRow]));
toast.success("공정이 추가되었어요. 저장을 눌러 반영해주세요");
@@ -362,7 +379,8 @@ export function ItemRoutingTab() {
is_fixed_order: formFixedOrder,
work_type: formWorkType,
standard_time: st || "0",
outsource_supplier: outsource,
outsource_supplier: outsourcePrimaryCode,
outsource_supplier_ids: outsourceIds,
}
: d,
),
@@ -399,6 +417,7 @@ export function ItemRoutingTab() {
work_type: d.work_type || "내부",
standard_time: String(d.standard_time ?? "0"),
outsource_supplier: d.outsource_supplier || "",
outsource_supplier_ids: d.outsource_supplier_ids || [],
}));
setSaving(true);
@@ -480,11 +499,23 @@ export function ItemRoutingTab() {
const detailsGridData = useMemo(
() =>
details.map((d) => ({
...d,
process_display: d.process_name || d.process_code,
outsource_display: subcontractorOptions.find((s) => s.code === d.outsource_supplier)?.name || d.outsource_supplier || "—",
})),
details.map((d) => {
const ids = Array.isArray(d.outsource_supplier_ids) && d.outsource_supplier_ids.length > 0
? d.outsource_supplier_ids
: [];
let names = ids
.map((i) => subcontractorOptions.find((s) => s.id === i)?.name)
.filter((v): v is string => Boolean(v));
// 레거시 폴백: id 매핑 없을 때 단일 code로 표시
if (names.length === 0 && d.outsource_supplier) {
names = [subcontractorOptions.find((s) => s.code === d.outsource_supplier)?.name || d.outsource_supplier];
}
return {
...d,
process_display: d.process_name || d.process_code,
outsource_display: names.length === 0 ? "—" : names.join(", "),
};
}),
[details, subcontractorOptions],
);
@@ -909,15 +940,46 @@ export function ItemRoutingTab() {
</div>
{showOutsourceField && (
<div className="space-y-1">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"></Label>
<Select value={formOutsource || ""} onValueChange={setFormOutsource}>
<SelectTrigger className="h-9"><SelectValue placeholder="외주업체 선택" /></SelectTrigger>
<SelectContent>
{subcontractorOptions.map((s) => (
<SelectItem key={s.code} value={s.code}>{s.name}</SelectItem>
))}
</SelectContent>
</Select>
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"> ( )</Label>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className="h-9 w-full justify-between font-normal">
<span className="truncate text-left text-sm">
{formOutsources.length === 0
? "외주업체 선택"
: formOutsources
.map((i) => subcontractorOptions.find((s) => s.id === i)?.name || i)
.join(", ")}
</span>
<Badge variant="secondary" className="ml-2 shrink-0">{formOutsources.length}</Badge>
</Button>
</PopoverTrigger>
<PopoverContent className="w-[320px] p-0" align="start">
<div className="max-h-64 overflow-y-auto p-2 space-y-1">
{subcontractorOptions.length === 0 ? (
<div className="text-xs text-muted-foreground px-2 py-3"> </div>
) : subcontractorOptions.map((s) => {
const checked = formOutsources.includes(s.id);
return (
<label
key={s.id}
className="flex items-center gap-2 rounded px-2 py-1.5 text-sm cursor-pointer hover:bg-muted"
>
<Checkbox
checked={checked}
onCheckedChange={(v) => {
setFormOutsources((prev) =>
v ? [...prev, s.id] : prev.filter((i) => i !== s.id),
);
}}
/>
<span className="truncate">{s.name}</span>
</label>
);
})}
</div>
</PopoverContent>
</Popover>
</div>
)}
</div>
@@ -52,6 +52,7 @@ const INSPECTION_COLUMNS = [
{ key: "inspection_code", label: "검사코드" },
{ key: "inspection_type", label: "검사유형" },
{ key: "inspection_criteria", label: "검사기준" },
{ key: "criteria_detail", label: "기준상세" },
{ key: "inspection_item", label: "검사항목" },
{ key: "inspection_method", label: "검사방법" },
{ key: "judgment_criteria", label: "판단기준" },
@@ -43,6 +43,7 @@ type InspectionRow = {
inspection_detail: string;
inspection_method: string;
apply_process: string;
classification: string;
acceptance_criteria: string;
is_required: boolean;
judgment_criteria?: string; // 판단기준 라벨 (수치(범위)/텍스트입력/O·X/선택형)
@@ -253,6 +254,11 @@ export default function ItemInspectionInfoPage() {
loadProcessOptions(item.code);
};
// 복사 모달: 편집 가능한 기준 데이터 상태 (등록/수정 폼과 평행 구조)
const [copyForm, setCopyForm] = useState<Record<string, any>>({});
const [copyInspectionRows, setCopyInspectionRows] = useState<Record<string, InspectionRow[]>>({});
const [copyCollapsedTypes, setCopyCollapsedTypes] = useState<Record<string, boolean>>({});
/* ═══════════════════ 복사 모달 (기준 품목 검사정보 → 다른 품목들) ═══════════════════ */
const [copyModalOpen, setCopyModalOpen] = useState(false);
const [copySearchKeyword, setCopySearchKeyword] = useState("");
@@ -294,11 +300,63 @@ export default function ItemInspectionInfoPage() {
setCopyTotal(resData?.total || resData?.totalCount || rows.length);
} catch { /* skip */ } finally { setCopySearchLoading(false); }
};
const openCopyModal = () => {
const openCopyModal = async () => {
if (!selectedItemCode) { toast.error("복사 기준 품목을 먼저 선택해주세요"); return; }
const srcGroup = groupedData.find(g => g.item_code === selectedItemCode);
if (!srcGroup || srcGroup.rows.length === 0) { toast.error("선택한 품목에 복사할 검사정보가 없어요"); return; }
setCopySearchKeyword(""); setCopyPage(1); setCopyCheckedIds([]);
// 기준 품목 데이터를 편집용 상태로 복제 (openEdit과 동일한 변환 로직)
const baseRow = srcGroup.rows[0];
try {
const res = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, {
page: 1, size: 0,
dataFilter: { enabled: true, filters: [{ columnName: "item_code", operator: "equals", value: selectedItemCode }] },
autoFilter: true,
});
const allRows = res.data?.data?.data || res.data?.data?.rows || [];
const rowMap: Record<string, InspectionRow[]> = {};
const typeFlags: Record<string, boolean> = {};
for (const r of allRows) {
const inspType = r.inspection_type || "";
const matched = INSPECTION_TYPES.find(t =>
t.matchLabels.some(ml => inspType.includes(ml)) ||
inspTypeCatOptions.some(cat => inspType.includes(cat.code) && t.matchLabels.some(ml => cat.label.includes(ml)))
);
const typeKey = matched?.key || "";
if (!typeKey) continue;
typeFlags[typeKey] = true;
if (!rowMap[typeKey]) rowMap[typeKey] = [];
const mCode = r.inspection_method || "";
const mLabel = inspMethodCatOptions.find(o => o.code === mCode)?.label || mCode;
const inspOpt = inspOptions.find(o => o.code === r.inspection_standard_id);
const jcCode = inspOpt?.judgment_criteria || "";
const jcLabel = judgmentCatOptions.find(c => c.code === jcCode)?.label || jcCode;
const unitCode = inspOpt?.unit || "";
const unitLabel = inspUnitCatOptions.find(c => c.code === unitCode)?.label || unitCode;
rowMap[typeKey].push({
id: crypto.randomUUID(), // 복사본은 새 id 부여 (원본과 분리)
inspection_standard_id: r.inspection_standard_id || "",
inspection_detail: r.inspection_item_name || r.inspection_standard || "",
inspection_method: mLabel,
apply_process: r.apply_process || "",
classification: r.classification || "",
acceptance_criteria: r.pass_criteria || "",
is_required: r.is_required === "true" || r.is_required === true,
judgment_criteria: jcLabel,
selection_options: inspOpt?.selection_options || "",
unit: unitLabel,
});
}
setCopyInspectionRows(rowMap);
setCopyForm({ ...baseRow, ...typeFlags });
setCopyCollapsedTypes({});
} catch {
setCopyInspectionRows({});
setCopyForm({ ...baseRow });
setCopyCollapsedTypes({});
}
setCopyModalOpen(true);
searchCopyTargets(1);
};
@@ -309,10 +367,18 @@ export default function ItemInspectionInfoPage() {
const handleCopy = async () => {
if (!selectedItemCode) { toast.error("복사 기준 품목이 없어요"); return; }
if (copyCheckedIds.length === 0) { toast.error("붙여넣을 품목을 선택해주세요"); return; }
const sourceGroup = groupedData.find(g => g.item_code === selectedItemCode);
if (!sourceGroup || sourceGroup.rows.length === 0) { toast.error("복사할 검사정보가 없어요"); return; }
// 편집된 rows를 평탄화 (선택된 검사유형의 rows만)
const enabledTypes = INSPECTION_TYPES.filter(t => !!copyForm[t.key]);
const flatRows: Array<{ row: InspectionRow; typeLabel: string }> = [];
for (const t of enabledTypes) {
const rows = copyInspectionRows[t.key] || [];
for (const r of rows) flatRows.push({ row: r, typeLabel: t.label });
}
if (flatRows.length === 0) { toast.error("복사할 검사항목이 없어요"); return; }
const ok = await confirm(
`선택한 ${copyCheckedIds.length}개 품목에 검사정보를 복사할까요?`,
`선택한 ${copyCheckedIds.length}개 품목에 편집된 검사정보(${flatRows.length}개 행)를 복사할까요?`,
{ description: "대상 품목의 기존 검사정보는 삭제 후 교체됩니다.", variant: "info", confirmText: "복사" }
);
if (!ok) return;
@@ -325,7 +391,7 @@ export default function ItemInspectionInfoPage() {
const target = copyFilteredItems.find(o => o.code === targetCode) || itemOptions.find(o => o.code === targetCode);
const targetName = target?.name || "";
const existRes = await apiClient.post(`/table-management/tables/${TABLE_NAME}/data`, {
page: 1, size: 500,
page: 1, size: 0,
dataFilter: { enabled: true, filters: [{ columnName: "item_code", operator: "equals", value: targetCode }] },
autoFilter: true,
});
@@ -333,13 +399,26 @@ export default function ItemInspectionInfoPage() {
if (existing.length > 0) {
await apiClient.delete(`/table-management/tables/${TABLE_NAME}/delete`, { data: existing.map((r: any) => ({ id: r.id })) });
}
for (const r of sourceGroup.rows) {
const { id: _id, created_at: _c, updated_at: _u, ...rest } = r;
let orderSeq = 0;
for (const { row: r, typeLabel } of flatRows) {
orderSeq += 1;
await apiClient.post(`/table-management/tables/${TABLE_NAME}/add`, {
...rest,
id: crypto.randomUUID(),
item_code: targetCode,
item_name: targetName,
inspection_type: typeLabel,
inspection_standard_id: r.inspection_standard_id || "",
inspection_item_name: r.inspection_detail || "",
inspection_method: r.inspection_method || "",
apply_process: r.apply_process || "",
classification: r.classification || "",
pass_criteria: r.acceptance_criteria || "",
is_required: r.is_required ? "true" : "false",
is_active: copyForm.is_active || "사용",
manager: copyForm.manager || "",
manager_id: copyForm.manager_id || "",
memo: copyForm.remarks || "",
sort_order: String(orderSeq).padStart(4, "0"),
});
}
setCopyProgress({ current: i + 1, total: copyCheckedIds.length });
@@ -402,7 +481,13 @@ export default function ItemInspectionInfoPage() {
// 선택된 탭의 검사항목 행
const selectedTabRows = useMemo(() => {
if (!selectedGroup || !selectedTypeTab) return [];
return selectedGroup.rows.filter((r: any) => r.inspection_type === selectedTypeTab && r.inspection_standard_id);
const filtered = selectedGroup.rows.filter((r: any) => r.inspection_type === selectedTypeTab && r.inspection_standard_id);
return [...filtered].sort((a: any, b: any) => {
const av = parseInt(String(a.sort_order || "9999"), 10);
const bv = parseInt(String(b.sort_order || "9999"), 10);
if (av === bv) return String(a.id).localeCompare(String(b.id));
return av - bv;
});
}, [selectedGroup, selectedTypeTab]);
// 검사기준 ID → 라벨
@@ -436,6 +521,13 @@ export default function ItemInspectionInfoPage() {
autoFilter: true,
});
const allRows = res.data?.data?.data || res.data?.data?.rows || [];
// sort_order 기준 오름차순 정렬 (varchar이므로 숫자 파싱 후 비교)
allRows.sort((a: any, b: any) => {
const av = parseInt(String(a.sort_order || "9999"), 10);
const bv = parseInt(String(b.sort_order || "9999"), 10);
if (av === bv) return String(a.id).localeCompare(String(b.id));
return av - bv;
});
const rowMap: Record<string, InspectionRow[]> = {};
const typeFlags: Record<string, boolean> = {};
@@ -462,7 +554,8 @@ export default function ItemInspectionInfoPage() {
inspection_standard_id: r.inspection_standard_id || "",
inspection_detail: r.inspection_item_name || r.inspection_standard || "",
inspection_method: mLabel,
apply_process: "",
apply_process: r.apply_process || "",
classification: r.classification || "",
acceptance_criteria: r.pass_criteria || "",
is_required: r.is_required === "true" || r.is_required === true,
judgment_criteria: jcLabel,
@@ -480,7 +573,7 @@ export default function ItemInspectionInfoPage() {
const addInspRow = (typeKey: string) => {
setInspectionRows(prev => ({
...prev,
[typeKey]: [...(prev[typeKey] || []), { id: crypto.randomUUID(), inspection_standard_id: "", inspection_detail: "", inspection_method: "", apply_process: "", acceptance_criteria: "", is_required: false }],
[typeKey]: [...(prev[typeKey] || []), { id: crypto.randomUUID(), inspection_standard_id: "", inspection_detail: "", inspection_method: "", apply_process: "", classification: "", acceptance_criteria: "", is_required: false }],
}));
};
const removeInspRow = (typeKey: string, rowId: string) => {
@@ -525,6 +618,46 @@ export default function ItemInspectionInfoPage() {
};
const toggleCollapse = (typeKey: string) => { setCollapsedTypes(prev => ({ ...prev, [typeKey]: !prev[typeKey] })); };
/* ═══════════════════ 복사 모달용 검사항목 행 관리 (등록 폼과 평행) ═══════════════════ */
const addCopyInspRow = (typeKey: string) => {
setCopyInspectionRows(prev => ({
...prev,
[typeKey]: [...(prev[typeKey] || []), { id: crypto.randomUUID(), inspection_standard_id: "", inspection_detail: "", inspection_method: "", apply_process: "", classification: "", acceptance_criteria: "", is_required: false }],
}));
};
const removeCopyInspRow = (typeKey: string, rowId: string) => {
setCopyInspectionRows(prev => ({ ...prev, [typeKey]: (prev[typeKey] || []).filter(r => r.id !== rowId) }));
};
const updateCopyInspRow = (typeKey: string, rowId: string, field: string, value: any) => {
setCopyInspectionRows(prev => ({
...prev,
[typeKey]: (prev[typeKey] || []).map(r => {
if (r.id !== rowId) return r;
if (field === "inspection_standard_id") {
const opt = inspOptions.find(o => o.code === value);
const methodCode = opt?.method || "";
const methodLabel = inspMethodCatOptions.find(o => o.code === methodCode)?.label || methodCode;
const jcCode = opt?.judgment_criteria || "";
const jcLabel = judgmentCatOptions.find(c => c.code === jcCode)?.label || jcCode;
const unitCode = opt?.unit || "";
const unitLabel = inspUnitCatOptions.find(c => c.code === unitCode)?.label || unitCode;
return {
...r,
inspection_standard_id: value,
inspection_detail: opt?.detail || "",
inspection_method: methodLabel,
judgment_criteria: jcLabel,
selection_options: opt?.selection_options || "",
unit: unitLabel,
acceptance_criteria: "",
};
}
return { ...r, [field]: value };
}),
}));
};
const toggleCopyCollapse = (typeKey: string) => { setCopyCollapsedTypes(prev => ({ ...prev, [typeKey]: !prev[typeKey] })); };
const handleSave = async () => {
if (!form.item_code) { toast.error("품목코드는 필수예요"); return; }
setSaving(true);
@@ -542,18 +675,23 @@ export default function ItemInspectionInfoPage() {
}
const enabledTypes = INSPECTION_TYPES.filter(t => !!form[t.key]);
const rows: any[] = [];
let globalOrder = 0;
for (const t of enabledTypes) {
const typeRows = inspectionRows[t.key] || [];
if (typeRows.length === 0) {
rows.push({ id: crypto.randomUUID(), item_code: form.item_code, item_name: form.item_name, inspection_type: t.label, is_active: form.is_active || "사용", manager_id: form.manager_id || "", memo: form.remarks || "" });
globalOrder += 1;
rows.push({ id: crypto.randomUUID(), item_code: form.item_code, item_name: form.item_name, inspection_type: t.label, is_active: form.is_active || "사용", manager_id: form.manager_id || "", memo: form.remarks || "", sort_order: String(globalOrder).padStart(4, "0") });
} else {
for (const r of typeRows) {
globalOrder += 1;
rows.push({
id: crypto.randomUUID(), item_code: form.item_code, item_name: form.item_name, inspection_type: t.label,
inspection_standard_id: r.inspection_standard_id || "", inspection_item_name: r.inspection_detail || "",
inspection_method: r.inspection_method || "", pass_criteria: r.acceptance_criteria || "",
apply_process: r.apply_process || "", classification: r.classification || "",
is_required: r.is_required ? "true" : "false", is_active: form.is_active || "사용",
manager_id: form.manager_id || "", memo: form.remarks || "",
sort_order: String(globalOrder).padStart(4, "0"),
});
}
}
@@ -974,6 +1112,7 @@ export default function ItemInspectionInfoPage() {
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8"></TableHead>
<TableHead className="text-[10px] font-bold h-8 w-[50px]"></TableHead>
@@ -983,7 +1122,7 @@ export default function ItemInspectionInfoPage() {
<TableBody>
{selectedTabRows.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="text-center py-8 text-xs text-muted-foreground"> </TableCell>
<TableCell colSpan={9} className="text-center py-8 text-xs text-muted-foreground"> </TableCell>
</TableRow>
) : selectedTabRows.map((row: any) => (
<TableRow key={row.id}>
@@ -1002,6 +1141,7 @@ export default function ItemInspectionInfoPage() {
const proc = processOptions.find(p => p.code === code);
return proc?.name || code;
})()}</TableCell>
<TableCell className="text-xs py-2">{row.classification || "-"}</TableCell>
<TableCell className="text-xs py-2">
{(() => {
const insp = inspOptions.find(o => o.code === row.inspection_standard_id);
@@ -1185,6 +1325,7 @@ export default function ItemInspectionInfoPage() {
<TableHead className="text-[10px] font-bold w-[130px]"> </TableHead>
<TableHead className="text-[10px] font-bold w-[90px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[90px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[100px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[80px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[200px]"> ()</TableHead>
<TableHead className="text-[10px] font-bold w-[40px]"></TableHead>
@@ -1194,7 +1335,7 @@ export default function ItemInspectionInfoPage() {
</TableHeader>
<TableBody>
{(!inspectionRows[key] || inspectionRows[key].length === 0) ? (
<TableRow><TableCell colSpan={9} className="text-center py-4 text-xs text-muted-foreground"> </TableCell></TableRow>
<TableRow><TableCell colSpan={10} className="text-center py-4 text-xs text-muted-foreground"> </TableCell></TableRow>
) : inspectionRows[key].map((row) => (
<TableRow key={row.id}>
<TableCell className="p-1">
@@ -1219,6 +1360,9 @@ export default function ItemInspectionInfoPage() {
<Input className="h-8 text-xs" value={row.apply_process} onChange={(e) => updateInspRow(key, row.id, "apply_process", e.target.value)} placeholder="공정" />
)}
</TableCell>
<TableCell className="p-1">
<Input className="h-8 text-xs" value={row.classification || ""} onChange={(e) => updateInspRow(key, row.id, "classification", e.target.value)} placeholder="구분 입력" />
</TableCell>
<TableCell className="p-1 text-center">
{row.judgment_criteria ? <Badge variant="outline" className="text-[10px]">{row.judgment_criteria}</Badge> : <span className="text-[10px] text-muted-foreground">-</span>}
</TableCell>
@@ -1285,20 +1429,20 @@ export default function ItemInspectionInfoPage() {
</DialogContent>
</Dialog>
{/* ═══════════════════ 복사 모달 ═══════════════════ */}
{/* ═══════════════════ 복사 모달 (2단 분할: 좌 대상 / 우 편집) ═══════════════════ */}
<Dialog open={copyModalOpen} onOpenChange={(v) => { if (!copying) setCopyModalOpen(v); }}>
<DialogContent
className="max-w-3xl w-[95vw] max-h-[85vh] flex flex-col overflow-hidden"
className="max-w-[95vw] sm:max-w-[1400px] w-[95vw] max-h-[90vh] flex flex-col overflow-hidden"
onPointerDownOutside={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => { if (copying) e.preventDefault(); }}
>
<DialogHeader>
<DialogHeader className="shrink-0">
<DialogTitle>{copying ? "검사정보 복사 중..." : "검사정보 복사"}</DialogTitle>
<DialogDescription>
<span className="font-medium text-foreground">{selectedGroup?.item_name || "-"}</span>
<span className="text-muted-foreground"> ({selectedItemCode})</span>
<span>{copying ? " 의 검사정보를 복사하고 있습니다" : " 의 검사정보를 아래 선택한 품목들에 복사합니다"}</span>
<span>{copying ? " 의 검사정보를 복사하고 있습니다" : " 의 검사정보를 편집해서 선택한 품목들에 복사합니다. 기준 품목은 변경되지 않아요"}</span>
</DialogDescription>
</DialogHeader>
{copying ? (
@@ -1322,81 +1466,229 @@ export default function ItemInspectionInfoPage() {
</p>
</div>
</div>
) : (<>
<div className="flex gap-2 shrink-0">
<Input className="h-9 flex-1" placeholder="품목코드 또는 품목명" value={copySearchKeyword}
onChange={(e) => setCopySearchKeyword(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") handleCopySearch(); }} />
<Button size="sm" className="h-9" onClick={handleCopySearch} disabled={copySearchLoading}>
{copySearchLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <><Search className="w-4 h-4 mr-1" /></>}
</Button>
</div>
<div className="flex-1 border rounded-lg overflow-auto">
<Table>
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-[40px] text-center">
<Checkbox
checked={copyFilteredItems.length > 0 && copyFilteredItems.every(i => copyCheckedIds.includes(i.code))}
onCheckedChange={(v) => {
if (v) setCopyCheckedIds(prev => Array.from(new Set([...prev, ...copyFilteredItems.map(i => i.code)])));
else setCopyCheckedIds(prev => prev.filter(c => !copyFilteredItems.some(i => i.code === c)));
}}
/>
</TableHead>
<TableHead className="text-[11px] font-bold w-[140px]"></TableHead>
<TableHead className="text-[11px] font-bold"></TableHead>
<TableHead className="text-[11px] font-bold w-[100px]"></TableHead>
<TableHead className="text-[11px] font-bold w-[80px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{copyFilteredItems.length === 0 ? (
<TableRow><TableCell colSpan={5} className="text-center py-8 text-muted-foreground text-sm">
{copySearchLoading ? "검색 중..." : "검색 결과가 없어요"}
</TableCell></TableRow>
) : copyFilteredItems.map((item) => (
<TableRow key={item.code} className="cursor-pointer hover:bg-primary/5"
onClick={() => toggleCopyChecked(item.code)}>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<Checkbox checked={copyCheckedIds.includes(item.code)} onCheckedChange={() => toggleCopyChecked(item.code)} />
</TableCell>
<TableCell className="text-sm font-mono">{item.code}</TableCell>
<TableCell className="text-sm">{item.name}</TableCell>
<TableCell className="text-sm">{item.item_type}</TableCell>
<TableCell className="text-sm">{item.unit}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="shrink-0 flex items-center justify-between border-t px-2 py-2 text-xs text-muted-foreground">
<span>
<span className="font-medium text-foreground">{copyTotal.toLocaleString()}</span>
{copyCheckedIds.length > 0 && <span className="ml-2"> <span className="font-medium text-primary">{copyCheckedIds.length}</span></span>}
</span>
<div className="flex items-center gap-0.5">
<button onClick={() => { setCopyPage(1); searchCopyTargets(1); }} disabled={copyPage <= 1}
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronsLeft className="h-3.5 w-3.5" /></button>
<button onClick={() => { const p = copyPage - 1; setCopyPage(p); searchCopyTargets(p); }} disabled={copyPage <= 1}
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronLeft className="h-3.5 w-3.5" /></button>
{Array.from({ length: Math.min(5, copyTotalPages) }, (_, i) => {
const start = Math.max(1, Math.min(copyPage - 2, copyTotalPages - 4));
const p = start + i;
if (p > copyTotalPages) return null;
return (
<button key={p} onClick={() => { setCopyPage(p); searchCopyTargets(p); }}
className={cn("h-7 w-7 flex items-center justify-center rounded text-xs",
p === copyPage ? "bg-primary text-primary-foreground font-medium" : "hover:bg-muted")}>{p}</button>
);
})}
<button onClick={() => { const p = copyPage + 1; setCopyPage(p); searchCopyTargets(p); }} disabled={copyPage >= copyTotalPages}
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronRight className="h-3.5 w-3.5" /></button>
<button onClick={() => { setCopyPage(copyTotalPages); searchCopyTargets(copyTotalPages); }} disabled={copyPage >= copyTotalPages}
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronsRight className="h-3.5 w-3.5" /></button>
) : (
<div className="flex-1 grid grid-cols-[420px_1fr] gap-4 overflow-hidden">
{/* 좌측: 복사 대상 품목 선택 */}
<div className="flex flex-col overflow-hidden border rounded-lg">
<div className="flex items-center justify-between border-b bg-muted/50 px-3 py-2">
<span className="text-xs font-semibold"> </span>
{copyCheckedIds.length > 0 && <span className="text-[10px] text-primary"> {copyCheckedIds.length}</span>}
</div>
<div className="flex gap-2 px-2 pt-2">
<Input className="h-8 flex-1 text-xs" placeholder="품목코드 또는 품목명" value={copySearchKeyword}
onChange={(e) => setCopySearchKeyword(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") handleCopySearch(); }} />
<Button size="sm" className="h-8 text-xs" onClick={handleCopySearch} disabled={copySearchLoading}>
{copySearchLoading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Search className="w-3.5 h-3.5" />}
</Button>
</div>
<div className="flex-1 overflow-auto mt-2">
<Table>
<TableHeader className="sticky top-0 z-10 bg-background">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-[36px] text-center text-[10px]">
<Checkbox
checked={copyFilteredItems.length > 0 && copyFilteredItems.every(i => copyCheckedIds.includes(i.code))}
onCheckedChange={(v) => {
if (v) setCopyCheckedIds(prev => Array.from(new Set([...prev, ...copyFilteredItems.map(i => i.code)])));
else setCopyCheckedIds(prev => prev.filter(c => !copyFilteredItems.some(i => i.code === c)));
}}
/>
</TableHead>
<TableHead className="text-[10px] font-bold w-[120px]"></TableHead>
<TableHead className="text-[10px] font-bold"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{copyFilteredItems.length === 0 ? (
<TableRow><TableCell colSpan={3} className="text-center py-6 text-muted-foreground text-xs">
{copySearchLoading ? "검색 중..." : "검색 결과가 없어요"}
</TableCell></TableRow>
) : copyFilteredItems.map((item) => (
<TableRow key={item.code} className="cursor-pointer hover:bg-primary/5"
onClick={() => toggleCopyChecked(item.code)}>
<TableCell className="text-center p-1" onClick={(e) => e.stopPropagation()}>
<Checkbox checked={copyCheckedIds.includes(item.code)} onCheckedChange={() => toggleCopyChecked(item.code)} />
</TableCell>
<TableCell className="text-xs font-mono p-1">{item.code}</TableCell>
<TableCell className="text-xs p-1 truncate">{item.name}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="border-t flex items-center justify-between px-2 py-1 text-[10px] text-muted-foreground">
<span> <span className="font-medium text-foreground">{copyTotal.toLocaleString()}</span></span>
<div className="flex items-center gap-0.5">
<button onClick={() => { setCopyPage(1); searchCopyTargets(1); }} disabled={copyPage <= 1}
className="h-6 w-6 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronsLeft className="h-3 w-3" /></button>
<button onClick={() => { const p = copyPage - 1; setCopyPage(p); searchCopyTargets(p); }} disabled={copyPage <= 1}
className="h-6 w-6 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronLeft className="h-3 w-3" /></button>
<span className="text-[10px] mx-1">{copyPage}/{copyTotalPages}</span>
<button onClick={() => { const p = copyPage + 1; setCopyPage(p); searchCopyTargets(p); }} disabled={copyPage >= copyTotalPages}
className="h-6 w-6 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronRight className="h-3 w-3" /></button>
<button onClick={() => { setCopyPage(copyTotalPages); searchCopyTargets(copyTotalPages); }} disabled={copyPage >= copyTotalPages}
className="h-6 w-6 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"><ChevronsRight className="h-3 w-3" /></button>
</div>
</div>
</div>
{/* 우측: 편집 폼 (등록/수정 폼과 동일 구조) */}
<div className="flex flex-col overflow-hidden border rounded-lg">
<div className="border-b bg-muted/50 px-3 py-2">
<span className="text-xs font-semibold"> (: {selectedItemCode})</span>
</div>
<div className="flex-1 overflow-y-auto p-3 space-y-4">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-muted-foreground"></Label>
<Select value={copyForm.is_active === false || copyForm.is_active === "N" ? "N" : "Y"} onValueChange={(v) => setCopyForm(p => ({ ...p, is_active: v === "Y" ? "사용" : "미사용" }))}>
<SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="Y"></SelectItem>
<SelectItem value="N"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-muted-foreground"></Label>
<Select value={copyForm.manager || ""} onValueChange={(v) => setCopyForm(p => ({ ...p, manager: v }))}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="관리자 선택" /></SelectTrigger>
<SelectContent>{userOptions.map(o => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<h4 className="text-xs font-semibold"> </h4>
<div className="flex flex-wrap gap-3">
{INSPECTION_TYPES.map(({ key, label }) => (
<div key={key} className="flex items-center gap-1.5">
<Checkbox checked={!!copyForm[key]} onCheckedChange={(v) => setCopyForm(p => ({ ...p, [key]: !!v }))} />
<Label className="text-xs cursor-pointer">{label}</Label>
</div>
))}
</div>
</div>
{INSPECTION_TYPES.filter(t => !!copyForm[t.key]).map(({ key, label }) => (
<div key={key} className="space-y-1.5">
<button type="button" className="w-full flex items-center gap-2 py-1.5 px-2 rounded-md border bg-muted/50 hover:bg-muted text-left" onClick={() => toggleCopyCollapse(key)}>
<Badge variant="default" className="text-[10px]">{label}</Badge>
<span className="text-xs font-medium"> </span>
<span className="text-[10px] text-muted-foreground ml-auto">{(copyInspectionRows[key] || []).length}</span>
</button>
{!copyCollapsedTypes[key] && (
<div className="space-y-1.5 pl-1">
<div className="flex items-center justify-between">
<span className="text-[10px] font-semibold text-muted-foreground"> </span>
<Button type="button" size="sm" variant="outline" className="h-6 text-[10px]" onClick={() => addCopyInspRow(key)}>
<Plus className="w-3 h-3 mr-1" />
</Button>
</div>
<div className="border rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow className="bg-muted/50 hover:bg-muted/50">
<TableHead className="text-[10px] font-bold w-[150px]"> </TableHead>
<TableHead className="text-[10px] font-bold w-[110px]"> </TableHead>
<TableHead className="text-[10px] font-bold w-[80px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[80px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[90px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[70px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[180px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[36px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[60px]"></TableHead>
<TableHead className="text-[10px] font-bold w-[32px]" />
</TableRow>
</TableHeader>
<TableBody>
{(!copyInspectionRows[key] || copyInspectionRows[key].length === 0) ? (
<TableRow><TableCell colSpan={10} className="text-center py-3 text-[10px] text-muted-foreground"> </TableCell></TableRow>
) : copyInspectionRows[key].map((row) => (
<TableRow key={row.id}>
<TableCell className="p-1">
<Select value={row.inspection_standard_id || ""} onValueChange={(v) => updateCopyInspRow(key, row.id, "inspection_standard_id", v)}>
<SelectTrigger className="h-7 text-[10px]"><SelectValue placeholder="검사기준" /></SelectTrigger>
<SelectContent>{getFilteredInspOptions(key).map(o => <SelectItem key={o.code} value={o.code}>{o.label}</SelectItem>)}</SelectContent>
</Select>
</TableCell>
<TableCell className="p-1"><Input className="h-7 text-[10px] bg-muted" value={row.inspection_detail} readOnly placeholder="자동" /></TableCell>
<TableCell className="p-1"><Input className="h-7 text-[10px] bg-muted" value={row.inspection_method} readOnly placeholder="자동" /></TableCell>
<TableCell className="p-1">
{processOptions.length > 0 ? (
<Select value={row.apply_process || ""} onValueChange={(v) => updateCopyInspRow(key, row.id, "apply_process", v)}>
<SelectTrigger className="h-7 text-[10px]"><SelectValue placeholder="공정" /></SelectTrigger>
<SelectContent>
{processOptions.map((p) => (
<SelectItem key={p.code} value={p.code} className="text-xs">{p.name}</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input className="h-7 text-[10px]" value={row.apply_process} onChange={(e) => updateCopyInspRow(key, row.id, "apply_process", e.target.value)} placeholder="공정" />
)}
</TableCell>
<TableCell className="p-1">
<Input className="h-7 text-[10px]" value={row.classification || ""} onChange={(e) => updateCopyInspRow(key, row.id, "classification", e.target.value)} placeholder="구분" />
</TableCell>
<TableCell className="p-1 text-center">
{row.judgment_criteria ? <Badge variant="outline" className="text-[9px]">{row.judgment_criteria}</Badge> : <span className="text-[9px] text-muted-foreground">-</span>}
</TableCell>
<TableCell className="p-1">
{row.judgment_criteria === "선택형" && row.selection_options ? (
<Select value={row.acceptance_criteria || ""} onValueChange={(v) => updateCopyInspRow(key, row.id, "acceptance_criteria", v)} disabled={!row.inspection_standard_id}>
<SelectTrigger className="h-7 text-[10px]"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{row.selection_options.split(",").filter(Boolean).map((opt) => (
<SelectItem key={opt} value={opt} className="text-xs">{opt}</SelectItem>
))}
</SelectContent>
</Select>
) : row.judgment_criteria === "O/X" ? (
<Select value={row.acceptance_criteria || ""} onValueChange={(v) => updateCopyInspRow(key, row.id, "acceptance_criteria", v)} disabled={!row.inspection_standard_id}>
<SelectTrigger className="h-7 text-[10px]"><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
<SelectItem value="O" className="text-xs">O ()</SelectItem>
<SelectItem value="X" className="text-xs">X ()</SelectItem>
</SelectContent>
</Select>
) : row.judgment_criteria === "수치(범위)" ? (
<div className="flex items-center gap-1">
<Input className="h-7 text-[10px] w-14" value={row.acceptance_criteria?.split("|")[0] || ""} onChange={(e) => {
const parts = (row.acceptance_criteria || "||").split("|");
parts[0] = e.target.value;
updateCopyInspRow(key, row.id, "acceptance_criteria", parts.join("|"));
}} placeholder="기준" disabled={!row.inspection_standard_id} />
<span className="text-[9px] text-muted-foreground">±</span>
<Input className="h-7 text-[10px] w-10" value={row.acceptance_criteria?.split("|")[1] || ""} onChange={(e) => {
const parts = (row.acceptance_criteria || "||").split("|");
parts[1] = e.target.value;
updateCopyInspRow(key, row.id, "acceptance_criteria", parts.join("|"));
}} placeholder="±" disabled={!row.inspection_standard_id} />
</div>
) : (
<Input className="h-7 text-[10px]" value={row.acceptance_criteria} onChange={(e) => updateCopyInspRow(key, row.id, "acceptance_criteria", e.target.value)} placeholder="합격기준" disabled={!row.inspection_standard_id} />
)}
</TableCell>
<TableCell className="p-1 text-center"><Checkbox checked={row.is_required} onCheckedChange={(v) => updateCopyInspRow(key, row.id, "is_required", !!v)} /></TableCell>
<TableCell className="p-1 text-[10px] text-muted-foreground">{row.unit || "-"}</TableCell>
<TableCell className="p-1">
<Button type="button" variant="destructive" size="sm" className="h-6 w-6 p-0" onClick={() => removeCopyInspRow(key, row.id)}><Trash2 className="w-3 h-3" /></Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)}
</div>
))}
</div>
</div>
</div>
</div>
</>)}
)}
<DialogFooter className="shrink-0">
<Button variant="outline" onClick={() => setCopyModalOpen(false)} disabled={copying}></Button>
<Button onClick={handleCopy} disabled={copying || copyCheckedIds.length === 0}>
+1
View File
@@ -22,6 +22,7 @@ export interface WorkOrder {
export interface MaterialLocation {
location: string;
warehouse: string;
warehouse_name?: string;
qty: number;
}
@@ -69,6 +69,8 @@ export function ProcessWorkStandardComponent({
createDetail,
updateDetail,
deleteDetail,
reorderWorkItems,
reorderDetails,
} = useProcessWorkStandard(config);
// 모달 상태
@@ -217,6 +219,8 @@ export function ProcessWorkStandardComponent({
onCreateDetail={createDetail}
onUpdateDetail={updateDetail}
onDeleteDetail={deleteDetail}
onReorderWorkItems={reorderWorkItems}
onReorderDetails={reorderDetails}
/>
))}
</div>
@@ -141,8 +141,8 @@ export function DetailFormModal({
return;
}
}
// 신규 추가 또는 저장값 없으면 전체 체크
setBomChecked(new Set(bomMaterials.map((m) => m.child_item_id)));
// 신규 추가 또는 저장값 없으면 전체 해제
setBomChecked(new Set());
}, [open, bomMaterials, mode, editData]);
useEffect(() => {
@@ -943,6 +943,29 @@ export function DetailFormModal({
{bomMaterials.length > 0 ? `${bomMaterials.length}` : ""}
</span>
</div>
{bomMaterials.length > 0 && (
<div className="mb-2 flex items-center gap-2 rounded border bg-white px-3 py-2">
<Checkbox
checked={
bomChecked.size === bomMaterials.length
? true
: bomChecked.size > 0
? "indeterminate"
: false
}
onCheckedChange={(checked) => {
if (checked) {
setBomChecked(new Set(bomMaterials.map((m) => m.child_item_id)));
} else {
setBomChecked(new Set());
}
}}
/>
<span className="text-xs font-medium">
({bomChecked.size} / {bomMaterials.length})
</span>
</div>
)}
<div className="max-h-[300px] overflow-y-auto rounded-lg border bg-white">
{bomLoading ? (
<div className="flex items-center justify-center gap-2 py-6">
@@ -1,7 +1,7 @@
"use client";
import React, { useState, useRef } from "react";
import { Plus, Pencil, Trash2 } from "lucide-react";
import { Plus, Pencil, Trash2, GripVertical } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
@@ -18,6 +18,7 @@ interface WorkItemDetailListProps {
onCreateDetail: (data: Partial<WorkItemDetail>) => void;
onUpdateDetail: (id: string, data: Partial<WorkItemDetail>) => void;
onDeleteDetail: (id: string) => void;
onReorderDetails?: (orderedDetails: WorkItemDetail[]) => void;
}
export function WorkItemDetailList({
@@ -30,11 +31,14 @@ export function WorkItemDetailList({
onCreateDetail,
onUpdateDetail,
onDeleteDetail,
onReorderDetails,
}: WorkItemDetailListProps) {
const [modalOpen, setModalOpen] = useState(false);
const [modalMode, setModalMode] = useState<"add" | "edit">("add");
const [editTarget, setEditTarget] = useState<WorkItemDetail | null>(null);
const editFirstRef = useRef(false);
const [dragIdx, setDragIdx] = useState<number | null>(null);
const [overIdx, setOverIdx] = useState<number | null>(null);
if (!workItem) {
return (
@@ -154,6 +158,7 @@ export function WorkItemDetailList({
<table className="w-full text-xs">
<thead className="sticky top-0 bg-muted/50">
<tr className="border-b">
<th className="w-8 px-1 py-2"></th>
<th className="w-12 px-2 py-2 text-center font-medium text-muted-foreground">
</th>
@@ -177,8 +182,35 @@ export function WorkItemDetailList({
{details.map((detail, idx) => (
<tr
key={detail.id}
className="border-b transition-colors hover:bg-muted/30"
draggable={!readonly && !!onReorderDetails}
onDragStart={() => setDragIdx(idx)}
onDragOver={(e) => {
e.preventDefault();
if (dragIdx !== null && dragIdx !== idx) setOverIdx(idx);
}}
onDrop={(e) => {
e.preventDefault();
if (dragIdx === null || dragIdx === idx) {
setDragIdx(null); setOverIdx(null); return;
}
const next = [...details];
const [moved] = next.splice(dragIdx, 1);
next.splice(idx, 0, moved);
onReorderDetails?.(next);
setDragIdx(null); setOverIdx(null);
}}
onDragEnd={() => { setDragIdx(null); setOverIdx(null); }}
className={cn(
"border-b transition-colors hover:bg-muted/30",
dragIdx === idx && "opacity-50",
overIdx === idx && "bg-primary/5"
)}
>
<td className="px-1 py-1.5 text-center">
{!readonly && onReorderDetails && (
<GripVertical className="inline-block h-3 w-3 cursor-grab text-muted-foreground/50" />
)}
</td>
<td className="px-2 py-1.5 text-center text-muted-foreground">
{idx + 1}
</td>
@@ -1,6 +1,6 @@
"use client";
import React from "react";
import React, { useState } from "react";
import { Plus, ClipboardList } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
@@ -29,6 +29,8 @@ interface WorkPhaseSectionProps {
onCreateDetail: (workItemId: string, data: Partial<WorkItemDetail>, phaseKey: string) => void;
onUpdateDetail: (id: string, data: Partial<WorkItemDetail>, phaseKey: string) => void;
onDeleteDetail: (id: string, phaseKey: string) => void;
onReorderWorkItems?: (orderedIds: string[]) => void;
onReorderDetails?: (workItemId: string, orderedDetails: WorkItemDetail[], phaseKey: string) => void;
}
export function WorkPhaseSection({
@@ -47,9 +49,37 @@ export function WorkPhaseSection({
onCreateDetail,
onUpdateDetail,
onDeleteDetail,
onReorderWorkItems,
onReorderDetails,
}: WorkPhaseSectionProps) {
const selectedItem = items.find((i) => i.id === selectedWorkItemId) || null;
const [dragIdx, setDragIdx] = useState<number | null>(null);
const [overIdx, setOverIdx] = useState<number | null>(null);
const handleDragStart = (idx: number) => setDragIdx(idx);
const handleDragOver = (e: React.DragEvent, idx: number) => {
e.preventDefault();
if (dragIdx === null || dragIdx === idx) return;
setOverIdx(idx);
};
const handleDragEnd = () => {
setDragIdx(null);
setOverIdx(null);
};
const handleDrop = (e: React.DragEvent, idx: number) => {
e.preventDefault();
if (dragIdx === null || dragIdx === idx) {
handleDragEnd();
return;
}
const next = [...items];
const [moved] = next.splice(dragIdx, 1);
next.splice(idx, 0, moved);
onReorderWorkItems?.(next.map((i) => i.id));
handleDragEnd();
};
return (
<div className="rounded-lg border bg-card">
{/* 섹션 헤더 */}
@@ -89,16 +119,31 @@ export function WorkPhaseSection({
</div>
) : (
<div className="space-y-1.5">
{items.map((item) => (
<WorkItemCard
{items.map((item, idx) => (
<div
key={item.id}
item={item}
isSelected={selectedWorkItemId === item.id}
readonly={readonly}
onClick={() => onSelectWorkItem(item.id, phase.key)}
onEdit={() => onEditWorkItem(item)}
onDelete={() => onDeleteWorkItem(item.id)}
/>
draggable={!readonly}
onDragStart={() => handleDragStart(idx)}
onDragOver={(e) => handleDragOver(e, idx)}
onDrop={(e) => handleDrop(e, idx)}
onDragEnd={handleDragEnd}
className={
dragIdx === idx
? "opacity-50"
: overIdx === idx
? "ring-2 ring-primary/40 rounded-lg"
: ""
}
>
<WorkItemCard
item={item}
isSelected={selectedWorkItemId === item.id}
readonly={readonly}
onClick={() => onSelectWorkItem(item.id, phase.key)}
onEdit={() => onEditWorkItem(item)}
onDelete={() => onDeleteWorkItem(item.id)}
/>
</div>
))}
</div>
)}
@@ -118,6 +163,11 @@ export function WorkPhaseSection({
}
onUpdateDetail={(id, data) => onUpdateDetail(id, data, phase.key)}
onDeleteDetail={(id) => onDeleteDetail(id, phase.key)}
onReorderDetails={
onReorderDetails && selectedWorkItemId
? (orderedDetails) => onReorderDetails(selectedWorkItemId, orderedDetails, phase.key)
: undefined
}
/>
</div>
</div>
@@ -383,6 +383,42 @@ export function useProcessWorkStandard(config: ProcessWorkStandardConfig) {
]
);
// 작업 항목 순서 일괄 재배치
const reorderWorkItems = useCallback(
async (orderedIds: string[]) => {
if (!selection.routingDetailId || orderedIds.length === 0) return;
try {
await Promise.all(
orderedIds.map((id, idx) =>
apiClient.put(`${API_BASE}/work-items/${id}`, { sort_order: idx + 1 })
)
);
await fetchWorkItems(selection.routingDetailId);
} catch (err) {
console.error("작업 항목 순서 변경 실패", err);
}
},
[selection.routingDetailId, fetchWorkItems]
);
// 상세 항목 순서 일괄 재배치 (전체 필드 보존 위해 객체 배열 수신)
const reorderDetails = useCallback(
async (workItemId: string, orderedDetails: WorkItemDetail[], phaseKey: string) => {
if (orderedDetails.length === 0) return;
try {
await Promise.all(
orderedDetails.map((d, idx) =>
apiClient.put(`${API_BASE}/work-item-details/${d.id}`, { ...d, sort_order: idx + 1 })
)
);
await fetchWorkItemDetails(workItemId, phaseKey);
} catch (err) {
console.error("상세 순서 변경 실패", err);
}
},
[fetchWorkItemDetails]
);
return {
items,
routings,
@@ -406,5 +442,7 @@ export function useProcessWorkStandard(config: ProcessWorkStandardConfig) {
createDetail,
updateDetail,
deleteDetail,
reorderWorkItems,
reorderDetails,
};
}
+4 -2
View File
@@ -5,7 +5,8 @@
// --- 자동 포맷팅 ---
// 전화번호: 숫자만 추출 → 자동 하이픈
// 010-1234-5678 / 02-1234-5678 / 031-123-4567
// 010-1234-5678(휴대폰 11자리) / 02-xxx-xxxx / 02-xxxx-xxxx
// 지역번호 10자리(032-672-1418) → 3-3-4 / 11자리(031-1234-5678) → 3-4-4
export function formatPhone(value: string): string {
const nums = value.replace(/\D/g, "").slice(0, 11);
if (nums.startsWith("02")) {
@@ -15,7 +16,8 @@ export function formatPhone(value: string): string {
return `${nums.slice(0, 2)}-${nums.slice(2, 6)}-${nums.slice(6)}`;
}
if (nums.length <= 3) return nums;
if (nums.length <= 7) return `${nums.slice(0, 3)}-${nums.slice(3)}`;
if (nums.length <= 6) return `${nums.slice(0, 3)}-${nums.slice(3)}`;
if (nums.length <= 10) return `${nums.slice(0, 3)}-${nums.slice(3, 6)}-${nums.slice(6)}`;
return `${nums.slice(0, 3)}-${nums.slice(3, 7)}-${nums.slice(7)}`;
}