Merge branch 'main' of https://g.wace.me/jskim/vexplor_dev into jskim-node
This commit is contained in:
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface WorkOrder {
|
||||
export interface MaterialLocation {
|
||||
location: string;
|
||||
warehouse: string;
|
||||
warehouse_name?: string;
|
||||
qty: number;
|
||||
}
|
||||
|
||||
|
||||
+4
@@ -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>
|
||||
|
||||
+25
-2
@@ -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">
|
||||
|
||||
+34
-2
@@ -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>
|
||||
|
||||
+60
-10
@@ -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>
|
||||
|
||||
+38
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user