Merge pull request 'jskim-node' (#35) from jskim-node into main
Some checks failed
Build and Push Images / build-and-push (push) Failing after 49s

Reviewed-on: jskim/vexplor_dev#35
This commit is contained in:
2026-04-24 02:12:55 +00:00
15 changed files with 3751 additions and 214 deletions

View File

@@ -163,24 +163,29 @@ export async function getMaterialStatus(
bomParams.push(companyCode);
}
// inventory_unit은 카테고리 코드(CAT_xxx)로 저장됨 → category_values 조인으로 라벨 해상
const bomQuery = `
SELECT
b.item_code AS parent_item_code,
b.base_qty AS bom_base_qty,
bd.child_item_id,
bd.quantity AS bom_qty,
bd.unit AS bom_unit,
bd.loss_rate,
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(cv_inv.value_label, 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
FROM bom b
JOIN bom_detail bd ON b.id = bd.bom_id AND b.company_code = bd.company_code
LEFT JOIN item_info ii ON bd.child_item_id = ii.id AND b.company_code = ii.company_code
LEFT JOIN category_values cv_inv
ON cv_inv.table_name = 'item_info'
AND cv_inv.column_name = 'inventory_unit'
AND cv_inv.value_code = ii.inventory_unit
AND cv_inv.company_code = ii.company_code
AND cv_inv.is_active = true
WHERE b.item_code IN (${itemPlaceholders})
${bomCompanyCondition}
ORDER BY b.item_code, bd.seq_no
@@ -221,11 +226,7 @@ export async function getMaterialStatus(
materialCode:
bomRow.material_code || bomRow.child_item_id,
materialName: bomRow.material_name || "알 수 없음",
unit:
bomRow.material_inventory_unit ||
bomRow.bom_unit ||
bomRow.material_unit ||
"EA",
unit: bomRow.material_inventory_unit || "",
requiredQty,
width: bomRow.material_width || "",
height: bomRow.material_height || "",

View File

@@ -463,7 +463,10 @@ export async function getWorkItemDetails(req: AuthenticatedRequest, res: Respons
SELECT id, work_item_id, detail_type, content, is_required, sort_order, remark,
inspection_code, inspection_method, unit, lower_limit, upper_limit,
duration_minutes, input_type, lookup_target, display_fields,
selected_bom_items, process_inspection_apply, equip_inspection_apply, created_date
selected_bom_items, process_inspection_apply, equip_inspection_apply,
condition_unit, condition_base_value, condition_tolerance,
condition_auto_collect, condition_plc_data,
created_date
FROM process_work_item_detail
WHERE work_item_id = $1 AND company_code = $2
ORDER BY sort_order, created_date
@@ -493,6 +496,9 @@ export async function createWorkItemDetail(req: AuthenticatedRequest, res: Respo
inspection_code, inspection_method, unit, lower_limit, upper_limit,
duration_minutes, input_type, lookup_target, display_fields,
selected_bom_items, process_inspection_apply, equip_inspection_apply,
// 설비조건(equip_condition) 전용 5개 필드 — TASK:ERP-015
condition_unit, condition_base_value, condition_tolerance,
condition_auto_collect, condition_plc_data,
} = req.body;
if (!work_item_id || !content) {
@@ -516,8 +522,11 @@ export async function createWorkItemDetail(req: AuthenticatedRequest, res: Respo
(company_code, work_item_id, detail_type, content, is_required, sort_order, remark, writer,
inspection_code, inspection_method, unit, lower_limit, upper_limit,
duration_minutes, input_type, lookup_target, display_fields, selected_bom_items,
process_inspection_apply, equip_inspection_apply)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20)
process_inspection_apply, equip_inspection_apply,
condition_unit, condition_base_value, condition_tolerance,
condition_auto_collect, condition_plc_data)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20,
$21, $22, $23, $24, $25)
RETURNING *
`;
@@ -545,6 +554,11 @@ export async function createWorkItemDetail(req: AuthenticatedRequest, res: Respo
bomItemsJson,
process_inspection_apply || null,
equip_inspection_apply || null,
condition_unit || null,
condition_base_value || null,
condition_tolerance || null,
condition_auto_collect || null,
condition_plc_data || null,
]);
logger.info("작업 항목 상세 생성", { companyCode, id: result.rows[0].id });
@@ -571,6 +585,9 @@ export async function updateWorkItemDetail(req: AuthenticatedRequest, res: Respo
inspection_code, inspection_method, unit, lower_limit, upper_limit,
duration_minutes, input_type, lookup_target, display_fields,
selected_bom_items, process_inspection_apply, equip_inspection_apply,
// 설비조건(equip_condition) 전용 5개 필드 — TASK:ERP-015
condition_unit, condition_base_value, condition_tolerance,
condition_auto_collect, condition_plc_data,
} = req.body;
const bomItemsJson = Array.isArray(selected_bom_items) ? JSON.stringify(selected_bom_items) : selected_bom_items ?? null;
@@ -594,6 +611,11 @@ export async function updateWorkItemDetail(req: AuthenticatedRequest, res: Respo
selected_bom_items = $17,
process_inspection_apply = $18,
equip_inspection_apply = $19,
condition_unit = $20,
condition_base_value = $21,
condition_tolerance = $22,
condition_auto_collect = $23,
condition_plc_data = $24,
updated_date = NOW()
WHERE id = $6 AND company_code = $7
RETURNING *
@@ -619,6 +641,11 @@ export async function updateWorkItemDetail(req: AuthenticatedRequest, res: Respo
bomItemsJson,
process_inspection_apply || null,
equip_inspection_apply || null,
condition_unit ?? null,
condition_base_value ?? null,
condition_tolerance ?? null,
condition_auto_collect ?? null,
condition_plc_data ?? null,
]);
if (result.rowCount === 0) {
@@ -733,8 +760,11 @@ export async function saveAll(req: AuthenticatedRequest, res: Response) {
`INSERT INTO process_work_item_detail
(company_code, work_item_id, detail_type, content, is_required, sort_order, remark, writer,
inspection_code, inspection_method, unit, lower_limit, upper_limit,
duration_minutes, input_type, lookup_target, display_fields)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)`,
duration_minutes, input_type, lookup_target, display_fields,
condition_unit, condition_base_value, condition_tolerance,
condition_auto_collect, condition_plc_data)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17,
$18, $19, $20, $21, $22)`,
[
companyCode,
workItemId,
@@ -753,6 +783,12 @@ export async function saveAll(req: AuthenticatedRequest, res: Response) {
detail.input_type || null,
detail.lookup_target || null,
detail.display_fields || null,
// 설비조건(equip_condition) 전용 5개 — TASK:ERP-015
detail.condition_unit || null,
detail.condition_base_value || null,
detail.condition_tolerance || null,
detail.condition_auto_collect || null,
detail.condition_plc_data || null,
]
);
}

View File

@@ -665,7 +665,9 @@ export async function getWorkStandard(req: AuthenticatedRequest, res: Response)
`SELECT id, wi_work_item_id AS work_item_id, detail_type, content, is_required, sort_order, remark,
inspection_code, inspection_method, unit, lower_limit, upper_limit,
duration_minutes, input_type, lookup_target, display_fields,
process_inspection_apply, equip_inspection_apply
process_inspection_apply, equip_inspection_apply,
condition_unit, condition_base_value, condition_tolerance,
condition_auto_collect, condition_plc_data
FROM wi_process_work_item_detail
WHERE wi_work_item_id = $1 AND company_code = $2
ORDER BY sort_order`,
@@ -690,7 +692,9 @@ export async function getWorkStandard(req: AuthenticatedRequest, res: Response)
`SELECT id, work_item_id, detail_type, content, is_required, sort_order, remark,
inspection_code, inspection_method, unit, lower_limit, upper_limit,
duration_minutes, input_type, lookup_target, display_fields,
process_inspection_apply, equip_inspection_apply
process_inspection_apply, equip_inspection_apply,
condition_unit, condition_base_value, condition_tolerance,
condition_auto_collect, condition_plc_data
FROM process_work_item_detail
WHERE work_item_id = $1 AND company_code = $2
ORDER BY sort_order`,
@@ -771,9 +775,9 @@ export async function copyWorkStandard(req: AuthenticatedRequest, res: Response)
for (const origDetail of origDetails.rows) {
await client.query(
`INSERT INTO wi_process_work_item_detail (id, company_code, wi_work_item_id, detail_type, content, is_required, sort_order, remark, inspection_code, inspection_method, unit, lower_limit, upper_limit, duration_minutes, input_type, lookup_target, display_fields, process_inspection_apply, equip_inspection_apply, writer)
VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)`,
[companyCode, newItemId, origDetail.detail_type, origDetail.content, origDetail.is_required, origDetail.sort_order, origDetail.remark, origDetail.inspection_code, origDetail.inspection_method, origDetail.unit, origDetail.lower_limit, origDetail.upper_limit, origDetail.duration_minutes, origDetail.input_type, origDetail.lookup_target, origDetail.display_fields, origDetail.process_inspection_apply || null, origDetail.equip_inspection_apply || null, userId]
`INSERT INTO wi_process_work_item_detail (id, company_code, wi_work_item_id, detail_type, content, is_required, sort_order, remark, inspection_code, inspection_method, unit, lower_limit, upper_limit, duration_minutes, input_type, lookup_target, display_fields, process_inspection_apply, equip_inspection_apply, condition_unit, condition_base_value, condition_tolerance, condition_auto_collect, condition_plc_data, writer)
VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24)`,
[companyCode, newItemId, origDetail.detail_type, origDetail.content, origDetail.is_required, origDetail.sort_order, origDetail.remark, origDetail.inspection_code, origDetail.inspection_method, origDetail.unit, origDetail.lower_limit, origDetail.upper_limit, origDetail.duration_minutes, origDetail.input_type, origDetail.lookup_target, origDetail.display_fields, origDetail.process_inspection_apply || null, origDetail.equip_inspection_apply || null, origDetail.condition_unit || null, origDetail.condition_base_value || null, origDetail.condition_tolerance || null, origDetail.condition_auto_collect || null, origDetail.condition_plc_data || null, userId]
);
}
}
@@ -838,9 +842,9 @@ export async function saveWorkStandard(req: AuthenticatedRequest, res: Response)
if (wi.details && Array.isArray(wi.details)) {
for (const d of wi.details) {
await client.query(
`INSERT INTO wi_process_work_item_detail (id, company_code, wi_work_item_id, detail_type, content, is_required, sort_order, remark, inspection_code, inspection_method, unit, lower_limit, upper_limit, duration_minutes, input_type, lookup_target, display_fields, process_inspection_apply, equip_inspection_apply, writer)
VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)`,
[companyCode, newId, d.detail_type, d.content, d.is_required, d.sort_order, d.remark || null, d.inspection_code || null, d.inspection_method || null, d.unit || null, d.lower_limit || null, d.upper_limit || null, d.duration_minutes || null, d.input_type || null, d.lookup_target || null, d.display_fields || null, d.process_inspection_apply || null, d.equip_inspection_apply || null, userId]
`INSERT INTO wi_process_work_item_detail (id, company_code, wi_work_item_id, detail_type, content, is_required, sort_order, remark, inspection_code, inspection_method, unit, lower_limit, upper_limit, duration_minutes, input_type, lookup_target, display_fields, process_inspection_apply, equip_inspection_apply, condition_unit, condition_base_value, condition_tolerance, condition_auto_collect, condition_plc_data, writer)
VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24)`,
[companyCode, newId, d.detail_type, d.content, d.is_required, d.sort_order, d.remark || null, d.inspection_code || null, d.inspection_method || null, d.unit || null, d.lower_limit || null, d.upper_limit || null, d.duration_minutes || null, d.input_type || null, d.lookup_target || null, d.display_fields || null, d.process_inspection_apply || null, d.equip_inspection_apply || null, d.condition_unit || null, d.condition_base_value || null, d.condition_tolerance || null, d.condition_auto_collect || null, d.condition_plc_data || null, userId]
);
}
}

View File

@@ -30,6 +30,9 @@ import {
Inbox,
Settings2,
Upload,
ArrowUpDown,
ArrowUp,
ArrowDown,
} from "lucide-react";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { ImageUpload } from "@/components/common/ImageUpload";
@@ -113,6 +116,8 @@ export default function InspectionManagementPage() {
const [eqForm, setEqForm] = useState<Record<string, any>>({});
const [eqSaving, setEqSaving] = useState(false);
const [eqKeyword, setEqKeyword] = useState("");
const [eqSortKey, setEqSortKey] = useState<string | null>(null);
const [eqSortDir, setEqSortDir] = useState<"asc" | "desc">("asc");
/* ───── 채번 ───── */
const [numberingRuleId, setNumberingRuleId] = useState<string | null>(null);
@@ -288,13 +293,54 @@ export default function InspectionManagementPage() {
)
: defects;
const filteredEquipments = eqKeyword.trim()
? equipments.filter(
(r) =>
(r.equipment_name || "").toLowerCase().includes(eqKeyword.toLowerCase()) ||
(r.model_name || "").toLowerCase().includes(eqKeyword.toLowerCase()),
)
: equipments;
const filteredEquipments = useMemo(() => {
const base = eqKeyword.trim()
? equipments.filter(
(r) =>
(r.equipment_name || "").toLowerCase().includes(eqKeyword.toLowerCase()) ||
(r.model_name || "").toLowerCase().includes(eqKeyword.toLowerCase()),
)
: equipments;
if (!eqSortKey) return base;
const key = eqSortKey;
const dir = eqSortDir;
return [...base].sort((a, b) => {
const av = a[key];
const bv = b[key];
const aEmpty = av === null || av === undefined || av === "";
const bEmpty = bv === null || bv === undefined || bv === "";
if (aEmpty && bEmpty) return 0;
if (aEmpty) return 1;
if (bEmpty) return -1;
const na = Number(av);
const nb = Number(bv);
if (!isNaN(na) && !isNaN(nb)) return dir === "asc" ? na - nb : nb - na;
const sa = String(av).toLowerCase();
const sb = String(bv).toLowerCase();
return dir === "asc" ? sa.localeCompare(sb) : sb.localeCompare(sa);
});
}, [equipments, eqKeyword, eqSortKey, eqSortDir]);
const handleEqSort = useCallback((key: string) => {
setEqSortKey((prev) => {
if (prev === key) {
setEqSortDir((d) => (d === "asc" ? "desc" : "asc"));
return prev;
}
setEqSortDir("asc");
return key;
});
}, []);
const renderEqSortIcon = (key: string) => {
if (eqSortKey !== key)
return <ArrowUpDown className="ml-1 inline h-3 w-3 opacity-40" />;
return eqSortDir === "asc" ? (
<ArrowUp className="ml-1 inline h-3 w-3" />
) : (
<ArrowDown className="ml-1 inline h-3 w-3" />
);
};
/* ═══════════════════ 검사기준 CRUD ═══════════════════ */
const openInspCreate = async () => {
@@ -1586,35 +1632,35 @@ export default function InspectionManagementPage() {
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase w-[60px] text-center">
</TableHead>
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase cursor-pointer select-none hover:text-foreground" onClick={() => handleEqSort("equipment_code")}>
{renderEqSortIcon("equipment_code")}
</TableHead>
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase cursor-pointer select-none hover:text-foreground" onClick={() => handleEqSort("equipment_name")}>
{renderEqSortIcon("equipment_name")}
</TableHead>
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase cursor-pointer select-none hover:text-foreground" onClick={() => handleEqSort("equipment_type")}>
{renderEqSortIcon("equipment_type")}
</TableHead>
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase cursor-pointer select-none hover:text-foreground" onClick={() => handleEqSort("model_name")}>
{renderEqSortIcon("model_name")}
</TableHead>
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase cursor-pointer select-none hover:text-foreground" onClick={() => handleEqSort("manufacturer")}>
{renderEqSortIcon("manufacturer")}
</TableHead>
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase cursor-pointer select-none hover:text-foreground" onClick={() => handleEqSort("installation_location")}>
{renderEqSortIcon("installation_location")}
</TableHead>
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase cursor-pointer select-none hover:text-foreground" onClick={() => handleEqSort("last_calibration_date")}>
{renderEqSortIcon("last_calibration_date")}
</TableHead>
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
()
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase cursor-pointer select-none hover:text-foreground" onClick={() => handleEqSort("calibration_period")}>
(){renderEqSortIcon("calibration_period")}
</TableHead>
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase cursor-pointer select-none hover:text-foreground" onClick={() => handleEqSort("equipment_status")}>
{renderEqSortIcon("equipment_status")}
</TableHead>
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase cursor-pointer select-none hover:text-foreground" onClick={() => handleEqSort("manager_id")}>
{renderEqSortIcon("manager_id")}
</TableHead>
</TableRow>
</TableHeader>

View File

@@ -30,6 +30,9 @@ import {
Inbox,
Settings2,
Upload,
ArrowUpDown,
ArrowUp,
ArrowDown,
} from "lucide-react";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { ImageUpload } from "@/components/common/ImageUpload";
@@ -113,6 +116,8 @@ export default function InspectionManagementPage() {
const [eqForm, setEqForm] = useState<Record<string, any>>({});
const [eqSaving, setEqSaving] = useState(false);
const [eqKeyword, setEqKeyword] = useState("");
const [eqSortKey, setEqSortKey] = useState<string | null>(null);
const [eqSortDir, setEqSortDir] = useState<"asc" | "desc">("asc");
/* ───── 채번 ───── */
const [numberingRuleId, setNumberingRuleId] = useState<string | null>(null);
@@ -288,13 +293,54 @@ export default function InspectionManagementPage() {
)
: defects;
const filteredEquipments = eqKeyword.trim()
? equipments.filter(
(r) =>
(r.equipment_name || "").toLowerCase().includes(eqKeyword.toLowerCase()) ||
(r.model_name || "").toLowerCase().includes(eqKeyword.toLowerCase()),
)
: equipments;
const filteredEquipments = useMemo(() => {
const base = eqKeyword.trim()
? equipments.filter(
(r) =>
(r.equipment_name || "").toLowerCase().includes(eqKeyword.toLowerCase()) ||
(r.model_name || "").toLowerCase().includes(eqKeyword.toLowerCase()),
)
: equipments;
if (!eqSortKey) return base;
const key = eqSortKey;
const dir = eqSortDir;
return [...base].sort((a, b) => {
const av = a[key];
const bv = b[key];
const aEmpty = av === null || av === undefined || av === "";
const bEmpty = bv === null || bv === undefined || bv === "";
if (aEmpty && bEmpty) return 0;
if (aEmpty) return 1;
if (bEmpty) return -1;
const na = Number(av);
const nb = Number(bv);
if (!isNaN(na) && !isNaN(nb)) return dir === "asc" ? na - nb : nb - na;
const sa = String(av).toLowerCase();
const sb = String(bv).toLowerCase();
return dir === "asc" ? sa.localeCompare(sb) : sb.localeCompare(sa);
});
}, [equipments, eqKeyword, eqSortKey, eqSortDir]);
const handleEqSort = useCallback((key: string) => {
setEqSortKey((prev) => {
if (prev === key) {
setEqSortDir((d) => (d === "asc" ? "desc" : "asc"));
return prev;
}
setEqSortDir("asc");
return key;
});
}, []);
const renderEqSortIcon = (key: string) => {
if (eqSortKey !== key)
return <ArrowUpDown className="ml-1 inline h-3 w-3 opacity-40" />;
return eqSortDir === "asc" ? (
<ArrowUp className="ml-1 inline h-3 w-3" />
) : (
<ArrowDown className="ml-1 inline h-3 w-3" />
);
};
/* ═══════════════════ 검사기준 CRUD ═══════════════════ */
const openInspCreate = async () => {
@@ -1586,35 +1632,35 @@ export default function InspectionManagementPage() {
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase w-[60px] text-center">
</TableHead>
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase cursor-pointer select-none hover:text-foreground" onClick={() => handleEqSort("equipment_code")}>
{renderEqSortIcon("equipment_code")}
</TableHead>
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase cursor-pointer select-none hover:text-foreground" onClick={() => handleEqSort("equipment_name")}>
{renderEqSortIcon("equipment_name")}
</TableHead>
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase cursor-pointer select-none hover:text-foreground" onClick={() => handleEqSort("equipment_type")}>
{renderEqSortIcon("equipment_type")}
</TableHead>
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase cursor-pointer select-none hover:text-foreground" onClick={() => handleEqSort("model_name")}>
{renderEqSortIcon("model_name")}
</TableHead>
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase cursor-pointer select-none hover:text-foreground" onClick={() => handleEqSort("manufacturer")}>
{renderEqSortIcon("manufacturer")}
</TableHead>
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase cursor-pointer select-none hover:text-foreground" onClick={() => handleEqSort("installation_location")}>
{renderEqSortIcon("installation_location")}
</TableHead>
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase cursor-pointer select-none hover:text-foreground" onClick={() => handleEqSort("last_calibration_date")}>
{renderEqSortIcon("last_calibration_date")}
</TableHead>
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
()
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase cursor-pointer select-none hover:text-foreground" onClick={() => handleEqSort("calibration_period")}>
(){renderEqSortIcon("calibration_period")}
</TableHead>
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase cursor-pointer select-none hover:text-foreground" onClick={() => handleEqSort("equipment_status")}>
{renderEqSortIcon("equipment_status")}
</TableHead>
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase cursor-pointer select-none hover:text-foreground" onClick={() => handleEqSort("manager_id")}>
{renderEqSortIcon("manager_id")}
</TableHead>
</TableRow>
</TableHeader>

View File

@@ -74,7 +74,7 @@ export default function ItemInspectionInfoPage() {
const [saving, setSaving] = useState(false);
// FK 옵션
const [itemOptions, setItemOptions] = useState<{ code: string; name: string; item_type: string; unit: string }[]>([]);
const [itemOptions, setItemOptions] = useState<{ code: string; name: string; item_type: string; unit: string; size: string }[]>([]);
const [inspOptions, setInspOptions] = useState<{ code: string; label: string; detail: string; method: string; judgment_criteria: string; selection_options: string; unit: string; types: string[] }[]>([]);
const [inspTypeCatOptions, setInspTypeCatOptions] = useState<{ code: string; label: string }[]>([]);
@@ -136,6 +136,7 @@ export default function ItemInspectionInfoPage() {
name: r.item_name || "",
item_type: r.type || r.item_type || "",
unit: r.inventory_unit || "",
size: r.size || "",
})));
const insps = inspRes.data?.data?.data || inspRes.data?.data?.rows || [];
@@ -244,7 +245,7 @@ export default function ItemInspectionInfoPage() {
const resData = res.data?.data;
const rows = resData?.data || resData?.rows || [];
const cm = itemCatMapRef.current;
setFilteredItems(rows.map((r: any) => ({ code: r.item_number, name: r.item_name, item_type: cm["type"]?.[r.type] || r.type || "", unit: cm["inventory_unit"]?.[r.inventory_unit] || r.inventory_unit || "" })));
setFilteredItems(rows.map((r: any) => ({ code: r.item_number, name: r.item_name, item_type: cm["type"]?.[r.type] || r.type || "", unit: cm["inventory_unit"]?.[r.inventory_unit] || r.inventory_unit || "", size: r.size || "" })));
setItemTotal(resData?.total || resData?.totalCount || rows.length);
} catch { /* skip */ } finally { setItemSearchLoading(false); }
};
@@ -296,6 +297,7 @@ export default function ItemInspectionInfoPage() {
name: r.item_name,
item_type: cm["type"]?.[r.type] || r.type || "",
unit: cm["inventory_unit"]?.[r.inventory_unit] || r.inventory_unit || "",
size: r.size || "",
}));
setCopyFilteredItems(list);
setCopyTotal(resData?.total || resData?.totalCount || rows.length);
@@ -1244,17 +1246,19 @@ export default function ItemInspectionInfoPage() {
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="text-[11px] font-bold w-[140px]"></TableHead>
<TableHead className="text-[11px] font-bold"></TableHead>
<TableHead className="text-[11px] font-bold w-[120px]"></TableHead>
<TableHead className="text-[11px] font-bold w-[100px]"></TableHead>
<TableHead className="text-[11px] font-bold w-[80px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredItems.length === 0 ? (
<TableRow><TableCell colSpan={4} className="text-center py-8 text-muted-foreground text-sm">{itemSearchLoading ? "검색 중..." : "검색 결과가 없어요"}</TableCell></TableRow>
<TableRow><TableCell colSpan={5} className="text-center py-8 text-muted-foreground text-sm">{itemSearchLoading ? "검색 중..." : "검색 결과가 없어요"}</TableCell></TableRow>
) : filteredItems.map((item) => (
<TableRow key={item.code} className="cursor-pointer hover:bg-primary/5" onClick={() => selectItem(item)}>
<TableCell className="text-sm font-mono">{item.code}</TableCell>
<TableCell className="text-sm">{item.name}</TableCell>
<TableCell className="text-sm truncate max-w-[120px]" title={item.size}>{item.size}</TableCell>
<TableCell className="text-sm">{item.item_type}</TableCell>
<TableCell className="text-sm">{item.unit}</TableCell>
</TableRow>

View File

@@ -30,6 +30,9 @@ import {
Inbox,
Settings2,
Upload,
ArrowUpDown,
ArrowUp,
ArrowDown,
} from "lucide-react";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { ImageUpload } from "@/components/common/ImageUpload";
@@ -113,6 +116,8 @@ export default function InspectionManagementPage() {
const [eqForm, setEqForm] = useState<Record<string, any>>({});
const [eqSaving, setEqSaving] = useState(false);
const [eqKeyword, setEqKeyword] = useState("");
const [eqSortKey, setEqSortKey] = useState<string | null>(null);
const [eqSortDir, setEqSortDir] = useState<"asc" | "desc">("asc");
/* ───── 채번 ───── */
const [numberingRuleId, setNumberingRuleId] = useState<string | null>(null);
@@ -288,13 +293,54 @@ export default function InspectionManagementPage() {
)
: defects;
const filteredEquipments = eqKeyword.trim()
? equipments.filter(
(r) =>
(r.equipment_name || "").toLowerCase().includes(eqKeyword.toLowerCase()) ||
(r.model_name || "").toLowerCase().includes(eqKeyword.toLowerCase()),
)
: equipments;
const filteredEquipments = useMemo(() => {
const base = eqKeyword.trim()
? equipments.filter(
(r) =>
(r.equipment_name || "").toLowerCase().includes(eqKeyword.toLowerCase()) ||
(r.model_name || "").toLowerCase().includes(eqKeyword.toLowerCase()),
)
: equipments;
if (!eqSortKey) return base;
const key = eqSortKey;
const dir = eqSortDir;
return [...base].sort((a, b) => {
const av = a[key];
const bv = b[key];
const aEmpty = av === null || av === undefined || av === "";
const bEmpty = bv === null || bv === undefined || bv === "";
if (aEmpty && bEmpty) return 0;
if (aEmpty) return 1;
if (bEmpty) return -1;
const na = Number(av);
const nb = Number(bv);
if (!isNaN(na) && !isNaN(nb)) return dir === "asc" ? na - nb : nb - na;
const sa = String(av).toLowerCase();
const sb = String(bv).toLowerCase();
return dir === "asc" ? sa.localeCompare(sb) : sb.localeCompare(sa);
});
}, [equipments, eqKeyword, eqSortKey, eqSortDir]);
const handleEqSort = useCallback((key: string) => {
setEqSortKey((prev) => {
if (prev === key) {
setEqSortDir((d) => (d === "asc" ? "desc" : "asc"));
return prev;
}
setEqSortDir("asc");
return key;
});
}, []);
const renderEqSortIcon = (key: string) => {
if (eqSortKey !== key)
return <ArrowUpDown className="ml-1 inline h-3 w-3 opacity-40" />;
return eqSortDir === "asc" ? (
<ArrowUp className="ml-1 inline h-3 w-3" />
) : (
<ArrowDown className="ml-1 inline h-3 w-3" />
);
};
/* ═══════════════════ 검사기준 CRUD ═══════════════════ */
const openInspCreate = async () => {
@@ -1586,35 +1632,35 @@ export default function InspectionManagementPage() {
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase w-[60px] text-center">
</TableHead>
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase cursor-pointer select-none hover:text-foreground" onClick={() => handleEqSort("equipment_code")}>
{renderEqSortIcon("equipment_code")}
</TableHead>
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase cursor-pointer select-none hover:text-foreground" onClick={() => handleEqSort("equipment_name")}>
{renderEqSortIcon("equipment_name")}
</TableHead>
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase cursor-pointer select-none hover:text-foreground" onClick={() => handleEqSort("equipment_type")}>
{renderEqSortIcon("equipment_type")}
</TableHead>
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase cursor-pointer select-none hover:text-foreground" onClick={() => handleEqSort("model_name")}>
{renderEqSortIcon("model_name")}
</TableHead>
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase cursor-pointer select-none hover:text-foreground" onClick={() => handleEqSort("manufacturer")}>
{renderEqSortIcon("manufacturer")}
</TableHead>
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase cursor-pointer select-none hover:text-foreground" onClick={() => handleEqSort("installation_location")}>
{renderEqSortIcon("installation_location")}
</TableHead>
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase cursor-pointer select-none hover:text-foreground" onClick={() => handleEqSort("last_calibration_date")}>
{renderEqSortIcon("last_calibration_date")}
</TableHead>
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
()
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase cursor-pointer select-none hover:text-foreground" onClick={() => handleEqSort("calibration_period")}>
(){renderEqSortIcon("calibration_period")}
</TableHead>
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase cursor-pointer select-none hover:text-foreground" onClick={() => handleEqSort("equipment_status")}>
{renderEqSortIcon("equipment_status")}
</TableHead>
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase cursor-pointer select-none hover:text-foreground" onClick={() => handleEqSort("manager_id")}>
{renderEqSortIcon("manager_id")}
</TableHead>
</TableRow>
</TableHeader>

View File

@@ -30,6 +30,9 @@ import {
Inbox,
Settings2,
Upload,
ArrowUpDown,
ArrowUp,
ArrowDown,
} from "lucide-react";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { ImageUpload } from "@/components/common/ImageUpload";
@@ -113,6 +116,8 @@ export default function InspectionManagementPage() {
const [eqForm, setEqForm] = useState<Record<string, any>>({});
const [eqSaving, setEqSaving] = useState(false);
const [eqKeyword, setEqKeyword] = useState("");
const [eqSortKey, setEqSortKey] = useState<string | null>(null);
const [eqSortDir, setEqSortDir] = useState<"asc" | "desc">("asc");
/* ───── 채번 ───── */
const [numberingRuleId, setNumberingRuleId] = useState<string | null>(null);
@@ -288,13 +293,54 @@ export default function InspectionManagementPage() {
)
: defects;
const filteredEquipments = eqKeyword.trim()
? equipments.filter(
(r) =>
(r.equipment_name || "").toLowerCase().includes(eqKeyword.toLowerCase()) ||
(r.model_name || "").toLowerCase().includes(eqKeyword.toLowerCase()),
)
: equipments;
const filteredEquipments = useMemo(() => {
const base = eqKeyword.trim()
? equipments.filter(
(r) =>
(r.equipment_name || "").toLowerCase().includes(eqKeyword.toLowerCase()) ||
(r.model_name || "").toLowerCase().includes(eqKeyword.toLowerCase()),
)
: equipments;
if (!eqSortKey) return base;
const key = eqSortKey;
const dir = eqSortDir;
return [...base].sort((a, b) => {
const av = a[key];
const bv = b[key];
const aEmpty = av === null || av === undefined || av === "";
const bEmpty = bv === null || bv === undefined || bv === "";
if (aEmpty && bEmpty) return 0;
if (aEmpty) return 1;
if (bEmpty) return -1;
const na = Number(av);
const nb = Number(bv);
if (!isNaN(na) && !isNaN(nb)) return dir === "asc" ? na - nb : nb - na;
const sa = String(av).toLowerCase();
const sb = String(bv).toLowerCase();
return dir === "asc" ? sa.localeCompare(sb) : sb.localeCompare(sa);
});
}, [equipments, eqKeyword, eqSortKey, eqSortDir]);
const handleEqSort = useCallback((key: string) => {
setEqSortKey((prev) => {
if (prev === key) {
setEqSortDir((d) => (d === "asc" ? "desc" : "asc"));
return prev;
}
setEqSortDir("asc");
return key;
});
}, []);
const renderEqSortIcon = (key: string) => {
if (eqSortKey !== key)
return <ArrowUpDown className="ml-1 inline h-3 w-3 opacity-40" />;
return eqSortDir === "asc" ? (
<ArrowUp className="ml-1 inline h-3 w-3" />
) : (
<ArrowDown className="ml-1 inline h-3 w-3" />
);
};
/* ═══════════════════ 검사기준 CRUD ═══════════════════ */
const openInspCreate = async () => {
@@ -1586,35 +1632,35 @@ export default function InspectionManagementPage() {
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase w-[60px] text-center">
</TableHead>
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase cursor-pointer select-none hover:text-foreground" onClick={() => handleEqSort("equipment_code")}>
{renderEqSortIcon("equipment_code")}
</TableHead>
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase cursor-pointer select-none hover:text-foreground" onClick={() => handleEqSort("equipment_name")}>
{renderEqSortIcon("equipment_name")}
</TableHead>
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase cursor-pointer select-none hover:text-foreground" onClick={() => handleEqSort("equipment_type")}>
{renderEqSortIcon("equipment_type")}
</TableHead>
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase cursor-pointer select-none hover:text-foreground" onClick={() => handleEqSort("model_name")}>
{renderEqSortIcon("model_name")}
</TableHead>
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase cursor-pointer select-none hover:text-foreground" onClick={() => handleEqSort("manufacturer")}>
{renderEqSortIcon("manufacturer")}
</TableHead>
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase cursor-pointer select-none hover:text-foreground" onClick={() => handleEqSort("installation_location")}>
{renderEqSortIcon("installation_location")}
</TableHead>
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase cursor-pointer select-none hover:text-foreground" onClick={() => handleEqSort("last_calibration_date")}>
{renderEqSortIcon("last_calibration_date")}
</TableHead>
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
()
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase cursor-pointer select-none hover:text-foreground" onClick={() => handleEqSort("calibration_period")}>
(){renderEqSortIcon("calibration_period")}
</TableHead>
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase cursor-pointer select-none hover:text-foreground" onClick={() => handleEqSort("equipment_status")}>
{renderEqSortIcon("equipment_status")}
</TableHead>
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase cursor-pointer select-none hover:text-foreground" onClick={() => handleEqSort("manager_id")}>
{renderEqSortIcon("manager_id")}
</TableHead>
</TableRow>
</TableHeader>

View File

@@ -30,6 +30,9 @@ import {
Inbox,
Settings2,
Upload,
ArrowUpDown,
ArrowUp,
ArrowDown,
} from "lucide-react";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { ImageUpload } from "@/components/common/ImageUpload";
@@ -113,6 +116,8 @@ export default function InspectionManagementPage() {
const [eqForm, setEqForm] = useState<Record<string, any>>({});
const [eqSaving, setEqSaving] = useState(false);
const [eqKeyword, setEqKeyword] = useState("");
const [eqSortKey, setEqSortKey] = useState<string | null>(null);
const [eqSortDir, setEqSortDir] = useState<"asc" | "desc">("asc");
/* ───── 채번 ───── */
const [numberingRuleId, setNumberingRuleId] = useState<string | null>(null);
@@ -288,13 +293,54 @@ export default function InspectionManagementPage() {
)
: defects;
const filteredEquipments = eqKeyword.trim()
? equipments.filter(
(r) =>
(r.equipment_name || "").toLowerCase().includes(eqKeyword.toLowerCase()) ||
(r.model_name || "").toLowerCase().includes(eqKeyword.toLowerCase()),
)
: equipments;
const filteredEquipments = useMemo(() => {
const base = eqKeyword.trim()
? equipments.filter(
(r) =>
(r.equipment_name || "").toLowerCase().includes(eqKeyword.toLowerCase()) ||
(r.model_name || "").toLowerCase().includes(eqKeyword.toLowerCase()),
)
: equipments;
if (!eqSortKey) return base;
const key = eqSortKey;
const dir = eqSortDir;
return [...base].sort((a, b) => {
const av = a[key];
const bv = b[key];
const aEmpty = av === null || av === undefined || av === "";
const bEmpty = bv === null || bv === undefined || bv === "";
if (aEmpty && bEmpty) return 0;
if (aEmpty) return 1;
if (bEmpty) return -1;
const na = Number(av);
const nb = Number(bv);
if (!isNaN(na) && !isNaN(nb)) return dir === "asc" ? na - nb : nb - na;
const sa = String(av).toLowerCase();
const sb = String(bv).toLowerCase();
return dir === "asc" ? sa.localeCompare(sb) : sb.localeCompare(sa);
});
}, [equipments, eqKeyword, eqSortKey, eqSortDir]);
const handleEqSort = useCallback((key: string) => {
setEqSortKey((prev) => {
if (prev === key) {
setEqSortDir((d) => (d === "asc" ? "desc" : "asc"));
return prev;
}
setEqSortDir("asc");
return key;
});
}, []);
const renderEqSortIcon = (key: string) => {
if (eqSortKey !== key)
return <ArrowUpDown className="ml-1 inline h-3 w-3 opacity-40" />;
return eqSortDir === "asc" ? (
<ArrowUp className="ml-1 inline h-3 w-3" />
) : (
<ArrowDown className="ml-1 inline h-3 w-3" />
);
};
/* ═══════════════════ 검사기준 CRUD ═══════════════════ */
const openInspCreate = async () => {
@@ -1586,35 +1632,35 @@ export default function InspectionManagementPage() {
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase w-[60px] text-center">
</TableHead>
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase cursor-pointer select-none hover:text-foreground" onClick={() => handleEqSort("equipment_code")}>
{renderEqSortIcon("equipment_code")}
</TableHead>
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase cursor-pointer select-none hover:text-foreground" onClick={() => handleEqSort("equipment_name")}>
{renderEqSortIcon("equipment_name")}
</TableHead>
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase cursor-pointer select-none hover:text-foreground" onClick={() => handleEqSort("equipment_type")}>
{renderEqSortIcon("equipment_type")}
</TableHead>
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase cursor-pointer select-none hover:text-foreground" onClick={() => handleEqSort("model_name")}>
{renderEqSortIcon("model_name")}
</TableHead>
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase cursor-pointer select-none hover:text-foreground" onClick={() => handleEqSort("manufacturer")}>
{renderEqSortIcon("manufacturer")}
</TableHead>
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase cursor-pointer select-none hover:text-foreground" onClick={() => handleEqSort("installation_location")}>
{renderEqSortIcon("installation_location")}
</TableHead>
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase cursor-pointer select-none hover:text-foreground" onClick={() => handleEqSort("last_calibration_date")}>
{renderEqSortIcon("last_calibration_date")}
</TableHead>
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
()
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase cursor-pointer select-none hover:text-foreground" onClick={() => handleEqSort("calibration_period")}>
(){renderEqSortIcon("calibration_period")}
</TableHead>
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase cursor-pointer select-none hover:text-foreground" onClick={() => handleEqSort("equipment_status")}>
{renderEqSortIcon("equipment_status")}
</TableHead>
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase cursor-pointer select-none hover:text-foreground" onClick={() => handleEqSort("manager_id")}>
{renderEqSortIcon("manager_id")}
</TableHead>
</TableRow>
</TableHeader>

View File

@@ -30,6 +30,9 @@ import {
Inbox,
Settings2,
Upload,
ArrowUpDown,
ArrowUp,
ArrowDown,
} from "lucide-react";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { ImageUpload } from "@/components/common/ImageUpload";
@@ -113,6 +116,8 @@ export default function InspectionManagementPage() {
const [eqForm, setEqForm] = useState<Record<string, any>>({});
const [eqSaving, setEqSaving] = useState(false);
const [eqKeyword, setEqKeyword] = useState("");
const [eqSortKey, setEqSortKey] = useState<string | null>(null);
const [eqSortDir, setEqSortDir] = useState<"asc" | "desc">("asc");
/* ───── 채번 ───── */
const [numberingRuleId, setNumberingRuleId] = useState<string | null>(null);
@@ -288,13 +293,54 @@ export default function InspectionManagementPage() {
)
: defects;
const filteredEquipments = eqKeyword.trim()
? equipments.filter(
(r) =>
(r.equipment_name || "").toLowerCase().includes(eqKeyword.toLowerCase()) ||
(r.model_name || "").toLowerCase().includes(eqKeyword.toLowerCase()),
)
: equipments;
const filteredEquipments = useMemo(() => {
const base = eqKeyword.trim()
? equipments.filter(
(r) =>
(r.equipment_name || "").toLowerCase().includes(eqKeyword.toLowerCase()) ||
(r.model_name || "").toLowerCase().includes(eqKeyword.toLowerCase()),
)
: equipments;
if (!eqSortKey) return base;
const key = eqSortKey;
const dir = eqSortDir;
return [...base].sort((a, b) => {
const av = a[key];
const bv = b[key];
const aEmpty = av === null || av === undefined || av === "";
const bEmpty = bv === null || bv === undefined || bv === "";
if (aEmpty && bEmpty) return 0;
if (aEmpty) return 1;
if (bEmpty) return -1;
const na = Number(av);
const nb = Number(bv);
if (!isNaN(na) && !isNaN(nb)) return dir === "asc" ? na - nb : nb - na;
const sa = String(av).toLowerCase();
const sb = String(bv).toLowerCase();
return dir === "asc" ? sa.localeCompare(sb) : sb.localeCompare(sa);
});
}, [equipments, eqKeyword, eqSortKey, eqSortDir]);
const handleEqSort = useCallback((key: string) => {
setEqSortKey((prev) => {
if (prev === key) {
setEqSortDir((d) => (d === "asc" ? "desc" : "asc"));
return prev;
}
setEqSortDir("asc");
return key;
});
}, []);
const renderEqSortIcon = (key: string) => {
if (eqSortKey !== key)
return <ArrowUpDown className="ml-1 inline h-3 w-3 opacity-40" />;
return eqSortDir === "asc" ? (
<ArrowUp className="ml-1 inline h-3 w-3" />
) : (
<ArrowDown className="ml-1 inline h-3 w-3" />
);
};
/* ═══════════════════ 검사기준 CRUD ═══════════════════ */
const openInspCreate = async () => {
@@ -1586,35 +1632,35 @@ export default function InspectionManagementPage() {
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase w-[60px] text-center">
</TableHead>
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase cursor-pointer select-none hover:text-foreground" onClick={() => handleEqSort("equipment_code")}>
{renderEqSortIcon("equipment_code")}
</TableHead>
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase cursor-pointer select-none hover:text-foreground" onClick={() => handleEqSort("equipment_name")}>
{renderEqSortIcon("equipment_name")}
</TableHead>
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase cursor-pointer select-none hover:text-foreground" onClick={() => handleEqSort("equipment_type")}>
{renderEqSortIcon("equipment_type")}
</TableHead>
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase cursor-pointer select-none hover:text-foreground" onClick={() => handleEqSort("model_name")}>
{renderEqSortIcon("model_name")}
</TableHead>
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase cursor-pointer select-none hover:text-foreground" onClick={() => handleEqSort("manufacturer")}>
{renderEqSortIcon("manufacturer")}
</TableHead>
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase cursor-pointer select-none hover:text-foreground" onClick={() => handleEqSort("installation_location")}>
{renderEqSortIcon("installation_location")}
</TableHead>
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase cursor-pointer select-none hover:text-foreground" onClick={() => handleEqSort("last_calibration_date")}>
{renderEqSortIcon("last_calibration_date")}
</TableHead>
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
()
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase cursor-pointer select-none hover:text-foreground" onClick={() => handleEqSort("calibration_period")}>
(){renderEqSortIcon("calibration_period")}
</TableHead>
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase cursor-pointer select-none hover:text-foreground" onClick={() => handleEqSort("equipment_status")}>
{renderEqSortIcon("equipment_status")}
</TableHead>
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase cursor-pointer select-none hover:text-foreground" onClick={() => handleEqSort("manager_id")}>
{renderEqSortIcon("manager_id")}
</TableHead>
</TableRow>
</TableHeader>

View File

@@ -0,0 +1,376 @@
"use client";
/**
* 절단계획 → 작업지시 적용 모달
* jskim-node의 작업지시 모달 구조와 호환 (품목별 일정/설비/작업조/작업자 지정).
* 저장 시 마스터에 batch_no(=plan_no) / cutting_plan_id 를 함께 전달.
*/
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { CheckCircle2, ChevronsUpDown, Loader2, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
} from "@/components/ui/dialog";
import {
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
} from "@/components/ui/select";
import {
Popover, PopoverContent, PopoverTrigger,
} from "@/components/ui/popover";
import {
Table, TableHeader, TableRow, TableHead, TableBody, TableCell,
} from "@/components/ui/table";
import { cn } from "@/lib/utils";
import {
previewWorkInstructionNo, saveWorkInstruction,
getEquipmentList, getEmployeeList,
} from "@/lib/api/workInstruction";
// ─── 공용 다중선택 Popover (설비/작업조/작업자) ────────────────────
interface MultiSelectOption { value: string; label: string; sub?: string; }
interface MultiSelectPopoverProps {
options: MultiSelectOption[];
value: string[];
onChange: (next: string[]) => void;
placeholder?: string;
searchable?: boolean;
triggerClassName?: string;
emptyMessage?: string;
}
function MultiSelectPopover({
options, value, onChange, placeholder = "선택", searchable = false, triggerClassName, emptyMessage = "항목이 없어요",
}: MultiSelectPopoverProps) {
const [open, setOpen] = useState(false);
const [keyword, setKeyword] = useState("");
const selectedSet = useMemo(() => new Set(value), [value]);
const toggle = (val: string) => {
if (selectedSet.has(val)) onChange(value.filter((v) => v !== val));
else onChange([...value, val]);
};
const filtered = useMemo(() => {
if (!searchable || !keyword.trim()) return options;
const k = keyword.trim().toLowerCase();
return options.filter((o) => o.label.toLowerCase().includes(k) || (o.sub || "").toLowerCase().includes(k));
}, [options, keyword, searchable]);
const display = useMemo(() => {
if (value.length === 0) return placeholder;
if (value.length === 1) return options.find((o) => o.value === value[0])?.label || value[0];
if (value.length === 2) {
return value.map((v) => options.find((o) => o.value === v)?.label || v).join(", ");
}
return `${value.length}개 선택`;
}, [value, options, placeholder]);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" aria-expanded={open}
className={cn("w-full justify-between font-normal", triggerClassName || "h-7 text-xs")}>
<span className={cn("truncate", value.length === 0 && "text-muted-foreground")}>{display}</span>
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)", minWidth: 200 }} align="start">
{searchable && (
<div className="p-2 border-b">
<Input placeholder="검색..." value={keyword} onChange={(e) => setKeyword(e.target.value)} className="h-7 text-xs" />
</div>
)}
<div className="max-h-56 overflow-y-auto py-1">
{filtered.length === 0 ? (
<div className="py-4 text-center text-xs text-muted-foreground">{emptyMessage}</div>
) : filtered.map((opt) => (
<label
key={opt.value}
className="flex items-center gap-2 px-2 py-1.5 cursor-pointer hover:bg-muted/50 text-xs"
onClick={(e) => { e.preventDefault(); toggle(opt.value); }}
>
<Checkbox checked={selectedSet.has(opt.value)} onCheckedChange={() => toggle(opt.value)} className="h-3.5 w-3.5" />
<span className="flex-1 truncate">
{opt.label}{opt.sub ? <span className="text-muted-foreground ml-1">({opt.sub})</span> : null}
</span>
</label>
))}
</div>
{value.length > 0 && (
<div className="p-1.5 border-t flex items-center justify-between">
<span className="text-[10px] text-muted-foreground">{value.length} </span>
<Button variant="ghost" size="sm" className="h-6 text-[11px] px-2" onClick={() => onChange([])}></Button>
</div>
)}
</PopoverContent>
</Popover>
);
}
// ─── 모달 인터페이스 ────────────────────────────────────────────────
export interface WorkInstructionApplyItem {
itemCode: string;
itemName: string;
spec?: string;
qty: number;
remark?: string;
sourceTable?: string;
sourceId?: string;
// 품목별 일정/설비/작업조/작업자
startDate?: string;
endDate?: string;
equipmentIds?: string[];
workTeams?: string[];
workers?: string[];
}
export interface WorkInstructionApplyModalProps {
open: boolean;
onOpenChange: (v: boolean) => void;
initialItems: WorkInstructionApplyItem[];
batchNo?: string | null;
cuttingPlanId?: number | null;
onSaved?: (result: { id: string; workInstructionNo: string }) => void;
}
export default function WorkInstructionApplyModal({
open, onOpenChange, initialItems, batchNo, cuttingPlanId, onSaved,
}: WorkInstructionApplyModalProps) {
const [wiNo, setWiNo] = useState("");
const [status, setStatus] = useState("일반");
const [remark, setRemark] = useState("");
const [items, setItems] = useState<WorkInstructionApplyItem[]>([]);
const [saving, setSaving] = useState(false);
const [equipmentOptions, setEquipmentOptions] = useState<{ id: string; equipment_code: string; equipment_name: string }[]>([]);
const [workerOptions, setWorkerOptions] = useState<{ user_id: string; user_name: string; dept_name: string | null }[]>([]);
// 모달 오픈 시 초기화 + 옵션 로드
useEffect(() => {
if (!open) return;
const today = new Date().toISOString().slice(0, 10);
setItems(initialItems.map((x) => ({
...x,
startDate: x.startDate || today,
endDate: x.endDate || "",
equipmentIds: x.equipmentIds || [],
workTeams: x.workTeams || [],
workers: x.workers || [],
})));
setStatus("일반");
setRemark("");
previewWorkInstructionNo().then((r) => { if (r.success) setWiNo(r.instructionNo); }).catch(() => {});
getEquipmentList().then((r) => { if (r.success) setEquipmentOptions(r.data || []); }).catch(() => {});
getEmployeeList().then((r) => { if (r.success) setWorkerOptions(r.data || []); }).catch(() => {});
}, [open, initialItems]);
const canSave = useMemo(() => items.length > 0 && items.every((i) => i.qty > 0), [items]);
const handleSave = async () => {
if (!canSave) { toast.error("품목/수량을 확인해주세요"); return; }
setSaving(true);
try {
// 하위호환: 마스터에는 첫 품목의 대표값을 실음 (jskim 스타일)
const first = items[0];
const payload = {
status,
startDate: first?.startDate || "",
endDate: first?.endDate || "",
equipmentId: first?.equipmentIds?.[0] || "",
workTeam: first?.workTeams?.[0] || "",
worker: first?.workers?.[0] || "",
remark,
routing: null,
batchNo: batchNo || null,
cuttingPlanId: cuttingPlanId ?? null,
items: items.map((i) => ({
itemNumber: i.itemCode, itemCode: i.itemCode, partCode: i.itemCode,
qty: String(i.qty), remark: i.remark || "",
sourceTable: i.sourceTable || "cutting_plan",
sourceId: i.sourceId || (cuttingPlanId != null ? String(cuttingPlanId) : ""),
routing: null,
startDate: i.startDate || "",
endDate: i.endDate || "",
equipmentIds: (i.equipmentIds || []).join(","),
workTeams: (i.workTeams || []).join(","),
workers: (i.workers || []).join(","),
})),
};
const r = await saveWorkInstruction(payload);
if (!r.success) { toast.error(r.message || "저장 실패"); return; }
toast.success(`작업지시 ${r.data?.workInstructionNo || wiNo} 등록 완료`);
onOpenChange(false);
onSaved?.(r.data);
} catch (e: any) {
toast.error(e?.message || "저장 실패");
} finally {
setSaving(false);
}
};
const equipmentSelectOptions = useMemo(
() => equipmentOptions.map((eq) => ({ value: eq.id, label: eq.equipment_name || eq.equipment_code, sub: eq.equipment_code })),
[equipmentOptions]
);
const workerSelectOptions = useMemo(
() => workerOptions.map((w) => ({ value: w.user_id, label: w.user_name, sub: w.dept_name || undefined })),
[workerOptions]
);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[95vw] sm:max-w-[1500px] w-[95vw] max-h-[90vh] flex flex-col overflow-hidden">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription>
&apos; &apos; .
{batchNo ? <span className="ml-2 text-primary font-medium"> {batchNo}</span> : null}
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto">
<div className="space-y-5">
<div className="bg-muted/30 border rounded-lg p-5">
<h4 className="text-[13px] font-bold pb-2 border-b text-foreground"> </h4>
<p className="text-[11px] text-muted-foreground mt-2">···· .</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-3">
<div className="space-y-1">
<Label className="text-[11px] font-semibold text-muted-foreground"></Label>
<Input value={wiNo} readOnly className="h-9 bg-muted cursor-not-allowed font-mono" />
</div>
<div className="space-y-1">
<Label className="text-[11px] font-semibold text-muted-foreground"></Label>
<Select value={status} onValueChange={setStatus}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="일반"></SelectItem>
<SelectItem value="긴급"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-[11px] font-semibold text-muted-foreground"></Label>
<Input className="h-9" placeholder="비고를 입력해주세요" value={remark} onChange={(e) => setRemark(e.target.value)} />
</div>
</div>
</div>
<div className="border rounded-lg p-5">
<h4 className="text-[13px] font-bold pb-2 border-b text-foreground mb-3"> </h4>
<div className="overflow-auto">
<Table className="min-w-[1700px]">
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-[50px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[130px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[140px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[130px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[130px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[240px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[40px]" />
</TableRow>
</TableHeader>
<TableBody>
{items.map((item, idx) => (
<TableRow key={idx}>
<TableCell className="text-[13px] text-center">{idx + 1}</TableCell>
<TableCell className="text-[13px] font-mono text-primary">{batchNo || "-"}</TableCell>
<TableCell className="text-[13px] font-medium">{item.itemCode || "-"}</TableCell>
<TableCell className="text-sm truncate max-w-[140px]" title={item.itemName || item.itemCode}>
{item.itemName || "-"}
</TableCell>
<TableCell className="text-[13px]">{item.spec || "-"}</TableCell>
<TableCell>
<Input type="number" className="h-7 text-[13px] w-20"
value={item.qty}
onChange={(e) => setItems((prev) => prev.map((it, i) => i === idx ? { ...it, qty: Number(e.target.value) } : it))} />
</TableCell>
<TableCell>
<Input type="date" className="h-7 text-[13px]"
value={item.startDate || ""}
onChange={(e) => setItems((prev) => prev.map((it, i) => i === idx ? { ...it, startDate: e.target.value } : it))} />
</TableCell>
<TableCell>
<Input type="date" className="h-7 text-[13px]"
value={item.endDate || ""}
onChange={(e) => setItems((prev) => prev.map((it, i) => i === idx ? { ...it, endDate: e.target.value } : it))} />
</TableCell>
<TableCell>
<MultiSelectPopover
options={equipmentSelectOptions}
value={item.equipmentIds || []}
onChange={(next) => setItems((prev) => prev.map((it, i) => i === idx ? { ...it, equipmentIds: next } : it))}
placeholder="설비 선택"
searchable
emptyMessage="설비가 없어요"
/>
</TableCell>
<TableCell>
<MultiSelectPopover
options={[{ value: "주간", label: "주간" }, { value: "야간", label: "야간" }]}
value={item.workTeams || []}
onChange={(next) => setItems((prev) => prev.map((it, i) => i === idx ? { ...it, workTeams: next } : it))}
placeholder="작업조 선택"
/>
</TableCell>
<TableCell>
<MultiSelectPopover
options={workerSelectOptions}
value={item.workers || []}
onChange={(next) => setItems((prev) => prev.map((it, i) => i === idx ? { ...it, workers: next } : it))}
placeholder="작업자 선택"
searchable
emptyMessage="사원을 찾을 수 없어요"
/>
</TableCell>
<TableCell>
<Input className="h-7 text-[13px]" placeholder="비고"
value={item.remark || ""}
onChange={(e) => setItems((prev) => prev.map((it, i) => i === idx ? { ...it, remark: e.target.value } : it))} />
</TableCell>
<TableCell>
<Button variant="ghost" size="icon" className="h-6 w-6"
onClick={() => setItems((prev) => prev.filter((_, i) => i !== idx))}>
<X className="w-3 h-3 text-destructive" />
</Button>
</TableCell>
</TableRow>
))}
{items.length === 0 && (
<TableRow>
<TableCell colSpan={13} className="text-center text-muted-foreground text-[12px] py-6">
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}></Button>
<Button onClick={handleSave} disabled={!canSave || saving}>
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <CheckCircle2 className="w-4 h-4 mr-1.5" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -647,6 +647,7 @@ export default function WorkInstructionPage() {
{ key: "worker", label: "작업자", width: "w-[100px]", render: (v, row) => Number(row.detail_seq) === 1 ? getWorkerName(v) : "" },
{ key: "start_date", label: "시작일", width: "w-[100px]", align: "center", render: (v, row) => Number(row.detail_seq) === 1 ? (v || "-") : "" },
{ key: "end_date", label: "완료일", width: "w-[100px]", align: "center", render: (v, row) => Number(row.detail_seq) === 1 ? (v || "-") : "" },
{ key: "batch_no", label: "배치번호", width: "w-[130px]", align: "center", render: (v, row) => Number(row.detail_seq) === 1 ? (v ? <span className="font-mono text-primary">{v}</span> : <span className="text-muted-foreground">-</span>) : "" },
{ key: "actions", label: "작업", width: "w-[150px]", align: "center", sortable: false, filterable: false, render: (_v, row) => {
const isFirstOfGroup = Number(row.detail_seq) === 1;
if (!isFirstOfGroup) return null;
@@ -911,6 +912,9 @@ export default function WorkInstructionPage() {
<TableHeader className="sticky top-0 z-10">
<TableRow className="bg-muted hover:bg-muted">
<TableHead className="w-[50px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
{editOrder?.batch_no ? (
<TableHead className="w-[130px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
) : null}
<TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[140px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground"></TableHead>
@@ -928,10 +932,13 @@ export default function WorkInstructionPage() {
</TableHeader>
<TableBody>
{editItems.length === 0 ? (
<TableRow><TableCell colSpan={14} className="text-center py-8 text-sm text-muted-foreground"> </TableCell></TableRow>
<TableRow><TableCell colSpan={editOrder?.batch_no ? 15 : 14} className="text-center py-8 text-sm text-muted-foreground"> </TableCell></TableRow>
) : editItems.map((item, idx) => (
<TableRow key={idx}>
<TableCell className="text-[13px] text-center">{idx + 1}</TableCell>
{editOrder?.batch_no ? (
<TableCell className="text-[13px] font-mono text-primary">{editOrder.batch_no}</TableCell>
) : null}
<TableCell className="text-[13px] font-medium">{item.itemCode}</TableCell>
<TableCell className="text-sm truncate max-w-[140px]" title={item.itemName}>{item.itemName || "-"}</TableCell>
<TableCell className="text-[13px] truncate" title={item.spec}>{item.spec || "-"}</TableCell>

View File

@@ -30,6 +30,9 @@ import {
Inbox,
Settings2,
Upload,
ArrowUpDown,
ArrowUp,
ArrowDown,
} from "lucide-react";
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
import { ImageUpload } from "@/components/common/ImageUpload";
@@ -113,6 +116,8 @@ export default function InspectionManagementPage() {
const [eqForm, setEqForm] = useState<Record<string, any>>({});
const [eqSaving, setEqSaving] = useState(false);
const [eqKeyword, setEqKeyword] = useState("");
const [eqSortKey, setEqSortKey] = useState<string | null>(null);
const [eqSortDir, setEqSortDir] = useState<"asc" | "desc">("asc");
/* ───── 채번 ───── */
const [numberingRuleId, setNumberingRuleId] = useState<string | null>(null);
@@ -288,13 +293,54 @@ export default function InspectionManagementPage() {
)
: defects;
const filteredEquipments = eqKeyword.trim()
? equipments.filter(
(r) =>
(r.equipment_name || "").toLowerCase().includes(eqKeyword.toLowerCase()) ||
(r.model_name || "").toLowerCase().includes(eqKeyword.toLowerCase()),
)
: equipments;
const filteredEquipments = useMemo(() => {
const base = eqKeyword.trim()
? equipments.filter(
(r) =>
(r.equipment_name || "").toLowerCase().includes(eqKeyword.toLowerCase()) ||
(r.model_name || "").toLowerCase().includes(eqKeyword.toLowerCase()),
)
: equipments;
if (!eqSortKey) return base;
const key = eqSortKey;
const dir = eqSortDir;
return [...base].sort((a, b) => {
const av = a[key];
const bv = b[key];
const aEmpty = av === null || av === undefined || av === "";
const bEmpty = bv === null || bv === undefined || bv === "";
if (aEmpty && bEmpty) return 0;
if (aEmpty) return 1;
if (bEmpty) return -1;
const na = Number(av);
const nb = Number(bv);
if (!isNaN(na) && !isNaN(nb)) return dir === "asc" ? na - nb : nb - na;
const sa = String(av).toLowerCase();
const sb = String(bv).toLowerCase();
return dir === "asc" ? sa.localeCompare(sb) : sb.localeCompare(sa);
});
}, [equipments, eqKeyword, eqSortKey, eqSortDir]);
const handleEqSort = useCallback((key: string) => {
setEqSortKey((prev) => {
if (prev === key) {
setEqSortDir((d) => (d === "asc" ? "desc" : "asc"));
return prev;
}
setEqSortDir("asc");
return key;
});
}, []);
const renderEqSortIcon = (key: string) => {
if (eqSortKey !== key)
return <ArrowUpDown className="ml-1 inline h-3 w-3 opacity-40" />;
return eqSortDir === "asc" ? (
<ArrowUp className="ml-1 inline h-3 w-3" />
) : (
<ArrowDown className="ml-1 inline h-3 w-3" />
);
};
/* ═══════════════════ 검사기준 CRUD ═══════════════════ */
const openInspCreate = async () => {
@@ -1586,35 +1632,35 @@ export default function InspectionManagementPage() {
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase w-[60px] text-center">
</TableHead>
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase cursor-pointer select-none hover:text-foreground" onClick={() => handleEqSort("equipment_code")}>
{renderEqSortIcon("equipment_code")}
</TableHead>
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase cursor-pointer select-none hover:text-foreground" onClick={() => handleEqSort("equipment_name")}>
{renderEqSortIcon("equipment_name")}
</TableHead>
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase cursor-pointer select-none hover:text-foreground" onClick={() => handleEqSort("equipment_type")}>
{renderEqSortIcon("equipment_type")}
</TableHead>
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase cursor-pointer select-none hover:text-foreground" onClick={() => handleEqSort("model_name")}>
{renderEqSortIcon("model_name")}
</TableHead>
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase cursor-pointer select-none hover:text-foreground" onClick={() => handleEqSort("manufacturer")}>
{renderEqSortIcon("manufacturer")}
</TableHead>
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase cursor-pointer select-none hover:text-foreground" onClick={() => handleEqSort("installation_location")}>
{renderEqSortIcon("installation_location")}
</TableHead>
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase cursor-pointer select-none hover:text-foreground" onClick={() => handleEqSort("last_calibration_date")}>
{renderEqSortIcon("last_calibration_date")}
</TableHead>
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
()
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase cursor-pointer select-none hover:text-foreground" onClick={() => handleEqSort("calibration_period")}>
(){renderEqSortIcon("calibration_period")}
</TableHead>
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase cursor-pointer select-none hover:text-foreground" onClick={() => handleEqSort("equipment_status")}>
{renderEqSortIcon("equipment_status")}
</TableHead>
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase">
<TableHead className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase cursor-pointer select-none hover:text-foreground" onClick={() => handleEqSort("manager_id")}>
{renderEqSortIcon("manager_id")}
</TableHead>
</TableRow>
</TableHeader>

View File

@@ -340,6 +340,7 @@ const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = {
"/COMPANY_9/production/work-instruction": dynamic(() => import("@/app/(main)/COMPANY_9/production/work-instruction/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_9/production/plan-management": dynamic(() => import("@/app/(main)/COMPANY_9/production/plan-management/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_9/production/bom": dynamic(() => import("@/app/(main)/COMPANY_9/production/bom/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_9/production/cutting-plan": dynamic(() => import("@/app/(main)/COMPANY_9/production/cutting-plan/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_9/equipment/info": dynamic(() => import("@/app/(main)/COMPANY_9/equipment/info/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_9/equipment/inspection-record": dynamic(() => import("@/app/(main)/COMPANY_9/equipment/inspection-record/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_9/equipment/plc-settings": dynamic(() => import("@/app/(main)/COMPANY_9/equipment/plc-settings/page"), { ssr: false, loading: LoadingFallback }),
@@ -572,6 +573,7 @@ const DYNAMIC_ADMIN_IMPORTS: Record<string, () => Promise<any>> = {
"/COMPANY_9/production/work-instruction": () => import("@/app/(main)/COMPANY_9/production/work-instruction/page"),
"/COMPANY_9/production/plan-management": () => import("@/app/(main)/COMPANY_9/production/plan-management/page"),
"/COMPANY_9/production/bom": () => import("@/app/(main)/COMPANY_9/production/bom/page"),
"/COMPANY_9/production/cutting-plan": () => import("@/app/(main)/COMPANY_9/production/cutting-plan/page"),
"/COMPANY_9/equipment/info": () => import("@/app/(main)/COMPANY_9/equipment/info/page"),
"/COMPANY_9/equipment/plc-settings": () => import("@/app/(main)/COMPANY_9/equipment/plc-settings/page"),
"/COMPANY_9/monitoring/production": () => import("@/app/(main)/COMPANY_9/monitoring/production/page"),