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

This commit is contained in:
kjs
2026-04-10 11:12:51 +09:00
19 changed files with 2071 additions and 268 deletions
@@ -359,11 +359,21 @@ export const syncWorkInstructions = async (
});
// 미동기화 작업지시 조회: routing이 있지만 work_order_process가 없는 항목
// header에 routing 없으면 detail에서 가져옴 (PC가 detail에만 저장하는 경우 대응)
const unsyncedResult = await pool.query(
`SELECT wi.id, wi.work_instruction_no, wi.routing, wi.qty
`SELECT wi.id, wi.work_instruction_no,
COALESCE(wi.routing, wid.routing_version_id) AS routing,
COALESCE(NULLIF(wi.qty, ''), wid.qty) AS qty,
COALESCE(wi.item_id, (SELECT id FROM item_info WHERE item_number = wid.item_number AND company_code = $1 LIMIT 1)) AS item_id
FROM work_instruction wi
LEFT JOIN LATERAL (
SELECT routing_version_id, qty, item_number
FROM work_instruction_detail
WHERE work_instruction_no = wi.work_instruction_no AND company_code = $1
LIMIT 1
) wid ON true
WHERE wi.company_code = $1
AND wi.routing IS NOT NULL
AND COALESCE(wi.routing, wid.routing_version_id) IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM work_order_process wop
WHERE wop.wo_id = wi.id AND wop.company_code = $1
@@ -373,6 +383,20 @@ export const syncWorkInstructions = async (
const unsynced = unsyncedResult.rows;
// header에 routing/qty/item_id가 비어있으면 자동 보정 (detail → header 동기화)
for (const wi of unsynced) {
await pool.query(
`UPDATE work_instruction SET
routing = COALESCE(routing, $2),
qty = COALESCE(NULLIF(qty, ''), $3),
item_id = COALESCE(item_id, $4),
updated_date = NOW()
WHERE id = $1 AND company_code = $5
AND (routing IS NULL OR qty IS NULL OR qty = '' OR item_id IS NULL)`,
[wi.id, wi.routing, wi.qty, wi.item_id, companyCode],
);
}
if (unsynced.length === 0) {
return res.json({
success: true,
@@ -1050,7 +1050,7 @@ export async function getWarehouses(req: AuthenticatedRequest, res: Response) {
const result = await pool.query(
`SELECT warehouse_code, warehouse_name, warehouse_type
FROM warehouse_info
WHERE company_code = $1 AND status != '삭제'
WHERE company_code = $1 AND COALESCE(status, '') != '삭제'
ORDER BY warehouse_name`,
[companyCode],
);
@@ -9,7 +9,7 @@
* 공급업체관리와 양방향 연동 (같은 supplier_item_mapping 테이블)
*/
import React, { useState, useEffect, useCallback } from "react";
import React, { useState, useEffect, useCallback, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -137,7 +137,7 @@ function MultiCategoryCombobox({ options, value, onChange, placeholder }: {
}
const FORM_FIELDS = [
{ key: "item_number", label: "품목코드", type: "text", required: true, disabled: true, placeholder: "자동 채번" },
{ key: "item_number", label: "품목코드", type: "numbering", required: true, placeholder: "자동 채번" },
{ key: "item_name", label: "품명", type: "text", required: true },
{ key: "division", label: "관리품목", type: "multi-category" },
{ key: "type", label: "품목구분", type: "category" },
@@ -217,6 +217,13 @@ export default function PurchaseItemPage() {
const [editId, setEditId] = useState<string | null>(null);
const [formData, setFormData] = useState<Record<string, any>>({});
// 채번 관련 상태
const [numberingRule, setNumberingRule] = useState<any>(null);
const [numberingParts, setNumberingParts] = useState<{ value: string; isManual: boolean; separator: string }[]>([]);
const [manualInputValue, setManualInputValue] = useState<string>("");
const [isNumberingLoading, setIsNumberingLoading] = useState(false);
const numberingRuleIdRef = useRef<string | null>(null);
// 검색 필터 (DynamicSearchFilter에서 관리)
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
@@ -340,38 +347,194 @@ export default function PurchaseItemPage() {
useEffect(() => { fetchItems(); }, [fetchItems]);
// 채번 미리보기
const loadNumberingPreview = async () => {
try {
const ruleRes = await apiClient.get(`/numbering-rules/by-column/${ITEM_TABLE}/item_number`);
const rule = ruleRes.data?.data;
if (rule?.ruleId) {
const previewRes = await apiClient.post(`/numbering-rules/${rule.ruleId}/preview`, { formData: {} });
return previewRes.data?.data?.generatedCode || "";
// 프리뷰 코드에서 각 파트별 표시값을 추출
const parsePreviewIntoParts = (previewCode: string, rule: any) => {
if (!previewCode || !rule?.parts) return [];
const sorted = [...rule.parts].sort((a: any, b: any) => a.order - b.order);
const globalSep = rule.separator || "";
const partMeta = sorted.map((part: any, idx: number) => {
const sep = idx < sorted.length - 1
? (part.separatorAfter ?? part.autoConfig?.separatorAfter ?? globalSep)
: "";
const config = part.autoConfig || {};
if (part.generationMethod === "manual") return { known: false, marker: "____", sep, isManual: true, partType: part.partType };
switch (part.partType) {
case "text": return { known: true, value: config.textValue || "", sep, isManual: false, partType: "text" };
case "number": return { known: true, value: String(config.numberValue || 1).padStart(config.numberLength || 3, "0"), sep, isManual: false, partType: "number" };
case "date": {
const now = new Date();
const y = String(now.getFullYear()), m = String(now.getMonth() + 1).padStart(2, "0"), d = String(now.getDate()).padStart(2, "0");
const fmt = config.dateFormat || "YYYYMMDD";
const map: Record<string, string> = { YYYY: y, YY: y.slice(2), YYYYMM: y + m, YYMM: y.slice(2) + m, YYYYMMDD: y + m + d, YYMMDD: y.slice(2) + m + d };
return { known: true, value: map[fmt] || y + m + d, sep, isManual: false, partType: "date" };
}
default: return { known: false, sep, isManual: false, partType: part.partType };
}
});
let remaining = previewCode;
const results: { value: string; isManual: boolean; separator: string }[] = [];
for (let i = 0; i < partMeta.length; i++) {
const meta = partMeta[i];
const nextMeta = i < partMeta.length - 1 ? partMeta[i + 1] : null;
if (meta.isManual) {
const markerIdx = remaining.indexOf("____");
if (markerIdx >= 0) {
remaining = remaining.substring(markerIdx + 4);
if (meta.sep && remaining.startsWith(meta.sep)) remaining = remaining.substring(meta.sep.length);
}
results.push({ value: "", isManual: true, separator: meta.sep });
continue;
}
if (meta.known) {
const valIdx = remaining.indexOf(meta.value);
if (valIdx >= 0) {
remaining = remaining.substring(valIdx + meta.value.length);
if (meta.sep && remaining.startsWith(meta.sep)) remaining = remaining.substring(meta.sep.length);
}
results.push({ value: meta.value, isManual: false, separator: meta.sep });
} else {
let endIdx = remaining.length;
if (meta.sep) {
if (nextMeta) {
if (nextMeta.known && nextMeta.value) {
const patIdx = remaining.indexOf(meta.sep + nextMeta.value);
if (patIdx >= 0) endIdx = patIdx;
} else if (nextMeta.isManual) {
const patIdx = remaining.indexOf(meta.sep + "____");
if (patIdx >= 0) endIdx = patIdx;
} else {
const sepIdx = remaining.indexOf(meta.sep);
if (sepIdx >= 0) endIdx = sepIdx;
}
}
} else if (nextMeta) {
if (nextMeta.known && nextMeta.value) {
const valIdx = remaining.indexOf(nextMeta.value);
if (valIdx >= 0) endIdx = valIdx;
} else if (nextMeta.isManual) {
const markerIdx = remaining.indexOf("____");
if (markerIdx >= 0) endIdx = markerIdx;
}
}
const extracted = remaining.substring(0, endIdx);
remaining = remaining.substring(endIdx);
if (meta.sep && remaining.startsWith(meta.sep)) remaining = remaining.substring(meta.sep.length);
results.push({ value: extracted, isManual: false, separator: meta.sep });
}
}
return results;
};
// 파트 값으로부터 전체 코드 조합
const buildCodeFromParts = (parts: { value: string; isManual: boolean; separator: string }[], manualVal: string) => {
return parts.map((p, idx) => {
const val = p.isManual ? manualVal : p.value;
const sep = idx < parts.length - 1 ? p.separator : "";
return val + sep;
}).join("");
};
// 채번 미리보기
const loadNumberingPreview = async (currentFormData?: Record<string, any>, currentManualValue?: string) => {
try {
setIsNumberingLoading(true);
let rule = numberingRule;
if (!rule) {
const ruleRes = await apiClient.get(`/numbering-rules/by-column/${ITEM_TABLE}/item_number`);
rule = ruleRes.data?.data;
if (rule) {
setNumberingRule(rule);
numberingRuleIdRef.current = rule.ruleId;
}
}
if (!rule?.ruleId) return { code: "", parts: [] as { value: string; isManual: boolean; separator: string }[] };
const previewRes = await apiClient.post(`/numbering-rules/${rule.ruleId}/preview`, {
formData: currentFormData || {},
manualInputValue: currentManualValue || undefined,
});
const generatedCode = previewRes.data?.data?.generatedCode || "";
const parts = parsePreviewIntoParts(generatedCode, rule);
setNumberingParts(parts);
return { code: generatedCode, parts };
} catch { /* 채번 규칙 없으면 무시 */ }
return "";
finally {
setIsNumberingLoading(false);
}
return { code: "", parts: [] as { value: string; isManual: boolean; separator: string }[] };
};
// 등록 모달 열기
const openRegisterModal = async () => {
setFormData({});
setManualInputValue("");
setNumberingParts([]);
setIsEditMode(false);
setEditId(null);
setIsModalOpen(true);
const code = await loadNumberingPreview();
if (code) setFormData((prev) => ({ ...prev, item_number: code }));
const result = await loadNumberingPreview({});
if (result.code) {
const hasManual = result.parts.some(p => p.isManual);
const displayCode = hasManual ? buildCodeFromParts(result.parts, "") : result.code;
setFormData(prev => ({ ...prev, item_number: displayCode }));
}
};
// 수정 모달 열기
const openEditModal = (item: any) => {
const raw = rawItems.find((r) => r.id === item.id) || item;
setFormData({ ...raw });
setManualInputValue("");
setNumberingParts([]);
setIsEditMode(true);
setEditId(item.id);
setIsModalOpen(true);
};
// 카테고리 변경 시 채번 preview 재호출
useEffect(() => {
if (isEditMode || !isModalOpen || !numberingRuleIdRef.current) return;
const hasCategoryPart = numberingRule?.parts?.some(
(p: any) => p.partType === "category" && p.generationMethod === "auto"
);
if (!hasCategoryPart) return;
const timer = setTimeout(async () => {
const result = await loadNumberingPreview(formData, manualInputValue);
if (result.parts.length > 0) {
setFormData(prev => ({ ...prev, item_number: buildCodeFromParts(result.parts, manualInputValue) }));
}
}, 300);
return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [...CATEGORY_COLUMNS.map(col => formData[col])]);
// 수동 입력값 변경 시 preview 갱신
useEffect(() => {
if (isEditMode || !isModalOpen || !numberingRuleIdRef.current) return;
if (!numberingParts.some(p => p.isManual)) return;
const timer = setTimeout(async () => {
const result = await loadNumberingPreview(formData, manualInputValue);
if (result.parts.length > 0) {
setFormData(prev => ({ ...prev, item_number: buildCodeFromParts(result.parts, manualInputValue) }));
}
}, 500);
return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [manualInputValue]);
// 저장 (등록 또는 수정)
const handleSave = async () => {
if (!formData.item_name) {
@@ -388,8 +551,38 @@ export default function PurchaseItemPage() {
});
toast.success("수정되었어요.");
} else {
// 신규 등록: allocateCode 호출하여 실제 순번 확보
let finalItemNumber = formData.item_number || "";
if (numberingRuleIdRef.current) {
try {
const hasManual = numberingParts.some(p => p.isManual);
const userInputCode = hasManual && manualInputValue
? manualInputValue
: undefined;
const allocRes = await apiClient.post(
`/numbering-rules/${numberingRuleIdRef.current}/allocate`,
{ formData, userInputCode }
);
if (allocRes.data?.success && allocRes.data?.data?.generatedCode) {
finalItemNumber = allocRes.data.data.generatedCode;
}
} catch (err) {
console.error("채번 할당 실패:", err);
toast.error("품목코드 할당에 실패했어요. 다시 시도해주세요.");
setSaving(false);
return;
}
}
const { id, created_date, updated_date, ...insertFields } = formData;
await apiClient.post(`/table-management/tables/${ITEM_TABLE}/add`, { id: crypto.randomUUID(), ...insertFields });
await apiClient.post(`/table-management/tables/${ITEM_TABLE}/add`, {
id: crypto.randomUUID(),
...insertFields,
item_number: finalItemNumber,
});
toast.success("등록되었어요.");
}
setIsModalOpen(false);
@@ -664,25 +857,51 @@ export default function PurchaseItemPage() {
if (found) custInfo = found;
} catch { /* skip */ }
const mappingRows = [{
_id: `m_existing_${row.id}`,
supplier_item_code: row.supplier_item_code || "",
supplier_item_name: row.supplier_item_name || "",
}].filter((m) => m.supplier_item_code || m.supplier_item_name);
// 매핑 전체 조회
let mappingRows: any[] = [];
try {
const mapRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
page: 1, size: 100,
dataFilter: { enabled: true, filters: [
{ columnName: "supplier_id", operator: "equals", value: custKey },
{ columnName: "item_id", operator: "equals", value: selectedItem!.item_number },
]}, autoFilter: true,
});
const allMappings = mapRes.data?.data?.data || mapRes.data?.data?.rows || [];
mappingRows = allMappings
.filter((m: any) => m.supplier_item_code || m.supplier_item_name)
.map((m: any) => ({
_id: `m_existing_${m.id}`,
supplier_item_code: m.supplier_item_code || "",
supplier_item_name: m.supplier_item_name || "",
}));
} catch { /* skip */ }
const priceRows = [{
_id: `p_existing_${row.id}`,
start_date: row.start_date || "",
end_date: row.end_date || "",
currency_code: row.currency_code || "CAT_MLAMDKVN_PZJI",
base_price_type: row.base_price_type || "CAT_MLAMFGFT_4RZW",
base_price: row.base_price ? String(row.base_price) : "",
discount_type: row.discount_type || "",
discount_value: row.discount_value ? String(row.discount_value) : "",
rounding_type: row.rounding_type || "",
rounding_unit_value: row.rounding_unit_value || "",
calculated_price: row.calculated_price ? String(row.calculated_price) : "",
}].filter((p) => p.base_price || p.start_date);
// 단가 전체 조회
let priceRows: any[] = [];
try {
const priceRes = await apiClient.post(`/table-management/tables/supplier_item_prices/data`, {
page: 1, size: 100,
dataFilter: { enabled: true, filters: [
{ columnName: "supplier_id", operator: "equals", value: custKey },
{ columnName: "item_id", operator: "equals", value: selectedItem!.item_number },
]}, autoFilter: true,
});
const allPriceData = priceRes.data?.data?.data || priceRes.data?.data?.rows || [];
priceRows = allPriceData.map((p: any) => ({
_id: `p_existing_${p.id}`,
start_date: p.start_date ? String(p.start_date).split("T")[0] : "",
end_date: p.end_date ? String(p.end_date).split("T")[0] : "",
currency_code: p.currency_code || "CAT_MLAMDKVN_PZJI",
base_price_type: p.base_price_type || "CAT_MLAMFGFT_4RZW",
base_price: p.base_price ? String(p.base_price) : "",
discount_type: p.discount_type || "",
discount_value: p.discount_value ? String(p.discount_value) : "",
rounding_type: p.rounding_type || "",
rounding_unit_value: p.rounding_unit_value || "",
calculated_price: p.calculated_price ? String(p.calculated_price) : "",
}));
} catch { /* skip */ }
if (priceRows.length === 0) {
priceRows.push({
@@ -709,47 +928,104 @@ export default function PurchaseItemPage() {
const mappingRows = suppMappings[custKey] || [];
if (isEditingExisting && editSuppData?.id) {
await apiClient.put(`/table-management/tables/${MAPPING_TABLE}/edit`, {
originalData: { id: editSuppData.id },
updatedData: {
supplier_item_code: mappingRows[0]?.supplier_item_code || "",
supplier_item_name: mappingRows[0]?.supplier_item_name || "",
},
});
// 기존 prices 삭제 후 재등록
// 매핑: 기존→UPDATE, 신규→INSERT, 삭제된→DELETE
const keptIds = new Set<string>();
let firstMappingId: string | null = null;
for (let mi = 0; mi < mappingRows.length; mi++) {
const m = mappingRows[mi];
if (m._id?.startsWith("m_existing_")) {
const realId = m._id.replace("m_existing_", "");
keptIds.add(realId);
if (mi === 0) firstMappingId = realId;
await apiClient.put(`/table-management/tables/${MAPPING_TABLE}/edit`, {
originalData: { id: realId },
updatedData: {
supplier_item_code: m.supplier_item_code || "",
supplier_item_name: m.supplier_item_name || "",
},
});
} else {
const res = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/add`, {
id: crypto.randomUUID(),
supplier_id: custKey, item_id: selectedItem.item_number,
supplier_item_code: m.supplier_item_code || "",
supplier_item_name: m.supplier_item_name || "",
});
if (mi === 0) firstMappingId = res.data?.data?.id || null;
}
}
// 폼에서 삭제된 매핑 → DB 삭제
try {
const existingPrices = await apiClient.post(`/table-management/tables/supplier_item_prices/data`, {
const dbMappings = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
page: 1, size: 100,
dataFilter: { enabled: true, filters: [
{ columnName: "mapping_id", operator: "equals", value: editSuppData.id },
{ columnName: "supplier_id", operator: "equals", value: custKey },
{ columnName: "item_id", operator: "equals", value: selectedItem.item_number },
]}, autoFilter: true,
});
const existing = existingPrices.data?.data?.data || existingPrices.data?.data?.rows || [];
if (existing.length > 0) {
await apiClient.delete(`/table-management/tables/supplier_item_prices/delete`, {
data: existing.map((p: any) => ({ id: p.id })),
const toDelete = (dbMappings.data?.data?.data || dbMappings.data?.data?.rows || [])
.filter((m: any) => !keptIds.has(m.id));
if (toDelete.length > 0) {
await apiClient.delete(`/table-management/tables/${MAPPING_TABLE}/delete`, {
data: toDelete.map((m: any) => ({ id: m.id })),
});
}
} catch { /* skip */ }
if (!firstMappingId) firstMappingId = editSuppData.id;
// 단가: 기존→UPDATE, 신규→INSERT, 삭제된→DELETE
const keptPriceIds = new Set<string>();
const priceRows = (suppPrices[custKey] || []).filter((p) =>
p.base_price || p.start_date || p.currency_code || p.base_price_type
);
for (const price of priceRows) {
await apiClient.post(`/table-management/tables/supplier_item_prices/add`, {
id: crypto.randomUUID(),
mapping_id: editSuppData.id,
supplier_id: custKey,
item_id: selectedItem.item_number,
start_date: price.start_date || null, end_date: price.end_date || null,
currency_code: price.currency_code || null, base_price_type: price.base_price_type || null,
base_price: price.base_price ? Number(price.base_price) : null,
discount_type: price.discount_type || null, discount_value: price.discount_value ? Number(price.discount_value) : null,
rounding_type: price.rounding_type || null, rounding_unit_value: price.rounding_unit_value || null,
calculated_price: price.calculated_price ? Number(price.calculated_price) : null,
});
if (price._id?.startsWith("p_existing_")) {
const realId = price._id.replace("p_existing_", "");
keptPriceIds.add(realId);
await apiClient.put(`/table-management/tables/supplier_item_prices/edit`, {
originalData: { id: realId },
updatedData: {
mapping_id: firstMappingId || "",
start_date: price.start_date || null, end_date: price.end_date || null,
currency_code: price.currency_code || null, base_price_type: price.base_price_type || null,
base_price: price.base_price ? Number(price.base_price) : null,
discount_type: price.discount_type || null, discount_value: price.discount_value ? Number(price.discount_value) : null,
rounding_type: price.rounding_type || null, rounding_unit_value: price.rounding_unit_value || null,
calculated_price: price.calculated_price ? Number(price.calculated_price) : null,
},
});
} else {
await apiClient.post(`/table-management/tables/supplier_item_prices/add`, {
id: crypto.randomUUID(),
mapping_id: firstMappingId || "",
supplier_id: custKey, item_id: selectedItem.item_number,
start_date: price.start_date || null, end_date: price.end_date || null,
currency_code: price.currency_code || null, base_price_type: price.base_price_type || null,
base_price: price.base_price ? Number(price.base_price) : null,
discount_type: price.discount_type || null, discount_value: price.discount_value ? Number(price.discount_value) : null,
rounding_type: price.rounding_type || null, rounding_unit_value: price.rounding_unit_value || null,
calculated_price: price.calculated_price ? Number(price.calculated_price) : null,
});
}
}
// 폼에서 삭제된 단가 → DB 삭제
try {
const dbPrices = await apiClient.post(`/table-management/tables/supplier_item_prices/data`, {
page: 1, size: 100,
dataFilter: { enabled: true, filters: [
{ columnName: "supplier_id", operator: "equals", value: custKey },
{ columnName: "item_id", operator: "equals", value: selectedItem.item_number },
]}, autoFilter: true,
});
const toDeletePrices = (dbPrices.data?.data?.data || dbPrices.data?.data?.rows || [])
.filter((p: any) => !keptPriceIds.has(p.id));
if (toDeletePrices.length > 0) {
await apiClient.delete(`/table-management/tables/supplier_item_prices/delete`, {
data: toDeletePrices.map((p: any) => ({ id: p.id })),
});
}
} catch { /* skip */ }
} else {
// 신규 등록
const mappingRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/add`, {
@@ -1216,7 +1492,71 @@ export default function PurchaseItemPage() {
{field.label}
{"required" in field && field.required && <span className="text-destructive ml-1">*</span>}
</Label>
{field.type === "image" ? (
{field.type === "numbering" ? (
isEditMode ? (
<Input
value={formData[field.key] || ""}
disabled
className="h-9 bg-muted"
/>
) : isNumberingLoading && numberingParts.length === 0 ? (
<div className="flex h-9 items-center gap-2 rounded-md border px-3">
<Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />
<span className="text-sm text-muted-foreground"> ...</span>
</div>
) : numberingParts.some(p => p.isManual) ? (
<div className="flex h-9 items-center rounded-md border border-input">
{numberingParts.map((part, idx) => {
const isFirst = idx === 0;
const isLast = idx === numberingParts.length - 1;
if (part.isManual) {
return (
<React.Fragment key={idx}>
<input
type="text"
value={manualInputValue}
onChange={(e) => {
const val = e.target.value;
setManualInputValue(val);
setFormData(prev => ({
...prev,
item_number: buildCodeFromParts(numberingParts, val),
}));
}}
placeholder="입력"
className="h-full min-w-[60px] flex-1 border-0 bg-transparent px-2 text-sm outline-none"
/>
{part.separator && !isLast && (
<span className="text-muted-foreground text-sm">{part.separator}</span>
)}
</React.Fragment>
);
}
return (
<React.Fragment key={idx}>
<span className={cn(
"flex h-full items-center bg-muted px-2 text-sm text-muted-foreground whitespace-nowrap",
isFirst && "rounded-l-[5px]",
isLast && "rounded-r-[5px]",
)}>
{part.value}
</span>
{part.separator && !isLast && (
<span className="text-muted-foreground text-sm">{part.separator}</span>
)}
</React.Fragment>
);
})}
</div>
) : (
<Input
value={formData[field.key] || ""}
disabled
placeholder="자동 채번"
className="h-9 bg-muted"
/>
)
) : field.type === "image" ? (
<ImageUpload
value={formData[field.key] || ""}
onChange={(v) => setFormData((prev) => ({ ...prev, [field.key]: v }))}
@@ -1260,8 +1600,7 @@ export default function PurchaseItemPage() {
<Input
value={formData[field.key] || ""}
onChange={(e) => setFormData((prev) => ({ ...prev, [field.key]: e.target.value }))}
placeholder={"placeholder" in field ? field.placeholder : ("disabled" in field && field.disabled ? "자동 채번" : field.label)}
disabled={"disabled" in field && field.disabled && !isEditMode}
placeholder={"placeholder" in field ? field.placeholder : field.label}
className="h-9"
/>
)}
@@ -9,7 +9,7 @@
* 거래처관리와 양방향 연동 (같은 customer_item_mapping 테이블)
*/
import React, { useState, useEffect, useCallback } from "react";
import React, { useState, useEffect, useCallback, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -157,7 +157,7 @@ const ITEM_GRID_COLUMNS = [
];
const FORM_FIELDS = [
{ key: "item_number", label: "품목코드", type: "text", required: true, disabled: true, placeholder: "자동 채번" },
{ key: "item_number", label: "품목코드", type: "numbering", required: true, placeholder: "자동 채번" },
{ key: "item_name", label: "품명", type: "text", required: true },
{ key: "division", label: "관리품목", type: "multi-category" },
{ key: "type", label: "품목구분", type: "category" },
@@ -241,6 +241,13 @@ export default function SalesItemPage() {
const [editId, setEditId] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
// 채번 관련 상태
const [numberingRule, setNumberingRule] = useState<any>(null);
const [numberingParts, setNumberingParts] = useState<{ value: string; isManual: boolean; separator: string }[]>([]);
const [manualInputValue, setManualInputValue] = useState<string>("");
const [isNumberingLoading, setIsNumberingLoading] = useState(false);
const numberingRuleIdRef = useRef<string | null>(null);
// 엑셀
const [excelUploadOpen, setExcelUploadOpen] = useState(false);
const [excelChainConfig, setExcelChainConfig] = useState<TableChainConfig | null>(null);
@@ -673,47 +680,104 @@ export default function SalesItemPage() {
const mappingRows = custMappings[custKey] || [];
if (isEditingExisting && editCustData?.id) {
await apiClient.put(`/table-management/tables/${MAPPING_TABLE}/edit`, {
originalData: { id: editCustData.id },
updatedData: {
customer_item_code: mappingRows[0]?.customer_item_code || "",
customer_item_name: mappingRows[0]?.customer_item_name || "",
},
});
// 기존 prices 삭제 후 재등록
// 매핑: 기존→UPDATE, 신규→INSERT, 삭제된→DELETE
const keptIds = new Set<string>();
let firstMappingId: string | null = null;
for (let mi = 0; mi < mappingRows.length; mi++) {
const m = mappingRows[mi];
if (m._id?.startsWith("m_existing_")) {
const realId = m._id.replace("m_existing_", "");
keptIds.add(realId);
if (mi === 0) firstMappingId = realId;
await apiClient.put(`/table-management/tables/${MAPPING_TABLE}/edit`, {
originalData: { id: realId },
updatedData: {
customer_item_code: m.customer_item_code || "",
customer_item_name: m.customer_item_name || "",
},
});
} else {
const res = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/add`, {
id: crypto.randomUUID(),
customer_id: custKey, item_id: selectedItem.item_number,
customer_item_code: m.customer_item_code || "",
customer_item_name: m.customer_item_name || "",
});
if (mi === 0) firstMappingId = res.data?.data?.id || null;
}
}
// 폼에서 삭제된 매핑 → DB 삭제
try {
const existingPrices = await apiClient.post(`/table-management/tables/customer_item_prices/data`, {
const dbMappings = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/data`, {
page: 1, size: 100,
dataFilter: { enabled: true, filters: [
{ columnName: "mapping_id", operator: "equals", value: editCustData.id },
{ columnName: "customer_id", operator: "equals", value: custKey },
{ columnName: "item_id", operator: "equals", value: selectedItem.item_number },
]}, autoFilter: true,
});
const existing = existingPrices.data?.data?.data || existingPrices.data?.data?.rows || [];
if (existing.length > 0) {
await apiClient.delete(`/table-management/tables/customer_item_prices/delete`, {
data: existing.map((p: any) => ({ id: p.id })),
const toDelete = (dbMappings.data?.data?.data || dbMappings.data?.data?.rows || [])
.filter((m: any) => !keptIds.has(m.id));
if (toDelete.length > 0) {
await apiClient.delete(`/table-management/tables/${MAPPING_TABLE}/delete`, {
data: toDelete.map((m: any) => ({ id: m.id })),
});
}
} catch { /* skip */ }
if (!firstMappingId) firstMappingId = editCustData.id;
// 단가: 기존→UPDATE, 신규→INSERT, 삭제된→DELETE
const keptPriceIds = new Set<string>();
const priceRows = (custPrices[custKey] || []).filter((p) =>
p.base_price || p.start_date || p.currency_code || p.base_price_type
);
for (const price of priceRows) {
await apiClient.post(`/table-management/tables/customer_item_prices/add`, {
id: crypto.randomUUID(),
mapping_id: editCustData.id,
customer_id: custKey,
item_id: selectedItem.item_number,
start_date: price.start_date || null, end_date: price.end_date || null,
currency_code: price.currency_code || null, base_price_type: price.base_price_type || null,
base_price: price.base_price ? Number(price.base_price) : null,
discount_type: price.discount_type || null, discount_value: price.discount_value ? Number(price.discount_value) : null,
rounding_type: price.rounding_type || null, rounding_unit_value: price.rounding_unit_value || null,
calculated_price: price.calculated_price ? Number(price.calculated_price) : null,
});
if (price._id?.startsWith("p_existing_")) {
const realId = price._id.replace("p_existing_", "");
keptPriceIds.add(realId);
await apiClient.put(`/table-management/tables/customer_item_prices/edit`, {
originalData: { id: realId },
updatedData: {
mapping_id: firstMappingId || "",
start_date: price.start_date || null, end_date: price.end_date || null,
currency_code: price.currency_code || null, base_price_type: price.base_price_type || null,
base_price: price.base_price ? Number(price.base_price) : null,
discount_type: price.discount_type || null, discount_value: price.discount_value ? Number(price.discount_value) : null,
rounding_type: price.rounding_type || null, rounding_unit_value: price.rounding_unit_value || null,
calculated_price: price.calculated_price ? Number(price.calculated_price) : null,
},
});
} else {
await apiClient.post(`/table-management/tables/customer_item_prices/add`, {
id: crypto.randomUUID(),
mapping_id: firstMappingId || "",
customer_id: custKey, item_id: selectedItem.item_number,
start_date: price.start_date || null, end_date: price.end_date || null,
currency_code: price.currency_code || null, base_price_type: price.base_price_type || null,
base_price: price.base_price ? Number(price.base_price) : null,
discount_type: price.discount_type || null, discount_value: price.discount_value ? Number(price.discount_value) : null,
rounding_type: price.rounding_type || null, rounding_unit_value: price.rounding_unit_value || null,
calculated_price: price.calculated_price ? Number(price.calculated_price) : null,
});
}
}
// 폼에서 삭제된 단가 → DB 삭제
try {
const dbPrices = await apiClient.post(`/table-management/tables/customer_item_prices/data`, {
page: 1, size: 100,
dataFilter: { enabled: true, filters: [
{ columnName: "customer_id", operator: "equals", value: custKey },
{ columnName: "item_id", operator: "equals", value: selectedItem.item_number },
]}, autoFilter: true,
});
const toDeletePrices = (dbPrices.data?.data?.data || dbPrices.data?.data?.rows || [])
.filter((p: any) => !keptPriceIds.has(p.id));
if (toDeletePrices.length > 0) {
await apiClient.delete(`/table-management/tables/customer_item_prices/delete`, {
data: toDeletePrices.map((p: any) => ({ id: p.id })),
});
}
} catch { /* skip */ }
} else {
// 신규 등록
const mappingRes = await apiClient.post(`/table-management/tables/${MAPPING_TABLE}/add`, {
@@ -765,27 +829,145 @@ export default function SalesItemPage() {
}
};
// 채번 미리보기 로드
const loadNumberingPreview = async () => {
try {
const ruleRes = await apiClient.get(`/numbering-rules/by-column/${ITEM_TABLE}/item_number`);
const rule = ruleRes.data?.data;
if (rule?.ruleId) {
const previewRes = await apiClient.post(`/numbering-rules/${rule.ruleId}/preview`, { formData: {} });
return previewRes.data?.data?.generatedCode || "";
// 프리뷰 코드에서 각 파트별 표시값을 추출
const parsePreviewIntoParts = (previewCode: string, rule: any) => {
if (!previewCode || !rule?.parts) return [];
const sorted = [...rule.parts].sort((a: any, b: any) => a.order - b.order);
const globalSep = rule.separator || "";
const partMeta = sorted.map((part: any, idx: number) => {
const sep = idx < sorted.length - 1
? (part.separatorAfter ?? part.autoConfig?.separatorAfter ?? globalSep)
: "";
const config = part.autoConfig || {};
if (part.generationMethod === "manual") return { known: false, marker: "____", sep, isManual: true, partType: part.partType };
switch (part.partType) {
case "text": return { known: true, value: config.textValue || "", sep, isManual: false, partType: "text" };
case "number": return { known: true, value: String(config.numberValue || 1).padStart(config.numberLength || 3, "0"), sep, isManual: false, partType: "number" };
case "date": {
const now = new Date();
const y = String(now.getFullYear()), m = String(now.getMonth() + 1).padStart(2, "0"), d = String(now.getDate()).padStart(2, "0");
const fmt = config.dateFormat || "YYYYMMDD";
const map: Record<string, string> = { YYYY: y, YY: y.slice(2), YYYYMM: y + m, YYMM: y.slice(2) + m, YYYYMMDD: y + m + d, YYMMDD: y.slice(2) + m + d };
return { known: true, value: map[fmt] || y + m + d, sep, isManual: false, partType: "date" };
}
default: return { known: false, sep, isManual: false, partType: part.partType };
}
});
let remaining = previewCode;
const results: { value: string; isManual: boolean; separator: string }[] = [];
for (let i = 0; i < partMeta.length; i++) {
const meta = partMeta[i];
const nextMeta = i < partMeta.length - 1 ? partMeta[i + 1] : null;
if (meta.isManual) {
const markerIdx = remaining.indexOf("____");
if (markerIdx >= 0) {
remaining = remaining.substring(markerIdx + 4);
if (meta.sep && remaining.startsWith(meta.sep)) remaining = remaining.substring(meta.sep.length);
}
results.push({ value: "", isManual: true, separator: meta.sep });
continue;
}
if (meta.known) {
const valIdx = remaining.indexOf(meta.value);
if (valIdx >= 0) {
remaining = remaining.substring(valIdx + meta.value.length);
if (meta.sep && remaining.startsWith(meta.sep)) remaining = remaining.substring(meta.sep.length);
}
results.push({ value: meta.value, isManual: false, separator: meta.sep });
} else {
let endIdx = remaining.length;
if (meta.sep) {
if (nextMeta) {
if (nextMeta.known && nextMeta.value) {
const patIdx = remaining.indexOf(meta.sep + nextMeta.value);
if (patIdx >= 0) endIdx = patIdx;
} else if (nextMeta.isManual) {
const patIdx = remaining.indexOf(meta.sep + "____");
if (patIdx >= 0) endIdx = patIdx;
} else {
const sepIdx = remaining.indexOf(meta.sep);
if (sepIdx >= 0) endIdx = sepIdx;
}
}
} else if (nextMeta) {
if (nextMeta.known && nextMeta.value) {
const valIdx = remaining.indexOf(nextMeta.value);
if (valIdx >= 0) endIdx = valIdx;
} else if (nextMeta.isManual) {
const markerIdx = remaining.indexOf("____");
if (markerIdx >= 0) endIdx = markerIdx;
}
}
const extracted = remaining.substring(0, endIdx);
remaining = remaining.substring(endIdx);
if (meta.sep && remaining.startsWith(meta.sep)) remaining = remaining.substring(meta.sep.length);
results.push({ value: extracted, isManual: false, separator: meta.sep });
}
}
return results;
};
// 파트 값으로부터 전체 코드 조합
const buildCodeFromParts = (parts: { value: string; isManual: boolean; separator: string }[], manualVal: string) => {
return parts.map((p, idx) => {
const val = p.isManual ? manualVal : p.value;
const sep = idx < parts.length - 1 ? p.separator : "";
return val + sep;
}).join("");
};
// 채번 미리보기 로드
const loadNumberingPreview = async (currentFormData?: Record<string, any>, currentManualValue?: string) => {
try {
setIsNumberingLoading(true);
let rule = numberingRule;
if (!rule) {
const ruleRes = await apiClient.get(`/numbering-rules/by-column/${ITEM_TABLE}/item_number`);
rule = ruleRes.data?.data;
if (rule) {
setNumberingRule(rule);
numberingRuleIdRef.current = rule.ruleId;
}
}
if (!rule?.ruleId) return { code: "", parts: [] as { value: string; isManual: boolean; separator: string }[] };
const previewRes = await apiClient.post(`/numbering-rules/${rule.ruleId}/preview`, {
formData: currentFormData || {},
manualInputValue: currentManualValue || undefined,
});
const generatedCode = previewRes.data?.data?.generatedCode || "";
const parts = parsePreviewIntoParts(generatedCode, rule);
setNumberingParts(parts);
return { code: generatedCode, parts };
} catch { /* 채번 규칙 없으면 무시 */ }
return "";
finally {
setIsNumberingLoading(false);
}
return { code: "", parts: [] as { value: string; isManual: boolean; separator: string }[] };
};
// 품목 등록 모달 열기
const openRegisterModal = async () => {
setEditItemForm({});
setManualInputValue("");
setNumberingParts([]);
setIsEditMode(false);
setEditId(null);
setEditItemOpen(true);
const code = await loadNumberingPreview();
if (code) setEditItemForm(prev => ({ ...prev, item_number: code }));
const result = await loadNumberingPreview({});
if (result.code) {
const hasManual = result.parts.some(p => p.isManual);
const displayCode = hasManual ? buildCodeFromParts(result.parts, "") : result.code;
setEditItemForm(prev => ({ ...prev, item_number: displayCode }));
}
};
// 품목 수정 모달 열기
@@ -794,11 +976,49 @@ export default function SalesItemPage() {
if (!target) return;
const raw = rawItems.find((r) => r.id === target.id) || target;
setEditItemForm({ ...raw });
setManualInputValue("");
setNumberingParts([]);
setIsEditMode(true);
setEditId(target.id);
setEditItemOpen(true);
};
// 카테고리 변경 시 채번 preview 재호출
useEffect(() => {
if (isEditMode || !editItemOpen || !numberingRuleIdRef.current) return;
const hasCategoryPart = numberingRule?.parts?.some(
(p: any) => p.partType === "category" && p.generationMethod === "auto"
);
if (!hasCategoryPart) return;
const timer = setTimeout(async () => {
const result = await loadNumberingPreview(editItemForm, manualInputValue);
if (result.parts.length > 0) {
setEditItemForm(prev => ({ ...prev, item_number: buildCodeFromParts(result.parts, manualInputValue) }));
}
}, 300);
return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [...CATEGORY_COLUMNS_FOR_MODAL.map(col => editItemForm[col])]);
// 수동 입력값 변경 시 preview 갱신
useEffect(() => {
if (isEditMode || !editItemOpen || !numberingRuleIdRef.current) return;
if (!numberingParts.some(p => p.isManual)) return;
const timer = setTimeout(async () => {
const result = await loadNumberingPreview(editItemForm, manualInputValue);
if (result.parts.length > 0) {
setEditItemForm(prev => ({ ...prev, item_number: buildCodeFromParts(result.parts, manualInputValue) }));
}
}, 500);
return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [manualInputValue]);
// 품목 저장 (등록 + 수정 통합)
const handleEditSave = async () => {
if (!editItemForm.item_name) {
@@ -815,8 +1035,38 @@ export default function SalesItemPage() {
});
toast.success("수정되었어요.");
} else {
// 신규 등록: allocateCode 호출하여 실제 순번 확보
let finalItemNumber = editItemForm.item_number || "";
if (numberingRuleIdRef.current) {
try {
const hasManual = numberingParts.some(p => p.isManual);
const userInputCode = hasManual && manualInputValue
? manualInputValue
: undefined;
const allocRes = await apiClient.post(
`/numbering-rules/${numberingRuleIdRef.current}/allocate`,
{ formData: editItemForm, userInputCode }
);
if (allocRes.data?.success && allocRes.data?.data?.generatedCode) {
finalItemNumber = allocRes.data.data.generatedCode;
}
} catch (err) {
console.error("채번 할당 실패:", err);
toast.error("품목코드 할당에 실패했어요. 다시 시도해주세요.");
setSaving(false);
return;
}
}
const { id, created_date, updated_date, ...insertFields } = editItemForm;
await apiClient.post(`/table-management/tables/${ITEM_TABLE}/add`, { id: crypto.randomUUID(), ...insertFields });
await apiClient.post(`/table-management/tables/${ITEM_TABLE}/add`, {
id: crypto.randomUUID(),
...insertFields,
item_number: finalItemNumber,
});
toast.success("등록되었어요.");
}
setEditItemOpen(false);
@@ -1238,7 +1488,71 @@ export default function SalesItemPage() {
{field.label}
{field.required && <span className="text-destructive ml-1">*</span>}
</Label>
{field.type === "image" ? (
{field.type === "numbering" ? (
isEditMode ? (
<Input
value={editItemForm[field.key] || ""}
disabled
className="h-9 bg-muted"
/>
) : isNumberingLoading && numberingParts.length === 0 ? (
<div className="flex h-9 items-center gap-2 rounded-md border px-3">
<Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />
<span className="text-sm text-muted-foreground"> ...</span>
</div>
) : numberingParts.some(p => p.isManual) ? (
<div className="flex h-9 items-center rounded-md border border-input">
{numberingParts.map((part, idx) => {
const isFirst = idx === 0;
const isLast = idx === numberingParts.length - 1;
if (part.isManual) {
return (
<React.Fragment key={idx}>
<input
type="text"
value={manualInputValue}
onChange={(e) => {
const val = e.target.value;
setManualInputValue(val);
setEditItemForm(prev => ({
...prev,
item_number: buildCodeFromParts(numberingParts, val),
}));
}}
placeholder="입력"
className="h-full min-w-[60px] flex-1 border-0 bg-transparent px-2 text-sm outline-none"
/>
{part.separator && !isLast && (
<span className="text-muted-foreground text-sm">{part.separator}</span>
)}
</React.Fragment>
);
}
return (
<React.Fragment key={idx}>
<span className={cn(
"flex h-full items-center bg-muted px-2 text-sm text-muted-foreground whitespace-nowrap",
isFirst && "rounded-l-[5px]",
isLast && "rounded-r-[5px]",
)}>
{part.value}
</span>
{part.separator && !isLast && (
<span className="text-muted-foreground text-sm">{part.separator}</span>
)}
</React.Fragment>
);
})}
</div>
) : (
<Input
value={editItemForm[field.key] || ""}
disabled
placeholder="자동 채번"
className="h-9 bg-muted"
/>
)
) : field.type === "image" ? (
<ImageUpload
value={editItemForm[field.key] || ""}
onChange={(v) => setEditItemForm((prev) => ({ ...prev, [field.key]: v }))}
@@ -0,0 +1,6 @@
"use client";
import { EquipmentInspection } from "@/components/pop/hardcoded/equipment/EquipmentInspection";
export default function EquipmentInspectionPage() {
return <EquipmentInspection />;
}
@@ -0,0 +1,6 @@
"use client";
import { EquipmentList } from "@/components/pop/hardcoded/equipment/EquipmentList";
export default function EquipmentManagementPage() {
return <EquipmentList />;
}
@@ -0,0 +1,6 @@
"use client";
import { EquipmentHome } from "@/components/pop/hardcoded/equipment/EquipmentHome";
export default function EquipmentPage() {
return <EquipmentHome />;
}
@@ -0,0 +1,6 @@
"use client";
import { InventoryMove } from "@/components/pop/hardcoded/inventory/InventoryMove";
export default function InventoryMovePage() {
return <InventoryMove />;
}
@@ -0,0 +1,6 @@
"use client";
import { InventoryTransfer } from "@/components/pop/hardcoded/inventory/InventoryTransfer";
export default function TransferPage() {
return <InventoryTransfer />;
}
+126 -12
View File
@@ -1,6 +1,7 @@
"use client";
import React, { useState, Suspense, useEffect, useCallback, useRef } from "react";
import React, { useState, Suspense, useEffect, useCallback, useRef, useMemo } from "react";
import { createPortal } from "react-dom";
import { useRouter, usePathname, useSearchParams } from "next/navigation";
import { Button } from "@/components/ui/button";
import {
@@ -262,6 +263,7 @@ function AppLayoutInner({ children }: AppLayoutProps) {
return false;
});
const [hasPopMenus, setHasPopMenus] = useState(false);
const [hoveredCollapsedMenu, setHoveredCollapsedMenu] = useState<string | null>(null);
const toggleSidebarCollapse = () => {
setSidebarCollapsed((prev) => {
@@ -623,22 +625,73 @@ function AppLayoutInner({ children }: AppLayoutProps) {
};
// 축소 상태 메뉴 렌더링 (아이콘만, hover 시 오버레이 메뉴로 조작)
const collapsedMenuRefs = useRef<Record<string, HTMLDivElement | null>>({});
const hoverTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const handleCollapsedMouseEnter = useCallback((menuId: string) => {
if (hoverTimeoutRef.current) clearTimeout(hoverTimeoutRef.current);
setHoveredCollapsedMenu(menuId);
}, []);
const handleCollapsedMouseLeave = useCallback(() => {
hoverTimeoutRef.current = setTimeout(() => setHoveredCollapsedMenu(null), 150);
}, []);
const renderCollapsedMenu = (menu: any) => {
const isActive = isMenuActive(menu);
const hasActiveChild = menu.hasChildren && menu.children?.some((child: any) => isMenuActive(child));
const isHovered = hoveredCollapsedMenu === menu.id;
return (
<button
<div
key={menu.id}
className={`flex h-10 w-10 items-center justify-center rounded-lg transition-colors [&_svg]:h-5 [&_svg]:w-5 ${
isActive || hasActiveChild
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-accent hover:text-foreground"
}`}
onClick={() => handleMenuClick(menu)}
ref={(el) => { collapsedMenuRefs.current[menu.id] = el; }}
onMouseEnter={() => handleCollapsedMouseEnter(menu.id)}
onMouseLeave={handleCollapsedMouseLeave}
>
{menu.icon}
</button>
<button
className={`flex h-10 w-10 items-center justify-center rounded-lg transition-colors [&_svg]:h-5 [&_svg]:w-5 ${
isActive || hasActiveChild
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-accent hover:text-foreground"
}`}
onClick={() => handleMenuClick(menu)}
>
{menu.icon}
</button>
{isHovered && typeof document !== "undefined" && createPortal(
<div
className="fixed z-[9999] min-w-[180px] rounded-lg border border-border bg-popover p-1.5 shadow-lg"
style={{
top: collapsedMenuRefs.current[menu.id]?.getBoundingClientRect().top ?? 0,
left: (collapsedMenuRefs.current[menu.id]?.getBoundingClientRect().right ?? 0) + 4,
}}
onMouseEnter={() => handleCollapsedMouseEnter(menu.id)}
onMouseLeave={handleCollapsedMouseLeave}
>
<div className="mb-1 rounded-md px-2.5 py-1.5 text-xs font-semibold text-foreground">
{menu.name}
</div>
{menu.hasChildren && menu.children?.map((child: any) => (
<button
key={child.id}
className={`flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-sm transition-colors hover:bg-accent hover:text-accent-foreground ${
isMenuActive(child) ? "bg-primary/10 text-primary font-medium" : "text-muted-foreground"
}`}
onClick={() => {
handleMenuClick(child);
setHoveredCollapsedMenu(null);
}}
>
{child.icon}
<span className="truncate">{child.name}</span>
</button>
))}
</div>,
document.body
)}
</div>
);
};
@@ -794,8 +847,69 @@ function AppLayoutInner({ children }: AppLayoutProps) {
</div>
)}
{/* 확장 상태 전용: 관리 회사, 모드 전환 버튼 */}
{(!isMobile && sidebarCollapsed) ? null : (
{/* 관리 회사, 모드 전환 버튼 */}
{(!isMobile && sidebarCollapsed) ? (
/* 축소 상태: 관리자 아이콘 + hover 팝오버 */
((user as ExtendedUserInfo)?.userType === "SUPER_ADMIN" ||
(user as ExtendedUserInfo)?.userType === "COMPANY_ADMIN" ||
(user as ExtendedUserInfo)?.userType === "admin") ? (
<div
className="flex flex-col items-center gap-1 border-b border-border py-2"
ref={(el) => { collapsedMenuRefs.current["__admin__"] = el; }}
onMouseEnter={() => handleCollapsedMouseEnter("__admin__")}
onMouseLeave={handleCollapsedMouseLeave}
>
<button
className={`flex h-10 w-10 items-center justify-center rounded-lg transition-colors ${
isAdminMode
? "bg-amber-100 text-amber-700 dark:bg-amber-950 dark:text-amber-400"
: "text-primary bg-primary/10"
}`}
onClick={handleModeSwitch}
>
{isAdminMode ? <UserCheck className="h-5 w-5" /> : <Shield className="h-5 w-5" />}
</button>
{hoveredCollapsedMenu === "__admin__" && typeof document !== "undefined" && createPortal(
<div
className="fixed z-[9999] min-w-[200px] rounded-lg border border-border bg-popover p-2 shadow-lg"
style={{
top: collapsedMenuRefs.current["__admin__"]?.getBoundingClientRect().top ?? 0,
left: (collapsedMenuRefs.current["__admin__"]?.getBoundingClientRect().right ?? 0) + 4,
}}
onMouseEnter={() => handleCollapsedMouseEnter("__admin__")}
onMouseLeave={handleCollapsedMouseLeave}
>
{(user as ExtendedUserInfo)?.userType === "SUPER_ADMIN" && (
<div className="mb-2 rounded-md bg-muted/50 px-2.5 py-2">
<p className="text-muted-foreground text-[10px]"> </p>
<p className="truncate text-sm font-semibold">{currentCompanyName || "로딩 중..."}</p>
</div>
)}
<Button
onClick={handleModeSwitch}
className={`flex w-full items-center justify-center gap-2 rounded-md px-3 py-1.5 text-sm font-medium transition-colors hover:cursor-pointer ${
isAdminMode
? "border border-amber-200 bg-amber-50 text-amber-700 hover:bg-amber-100 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-400"
: "border-primary/20 bg-primary/5 text-primary hover:bg-primary/10 border"
}`}
>
{isAdminMode ? <><UserCheck className="h-4 w-4" /> </> : <><Shield className="h-4 w-4" /> </>}
</Button>
{(user as ExtendedUserInfo)?.userType === "SUPER_ADMIN" && (
<Button
onClick={() => { setShowCompanySwitcher(true); setHoveredCollapsedMenu(null); }}
className="border-primary/20 bg-primary/5 text-primary hover:bg-primary/10 mt-1.5 flex w-full items-center justify-center gap-2 rounded-md border px-3 py-1.5 text-sm font-medium transition-colors hover:cursor-pointer"
>
<Building2 className="h-4 w-4" />
</Button>
)}
</div>,
document.body
)}
</div>
) : null
) : (
<>
{(user as ExtendedUserInfo)?.userType === "SUPER_ADMIN" && (
<div className="border-border bg-muted/50 mx-3 mt-3 rounded-md border p-3">
@@ -126,7 +126,7 @@ const MENU_ITEMS: MenuIconItem[] = [
/>
</svg>
),
href: "/pop/screens/equipment",
href: "/pop/equipment",
},
{
id: "inventory",
@@ -0,0 +1,198 @@
"use client";
import { useRouter } from "next/navigation";
import React, { useEffect, useState } from "react";
import { apiClient } from "@/lib/api/client";
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
interface EquipmentItem {
id: string;
equipment_code: string;
equipment_name: string;
equipment_type?: string;
status?: string;
}
interface KpiData {
total: number;
active: number;
idle: number;
inspect: number;
rate: string;
}
/* ------------------------------------------------------------------ */
/* Menu */
/* ------------------------------------------------------------------ */
const MENU_ITEMS = [
{
id: "management",
title: "설비관리",
gradient: "linear-gradient(135deg,#8b5cf6,#6d28d9)",
shadowColor: "rgba(139,92,246,.3)",
icon: (
<svg className="w-7 h-7 text-white" fill="none" stroke="currentColor" strokeWidth={1.5} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M11.42 15.17l-5.65-5.65a8 8 0 1111.31 0l-5.65 5.65z" />
</svg>
),
href: "/pop/equipment/management",
},
{
id: "inspection",
title: "설비점검",
gradient: "linear-gradient(135deg,#f59e0b,#d97706)",
shadowColor: "rgba(245,158,11,.3)",
icon: (
<svg className="w-7 h-7 text-white" fill="none" stroke="currentColor" strokeWidth={1.5} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M21.75 6.75a4.5 4.5 0 01-4.884 4.484c-1.076-.091-2.264.071-2.95.904l-7.152 8.684a2.548 2.548 0 11-3.586-3.586l8.684-7.152c.833-.686.995-1.874.904-2.95a4.5 4.5 0 016.336-4.486l-3.276 3.276a3.004 3.004 0 002.25 2.25l3.276-3.276c.256.565.398 1.192.398 1.852z" />
</svg>
),
href: "/pop/equipment/inspection",
},
];
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
export function EquipmentHome() {
const router = useRouter();
const [kpi, setKpi] = useState<KpiData>({
total: 0,
active: 0,
idle: 0,
inspect: 0,
rate: "0%",
});
const [recentItems, setRecentItems] = useState<EquipmentItem[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchData = async () => {
setLoading(true);
try {
const res = await apiClient.get("/data/equipment_mng", {
params: { pageSize: 500 },
});
const data = res.data?.data?.data ?? res.data?.data ?? [];
const items = Array.isArray(data) ? data : [];
const total = items.length;
const active = items.filter((i: EquipmentItem) => !i.status || i.status === "가동" || i.status === "정상").length;
const idle = items.filter((i: EquipmentItem) => i.status === "대기").length;
const inspect = items.filter((i: EquipmentItem) => i.status === "점검").length;
const rate = total > 0 ? `${Math.round((active / total) * 100)}%` : "0%";
setKpi({ total, active, idle, inspect, rate });
setRecentItems(items.slice(0, 5));
} catch { /* */ }
setLoading(false);
};
fetchData();
}, []);
return (
<div className="min-h-screen bg-gray-50">
{/* Header */}
<div className="bg-white px-5 pt-5 pb-4">
<div className="flex items-center gap-3 mb-3">
<button onClick={() => router.back()} className="w-9 h-9 rounded-xl bg-gray-100 flex items-center justify-center hover:bg-gray-200 transition-colors">
<svg className="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
</button>
<div>
<h1 className="text-xl font-bold text-gray-900"></h1>
<p className="text-sm text-gray-500"> </p>
</div>
</div>
{/* KPI */}
<div className="bg-white rounded-2xl border border-gray-200 p-4">
<div className="grid grid-cols-5 text-center divide-x divide-gray-100">
{[
{ value: loading ? "-" : kpi.total, label: "전체", color: "text-gray-900" },
{ value: loading ? "-" : kpi.active, label: "가동", color: "text-green-600" },
{ value: loading ? "-" : kpi.idle, label: "대기", color: "text-blue-600" },
{ value: loading ? "-" : kpi.inspect, label: "점검", color: "text-red-600" },
{ value: loading ? "-" : kpi.rate, label: "가동률", color: "text-purple-600" },
].map((item) => (
<div key={item.label} className="px-2">
<p className={`text-2xl font-extrabold ${item.color}`}>{item.value}</p>
<p className="text-xs text-gray-400 mt-0.5">{item.label}</p>
</div>
))}
</div>
</div>
</div>
{/* Menu Icons */}
<div className="px-5 pt-5">
<h2 className="flex items-center gap-2 text-sm font-bold text-gray-700 mb-3">
<span className="w-1 h-4 bg-purple-500 rounded" />
</h2>
<div className="flex gap-4">
{MENU_ITEMS.map((item) => (
<button
key={item.id}
onClick={() => router.push(item.href)}
className="flex flex-col items-center gap-2 active:scale-95 transition-transform"
>
<div
className="w-14 h-14 rounded-2xl flex items-center justify-center"
style={{
background: item.gradient,
boxShadow: `0 4px 12px ${item.shadowColor}`,
}}
>
{item.icon}
</div>
<span className="text-xs font-semibold text-gray-700">{item.title}</span>
</button>
))}
</div>
</div>
{/* Recent Equipment */}
<div className="px-5 pt-6 pb-24">
<div className="bg-white rounded-2xl border border-gray-200 overflow-hidden">
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-100">
<h3 className="font-bold text-gray-900"> </h3>
<span className="text-xs text-gray-400"> 5</span>
</div>
{loading ? (
<div className="flex justify-center py-12">
<div className="w-6 h-6 border-3 border-purple-500 border-t-transparent rounded-full animate-spin" />
</div>
) : recentItems.length === 0 ? (
<div className="text-center py-12 text-gray-400 text-sm"> </div>
) : (
<div className="divide-y divide-gray-50">
{recentItems.map((item) => (
<div key={item.id} className="flex items-center justify-between px-4 py-3">
<div>
<p className="text-sm font-semibold text-gray-900">{item.equipment_name || "-"}</p>
<p className="text-xs text-gray-400">{item.equipment_code} {item.equipment_type ? `· ${item.equipment_type}` : ""}</p>
</div>
<span className={`text-xs px-2 py-0.5 rounded-full font-semibold ${
item.status === "정지" || item.status === "비가동"
? "bg-red-50 text-red-600"
: item.status === "점검"
? "bg-amber-50 text-amber-600"
: "bg-green-50 text-green-600"
}`}>
{item.status || "정상"}
</span>
</div>
))}
</div>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,119 @@
"use client";
import React, { useState } from "react";
import { useRouter } from "next/navigation";
import { PopShell } from "../PopShell";
type TabType = "all" | "running" | "idle" | "inspect" | "stopped";
export function EquipmentInspection() {
const router = useRouter();
const [activeTab, setActiveTab] = useState<TabType>("all");
const [searchKeyword, setSearchKeyword] = useState("");
// 시안 기준 KPI (점검 테이블 없으므로 0)
const kpi = { total: 0, running: 0, idle: 0, inspect: 0, rate: "0%" };
const tabs: { key: TabType; label: string; count: number }[] = [
{ key: "all", label: "전체", count: kpi.total },
{ key: "running", label: "가동", count: kpi.running },
{ key: "idle", label: "대기", count: kpi.idle },
{ key: "inspect", label: "점검", count: kpi.inspect },
{ key: "stopped", label: "비가동", count: 0 },
];
return (
<PopShell title="점검관리" showBanner={false}>
<div className="min-h-screen bg-gray-50">
{/* Header */}
<div className="bg-white border-b border-gray-200 px-5 pt-5 pb-4">
<div className="flex items-center gap-3 mb-4">
<button onClick={() => router.back()} className="w-9 h-9 rounded-xl bg-gray-100 flex items-center justify-center">
<svg className="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
</button>
<div>
<h1 className="text-xl font-bold text-gray-900">🔧 </h1>
</div>
</div>
{/* Search */}
<div className="flex gap-2">
<input
type="text"
placeholder="설비명 / 설비코드 검색"
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
className="flex-1 px-4 py-3 rounded-xl border border-gray-200 text-sm focus:outline-none focus:border-amber-400"
/>
<select className="px-3 py-3 rounded-xl border border-gray-200 text-sm bg-white">
<option></option>
<option></option>
<option></option>
<option></option>
</select>
<button className="px-5 py-3 rounded-xl bg-amber-500 text-white text-sm font-bold active:bg-amber-600">
🔍
</button>
</div>
</div>
{/* KPI */}
<div className="px-5 py-3">
<div className="grid grid-cols-5 gap-2">
{[
{ label: "전체", value: kpi.total, color: "border-t-amber-500", icon: "🎬" },
{ label: "가동", value: kpi.running, color: "border-t-green-500", icon: "🟢" },
{ label: "대기", value: kpi.idle, color: "border-t-blue-500", icon: "🔵" },
{ label: "점검", value: kpi.inspect, color: "border-t-red-500", icon: "🔴" },
{ label: "가동률", value: kpi.rate, color: "border-t-purple-500", icon: "📊" },
].map((item) => (
<div key={item.label} className={`bg-white rounded-xl border border-gray-200 ${item.color} border-t-[3px] p-3 text-center`}>
<p className="text-lg mb-0.5">{item.icon}</p>
<p className="text-xl font-bold text-gray-900">{item.value}</p>
<p className="text-[10px] text-gray-500">{item.label}</p>
</div>
))}
</div>
</div>
{/* Tabs */}
<div className="px-5">
<div className="flex bg-white rounded-xl border border-gray-200 overflow-hidden">
{tabs.map((tab) => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`flex-1 py-3 text-xs font-medium text-center relative transition-all ${
activeTab === tab.key
? "text-amber-600 font-bold bg-amber-50"
: "text-gray-500"
}`}
>
{tab.label}
<span className={`ml-1 text-[9px] px-1.5 py-0.5 rounded-full font-bold ${
activeTab === tab.key ? "bg-amber-500 text-white" : "bg-gray-200 text-gray-500"
}`}>
{tab.count}
</span>
{activeTab === tab.key && (
<span className="absolute bottom-0 left-[20%] right-[20%] h-[3px] bg-amber-500 rounded-t" />
)}
</button>
))}
</div>
</div>
{/* Card List — 데이터 없음 */}
<div className="px-5 py-6">
<div className="flex flex-col items-center justify-center py-20 text-gray-400">
<span className="text-5xl mb-4">🔧</span>
<p className="text-base font-semibold mb-1"> </p>
<p className="text-sm">PC에서 </p>
</div>
</div>
</div>
</PopShell>
);
}
@@ -0,0 +1,169 @@
"use client";
import React, { useState, useEffect, useCallback } from "react";
import { useRouter } from "next/navigation";
import { apiClient } from "@/lib/api/client";
import { PopShell } from "../PopShell";
interface EquipmentItem {
id: string;
equipment_code: string;
equipment_name: string;
equipment_type?: string;
location?: string;
status?: string;
last_inspection_date?: string;
next_inspection_date?: string;
memo?: string;
}
export function EquipmentList() {
const router = useRouter();
const [items, setItems] = useState<EquipmentItem[]>([]);
const [loading, setLoading] = useState(true);
const [searchKeyword, setSearchKeyword] = useState("");
const fetchEquipments = useCallback(async () => {
setLoading(true);
try {
const res = await apiClient.get("/data/equipment_mng", {
params: { pageSize: 500 },
});
const data = res.data?.data?.data ?? res.data?.data ?? [];
setItems(Array.isArray(data) ? data : []);
} catch {
setItems([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchEquipments();
}, [fetchEquipments]);
const filtered = items.filter((item) => {
if (!searchKeyword) return true;
const kw = searchKeyword.toLowerCase();
return (
(item.equipment_code || "").toLowerCase().includes(kw) ||
(item.equipment_name || "").toLowerCase().includes(kw) ||
(item.equipment_type || "").toLowerCase().includes(kw)
);
});
// KPI
const totalCount = items.length;
const activeCount = items.filter((i) => i.status === "가동" || i.status === "정상" || !i.status).length;
const stopCount = items.filter((i) => i.status === "정지" || i.status === "비가동").length;
return (
<PopShell title="설비관리" showBanner={false}>
<div className="min-h-screen bg-gray-50">
{/* Header */}
<div className="bg-white border-b border-gray-200 px-5 pt-5 pb-4">
<div className="flex items-center gap-3 mb-4">
<button
onClick={() => router.back()}
className="w-9 h-9 rounded-xl bg-gray-100 flex items-center justify-center"
>
<svg className="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
</button>
<div>
<h1 className="text-xl font-bold text-gray-900"></h1>
<p className="text-sm text-gray-500"> </p>
</div>
</div>
{/* Search */}
<div className="relative">
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
</svg>
<input
type="text"
placeholder="설비명 또는 코드 검색..."
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
className="w-full pl-10 pr-4 py-3 rounded-xl border border-gray-200 text-sm focus:outline-none focus:border-blue-400"
/>
</div>
</div>
{/* KPI */}
<div className="px-5 py-3">
<div className="grid grid-cols-3 gap-3">
<div className="bg-white rounded-xl border border-gray-200 p-3 text-center">
<p className="text-xs text-gray-400"></p>
<p className="text-2xl font-bold text-gray-900">{totalCount}</p>
</div>
<div className="bg-white rounded-xl border border-green-200 p-3 text-center">
<p className="text-xs text-green-500"></p>
<p className="text-2xl font-bold text-green-600">{activeCount}</p>
</div>
<div className="bg-white rounded-xl border border-red-200 p-3 text-center">
<p className="text-xs text-red-500"></p>
<p className="text-2xl font-bold text-red-600">{stopCount}</p>
</div>
</div>
</div>
{/* List */}
<div className="px-5 pb-24">
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-semibold text-gray-500"> </h2>
<span className="text-xs text-gray-400">{filtered.length}</span>
</div>
{loading ? (
<div className="flex items-center justify-center py-20">
<div className="w-8 h-8 border-4 border-blue-500 border-t-transparent rounded-full animate-spin" />
</div>
) : filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-gray-400">
<svg className="w-16 h-16 mb-4 text-gray-200" fill="none" stroke="currentColor" strokeWidth={1} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M11.42 15.17l-5.65-5.65a8 8 0 1111.31 0l-5.65 5.65z" />
</svg>
<p className="text-sm"> </p>
</div>
) : (
<div className="space-y-3">
{filtered.map((item) => (
<div
key={item.id}
className="bg-white rounded-2xl border border-gray-200 p-4 hover:shadow-md transition-shadow"
>
<div className="flex items-start justify-between mb-2">
<div>
<h3 className="font-bold text-gray-900">{item.equipment_name || "-"}</h3>
<p className="text-xs text-gray-400">{item.equipment_code}</p>
</div>
<span
className={`text-xs px-2.5 py-1 rounded-full font-semibold ${
item.status === "정지" || item.status === "비가동"
? "bg-red-100 text-red-600"
: "bg-green-100 text-green-600"
}`}
>
{item.status || "정상"}
</span>
</div>
<div className="grid grid-cols-2 gap-2 text-xs text-gray-500">
{item.equipment_type && (
<div>: <span className="text-gray-700 font-medium">{item.equipment_type}</span></div>
)}
{item.location && (
<div>: <span className="text-gray-700 font-medium">{item.location}</span></div>
)}
</div>
</div>
))}
</div>
)}
</div>
</div>
</PopShell>
);
}
@@ -95,7 +95,29 @@ const MENU_ITEMS = [
/>
</svg>
),
href: "#",
href: "/pop/inventory/transfer",
},
{
id: "move",
title: "재고이동",
gradient: "linear-gradient(135deg,#10b981,#059669)",
shadowColor: "rgba(16,185,129,.3)",
icon: (
<svg
className="w-7 h-7 text-white"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M7.5 21L3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5"
/>
</svg>
),
href: "/pop/inventory/move",
},
];
@@ -0,0 +1,289 @@
"use client";
import React, { useState, useEffect, useCallback } from "react";
import { useRouter } from "next/navigation";
import { apiClient } from "@/lib/api/client";
import { PopShell } from "../PopShell";
interface Warehouse {
id: string;
warehouse_code: string;
warehouse_name: string;
}
interface StockItem {
id: string;
item_code: string;
item_name?: string;
warehouse_code: string;
location_code?: string;
current_qty: string;
}
interface PendingItem {
stock: StockItem;
moveQty: number;
toWarehouse: string;
}
export function InventoryMove() {
const router = useRouter();
const [warehouses, setWarehouses] = useState<Warehouse[]>([]);
const [fromWarehouse, setFromWarehouse] = useState("");
const [toWarehouse, setToWarehouse] = useState("");
const [stockItems, setStockItems] = useState<StockItem[]>([]);
const [loading, setLoading] = useState(false);
const [searchKeyword, setSearchKeyword] = useState("");
const [pendingItems, setPendingItems] = useState<PendingItem[]>([]);
const fetchWarehouses = useCallback(async () => {
try {
const res = await apiClient.get("/outbound/warehouses");
setWarehouses(res.data?.data || []);
} catch { /* */ }
}, []);
const fetchStock = useCallback(async () => {
if (!fromWarehouse) { setStockItems([]); return; }
setLoading(true);
try {
const res = await apiClient.get("/data/inventory_stock", {
params: { pageSize: "500", filters: JSON.stringify({ warehouse_code: fromWarehouse }) },
});
const data = res.data?.data?.data ?? res.data?.data ?? [];
setStockItems(Array.isArray(data) ? data.filter((d: StockItem) => parseFloat(d.current_qty || "0") > 0) : []);
} catch {
setStockItems([]);
} finally {
setLoading(false);
}
}, [fromWarehouse]);
useEffect(() => { fetchWarehouses(); }, [fetchWarehouses]);
useEffect(() => { fetchStock(); }, [fetchStock]);
const filtered = stockItems.filter((item) => {
if (!searchKeyword) return true;
const kw = searchKeyword.toLowerCase();
return (item.item_code || "").toLowerCase().includes(kw) || (item.item_name || "").toLowerCase().includes(kw);
});
const addToPending = (stock: StockItem) => {
if (!toWarehouse) { alert("도착 창고를 먼저 선택하세요."); return; }
if (pendingItems.find((p) => p.stock.id === stock.id)) return;
const qty = parseFloat(stock.current_qty || "0");
setPendingItems((prev) => [...prev, { stock, moveQty: qty, toWarehouse }]);
};
const removePending = (id: string) => {
setPendingItems((prev) => prev.filter((p) => p.stock.id !== id));
};
const fromWh = warehouses.find((w) => w.warehouse_code === fromWarehouse);
const toWh = warehouses.find((w) => w.warehouse_code === toWarehouse);
return (
<PopShell title="재고이동" showBanner={false}>
<div className="flex flex-col h-screen bg-gray-100">
{/* Header */}
<div className="bg-white border-b border-gray-200 px-4 py-3 flex items-center gap-3 shrink-0">
<button onClick={() => router.back()} className="w-9 h-9 rounded-xl bg-gray-100 flex items-center justify-center">
<svg className="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
</button>
<div className="flex-1">
<h1 className="text-lg font-bold text-gray-900">📦 </h1>
<p className="text-xs text-gray-500"> </p>
</div>
</div>
{/* 좌우 분할 */}
<div className="flex-1 flex overflow-hidden">
{/* ===== 왼쪽: 출발 창고 + 품목 선택 ===== */}
<div className="flex-1 flex flex-col bg-white border-r-2 border-gray-200">
{/* 출발 창고 헤더 */}
<div className="px-4 py-3 border-b border-gray-100 bg-blue-50">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-bold text-blue-800">📤 </span>
<span className="text-xs px-2 py-0.5 rounded-full bg-blue-100 text-blue-600 font-semibold">FROM</span>
</div>
<div className="flex gap-2 overflow-x-auto pb-1">
{warehouses.map((wh) => (
<button
key={wh.warehouse_code}
onClick={() => { setFromWarehouse(wh.warehouse_code); setPendingItems([]); }}
className={`px-4 py-2.5 rounded-xl text-sm font-semibold whitespace-nowrap transition-all ${
fromWarehouse === wh.warehouse_code
? "bg-blue-600 text-white shadow-md"
: "bg-white text-gray-600 border border-gray-200"
}`}
>
{wh.warehouse_name}
</button>
))}
</div>
</div>
{/* 검색 */}
{fromWarehouse && (
<div className="px-4 py-2 border-b border-gray-100">
<div className="flex gap-2">
<input
type="text"
placeholder="품목명 / 코드 검색"
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
className="flex-1 px-4 py-2.5 rounded-xl border border-gray-200 text-sm focus:outline-none focus:border-blue-400"
/>
<button className="px-4 py-2.5 rounded-xl bg-blue-500 text-white text-sm font-bold">🔍</button>
</div>
</div>
)}
{/* 품목 리스트 */}
<div className="flex-1 overflow-y-auto px-3 py-2">
{!fromWarehouse ? (
<div className="flex flex-col items-center justify-center py-20 text-gray-400">
<span className="text-4xl mb-3">📦</span>
<p className="text-base font-semibold"> </p>
</div>
) : loading ? (
<div className="flex justify-center py-16">
<div className="w-8 h-8 border-4 border-blue-500 border-t-transparent rounded-full animate-spin" />
</div>
) : filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-gray-400">
<p className="text-sm"> </p>
</div>
) : (
<div className="space-y-2">
{filtered.map((item) => {
const isPending = pendingItems.some((p) => p.stock.id === item.id);
return (
<button
key={item.id}
onClick={() => addToPending(item)}
disabled={isPending}
className={`w-full text-left p-3.5 rounded-xl border transition-all active:scale-[0.98] ${
isPending
? "bg-green-50 border-green-300 opacity-60"
: "bg-white border-gray-200 hover:border-blue-300 hover:shadow-sm"
}`}
>
<div className="flex items-center justify-between">
<div>
<p className="text-base font-bold text-gray-900">{item.item_name || item.item_code}</p>
<p className="text-xs text-gray-400">{item.item_code} · {item.location_code || item.warehouse_code}</p>
</div>
<div className="text-right">
<p className="text-xl font-bold text-blue-600">{parseFloat(item.current_qty || "0").toLocaleString()}</p>
</div>
</div>
</button>
);
})}
</div>
)}
</div>
</div>
{/* ===== 오른쪽: 도착 창고 + 이동 대기열 ===== */}
<div className="flex-1 flex flex-col bg-gray-50">
{/* 도착 창고 헤더 */}
<div className="px-4 py-3 border-b border-gray-100 bg-green-50">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-bold text-green-800">📥 </span>
<span className="text-xs px-2 py-0.5 rounded-full bg-green-100 text-green-600 font-semibold">TO</span>
</div>
<div className="flex gap-2 overflow-x-auto pb-1">
{warehouses
.filter((wh) => wh.warehouse_code !== fromWarehouse)
.map((wh) => (
<button
key={wh.warehouse_code}
onClick={() => setToWarehouse(wh.warehouse_code)}
className={`px-4 py-2.5 rounded-xl text-sm font-semibold whitespace-nowrap transition-all ${
toWarehouse === wh.warehouse_code
? "bg-green-600 text-white shadow-md"
: "bg-white text-gray-600 border border-gray-200"
}`}
>
{wh.warehouse_name}
</button>
))}
</div>
</div>
{/* 이동 방향 표시 */}
{fromWh && toWh && (
<div className="px-4 py-2 bg-white border-b border-gray-200 flex items-center justify-center gap-3">
<span className="text-sm font-bold text-blue-600">{fromWh.warehouse_name}</span>
<span className="text-lg"></span>
<span className="text-sm font-bold text-green-600">{toWh.warehouse_name}</span>
</div>
)}
{/* 이동 대기 목록 */}
<div className="flex-1 overflow-y-auto px-3 py-2">
{pendingItems.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-gray-400">
<span className="text-4xl mb-3">📋</span>
<p className="text-base font-semibold"> </p>
<p className="text-sm mt-1"> </p>
</div>
) : (
<div className="space-y-2">
{pendingItems.map((p) => (
<div key={p.stock.id} className="bg-white rounded-xl border-l-4 border-l-blue-500 border border-gray-200 p-3.5">
<div className="flex items-center justify-between mb-1">
<p className="text-base font-bold text-gray-900">{p.stock.item_name || p.stock.item_code}</p>
<button
onClick={() => removePending(p.stock.id)}
className="px-3 py-1.5 rounded-lg border border-red-200 bg-red-50 text-red-600 text-sm font-bold active:bg-red-100"
>
</button>
</div>
<p className="text-xs text-gray-400 mb-2">{p.stock.item_code}</p>
<div className="flex items-center gap-2">
<span className="text-sm font-bold text-blue-600 bg-blue-50 px-2 py-0.5 rounded">
{p.moveQty.toLocaleString()} EA
</span>
<span className="text-xs text-gray-400">
{p.stock.warehouse_code} {p.toWarehouse}
</span>
</div>
</div>
))}
</div>
)}
</div>
{/* 하단 확정 바 */}
<div className="px-4 py-3 border-t border-gray-200 bg-white flex items-center justify-between shrink-0">
<div className="text-sm text-gray-500">
: <strong className="text-blue-600">{pendingItems.length}</strong>
</div>
<button
onClick={() => alert("재고 이동 API 준비 중입니다.")}
disabled={pendingItems.length === 0}
className={`px-6 py-3 rounded-xl text-base font-bold text-white active:scale-[0.98] transition-all ${
pendingItems.length > 0
? "bg-red-500 hover:bg-red-600"
: "bg-gray-300"
}`}
>
{pendingItems.length > 0 && (
<span className="ml-1 bg-white text-red-500 text-xs font-bold px-2 py-0.5 rounded-full">
{pendingItems.length}
</span>
)}
</button>
</div>
</div>
</div>
</div>
</PopShell>
);
}
@@ -0,0 +1,252 @@
"use client";
import React, { useState, useEffect, useCallback } from "react";
import { useRouter } from "next/navigation";
import { apiClient } from "@/lib/api/client";
import { PopShell } from "../PopShell";
interface Warehouse {
id: string;
warehouse_code: string;
warehouse_name: string;
warehouse_type?: string;
}
interface StockItem {
id: string;
item_code: string;
item_name?: string;
warehouse_code: string;
location_code?: string;
current_qty: string;
unit?: string;
}
interface SelectedItem {
stock: StockItem;
adjustQty: string;
type: "confirm" | "adjust";
}
export function InventoryTransfer() {
const router = useRouter();
const [warehouses, setWarehouses] = useState<Warehouse[]>([]);
const [selectedWarehouse, setSelectedWarehouse] = useState("all");
const [stockItems, setStockItems] = useState<StockItem[]>([]);
const [loading, setLoading] = useState(true);
const [searchKeyword, setSearchKeyword] = useState("");
const [selectedItems, setSelectedItems] = useState<SelectedItem[]>([]);
const fetchWarehouses = useCallback(async () => {
try {
const res = await apiClient.get("/outbound/warehouses");
setWarehouses(res.data?.data || []);
} catch { /* */ }
}, []);
const fetchStock = useCallback(async () => {
setLoading(true);
try {
const params: Record<string, string> = { pageSize: "500" };
if (selectedWarehouse !== "all") {
params.filters = JSON.stringify({ warehouse_code: selectedWarehouse });
}
const res = await apiClient.get("/data/inventory_stock", { params });
const data = res.data?.data?.data ?? res.data?.data ?? [];
setStockItems(Array.isArray(data) ? data.filter((d: StockItem) => parseFloat(d.current_qty || "0") > 0) : []);
} catch {
setStockItems([]);
} finally {
setLoading(false);
}
}, [selectedWarehouse]);
useEffect(() => { fetchWarehouses(); }, [fetchWarehouses]);
useEffect(() => { fetchStock(); }, [fetchStock]);
const filtered = stockItems.filter((item) => {
if (!searchKeyword) return true;
const kw = searchKeyword.toLowerCase();
return (item.item_code || "").toLowerCase().includes(kw) || (item.item_name || "").toLowerCase().includes(kw);
});
const addItem = (stock: StockItem) => {
if (selectedItems.find((s) => s.stock.id === stock.id)) return;
setSelectedItems((prev) => [...prev, { stock, adjustQty: "", type: "confirm" }]);
};
const removeItem = (id: string) => {
setSelectedItems((prev) => prev.filter((s) => s.stock.id !== id));
};
const confirmCount = selectedItems.filter((s) => s.type === "confirm").length;
const adjustCount = selectedItems.filter((s) => s.type === "adjust").length;
return (
<PopShell title="재고조정" showBanner={false}>
<div className="min-h-screen bg-gray-50 flex flex-col">
{/* Header */}
<div className="bg-white border-b border-gray-200 px-5 pt-5 pb-4 shrink-0">
<div className="flex items-center gap-3">
<button onClick={() => router.back()} className="w-9 h-9 rounded-xl bg-gray-100 flex items-center justify-center">
<svg className="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
</button>
<h1 className="text-xl font-bold text-gray-900">📦 </h1>
</div>
</div>
{/* Main — 2단 레이아웃 */}
<div className="flex-1 flex flex-col lg:flex-row overflow-hidden">
{/* 왼쪽: 제품 선택 */}
<div className="flex-1 flex flex-col border-r border-gray-200 bg-white">
<div className="px-4 pt-4 pb-2 border-b border-gray-100">
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-bold text-gray-700">📦 </h2>
<button className="px-3 py-1.5 text-xs rounded-lg border border-gray-200 text-gray-500">📋 </button>
</div>
{/* 창고 탭 */}
<div className="flex gap-2 mb-3 overflow-x-auto pb-1">
<button
onClick={() => setSelectedWarehouse("all")}
className={`px-4 py-2 rounded-full text-xs font-semibold whitespace-nowrap transition-all ${
selectedWarehouse === "all" ? "bg-amber-500 text-white" : "bg-gray-100 text-gray-600"
}`}
>
</button>
{warehouses.map((wh) => (
<button
key={wh.warehouse_code}
onClick={() => setSelectedWarehouse(wh.warehouse_code)}
className={`px-4 py-2 rounded-full text-xs font-semibold whitespace-nowrap transition-all ${
selectedWarehouse === wh.warehouse_code ? "bg-amber-500 text-white" : "bg-gray-100 text-gray-600"
}`}
>
{wh.warehouse_name}
</button>
))}
</div>
{/* 검색 */}
<div className="flex gap-2">
<input
type="text"
placeholder="품목명 / 코드 검색"
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
className="flex-1 px-4 py-2.5 rounded-xl border border-gray-200 text-sm focus:outline-none focus:border-amber-400"
/>
<button className="px-4 py-2.5 rounded-xl bg-amber-500 text-white text-sm font-bold">🔍</button>
</div>
</div>
{/* 품목 리스트 */}
<div className="flex-1 overflow-y-auto px-4 py-2">
{loading ? (
<div className="flex justify-center py-16">
<div className="w-8 h-8 border-4 border-amber-500 border-t-transparent rounded-full animate-spin" />
</div>
) : filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-gray-400">
<span className="text-4xl mb-3">📦</span>
<p className="text-sm"> </p>
</div>
) : (
<div className="divide-y divide-gray-100">
{filtered.map((item) => (
<div key={item.id} className="flex items-center justify-between py-3">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-amber-50 flex items-center justify-center text-sm">📦</div>
<div>
<p className="text-sm font-bold text-gray-900">
{item.item_name || item.item_code}
{item.item_name && <span className="text-gray-400 font-normal"> ({item.item_code})</span>}
</p>
<p className="text-xs text-gray-400">{item.warehouse_code}{item.location_code ? ` · ${item.location_code}` : ""}</p>
</div>
</div>
<div className="flex items-center gap-3">
<div className="text-right">
<p className="text-lg font-bold text-gray-900">{parseFloat(item.current_qty || "0").toLocaleString()}</p>
<p className="text-[10px] text-gray-400">{item.location_code || item.warehouse_code}</p>
</div>
<button
onClick={() => addItem(item)}
className={`w-8 h-8 rounded-full flex items-center justify-center text-white text-lg font-bold transition-all ${
selectedItems.find((s) => s.stock.id === item.id)
? "bg-gray-300"
: "bg-amber-500 active:bg-amber-600"
}`}
>
+
</button>
</div>
</div>
))}
</div>
)}
</div>
</div>
{/* 오른쪽: 처리 결과 */}
<div className="w-full lg:w-[400px] bg-gray-50 flex flex-col">
<div className="px-4 pt-4 pb-2 border-b border-gray-200 bg-white flex items-center justify-between">
<h2 className="text-sm font-bold text-gray-700">📋 </h2>
<span className="text-xs font-bold text-amber-600 bg-amber-50 px-2.5 py-1 rounded-full">
{selectedItems.length}
</span>
</div>
<div className="flex-1 overflow-y-auto px-4 py-3">
{selectedItems.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-gray-400">
<span className="text-4xl mb-3">📋</span>
<p className="text-sm"> / </p>
</div>
) : (
<div className="space-y-3">
{selectedItems.map((sel) => (
<div key={sel.stock.id} className="bg-white rounded-xl border border-gray-200 p-3">
<div className="flex items-center justify-between mb-2">
<p className="text-sm font-bold text-gray-900">{sel.stock.item_name || sel.stock.item_code}</p>
<button onClick={() => removeItem(sel.stock.id)} className="text-xs text-red-500 font-semibold"></button>
</div>
<p className="text-xs text-gray-400 mb-2"> : {parseFloat(sel.stock.current_qty || "0").toLocaleString()}</p>
<input
type="number"
placeholder="조정 수량"
className="w-full px-3 py-2 rounded-lg border border-gray-200 text-sm focus:outline-none focus:border-amber-400"
/>
</div>
))}
</div>
)}
</div>
{/* Footer */}
<div className="px-4 py-3 border-t border-gray-200 bg-white flex items-center justify-between">
<div className="flex gap-3 text-xs">
<span className="text-blue-600 font-semibold"> {confirmCount}</span>
<span className="text-amber-600 font-semibold"> {adjustCount}</span>
</div>
<div className="flex gap-2">
<button
onClick={() => setSelectedItems([])}
className="px-4 py-2.5 rounded-xl border border-gray-200 text-xs font-semibold text-gray-600"
>
</button>
<button className="px-4 py-2.5 rounded-xl bg-amber-500 text-white text-xs font-bold active:bg-amber-600">
</button>
</div>
</div>
</div>
</div>
</div>
</PopShell>
);
}
@@ -1208,34 +1208,45 @@ export function ProcessWork({ processId }: ProcessWorkProps) {
behavior: "smooth",
});
}}
className={`w-full flex items-center gap-2 px-3 py-2 text-left transition-all ${
className={`w-full flex items-center gap-3 mx-2 mb-1.5 px-3 py-3 rounded-xl text-left transition-all ${
isSelected
? "border-l-[3px] border-l-gray-900 bg-white"
: "border-l-[3px] border-l-transparent hover:bg-gray-50"
? "bg-blue-50 border-2 border-blue-400 shadow-sm"
: isDone
? "bg-green-50/50 border border-green-200"
: "bg-white border border-gray-200 hover:border-blue-300 hover:shadow-sm"
}`}
style={{ width: "calc(100% - 16px)" }}
>
<span
className={`w-2 h-2 rounded-full shrink-0 ${
className={`w-3 h-3 rounded-full shrink-0 ${
isDone
? "bg-green-500"
: g.timerStarted
? "bg-blue-500 animate-pulse"
: "bg-gray-300"
: isSelected
? "bg-blue-500"
: "bg-gray-300"
}`}
/>
<span
className={`text-sm truncate flex-1 ${
className={`text-sm flex-1 ${
isSelected
? "font-semibold text-blue-700"
? "font-bold text-blue-800"
: isDone
? "text-gray-400"
: "text-gray-700"
? "text-green-700 font-medium"
: "text-gray-700 font-medium"
}`}
>
{g.title}
</span>
<span
className={`text-[13px] shrink-0 ${isDone ? "text-green-500" : "text-gray-300"}`}
className={`text-xs font-bold px-2 py-0.5 rounded-full ${
isDone
? "bg-green-100 text-green-600"
: isSelected
? "bg-blue-100 text-blue-600"
: "bg-gray-100 text-gray-400"
}`}
>
{g.completed}/{g.total}
</span>
@@ -1259,41 +1270,22 @@ export function ProcessWork({ processId }: ProcessWorkProps) {
behavior: "smooth",
});
}}
className={`w-full flex items-center gap-2 px-3 py-2.5 mb-2 text-left transition-all ${
className={`w-full flex items-center gap-3 mx-2 mb-1.5 px-3 py-3 rounded-xl text-left transition-all ${
activeSection === "material"
? "border-l-[3px] border-l-gray-900 bg-white"
: "border-l-[3px] border-l-transparent hover:bg-gray-50"
? "bg-blue-50 border-2 border-blue-400 shadow-sm"
: "bg-white border border-gray-200 hover:border-blue-300 hover:shadow-sm"
}`}
style={{ width: "calc(100% - 16px)" }}
>
<span className="text-sm">📦</span>
<span className="text-base">📦</span>
<span
className={`text-sm ${activeSection === "material" ? "font-semibold text-blue-700" : "text-gray-600 font-medium"}`}
className={`text-sm font-medium ${activeSection === "material" ? "font-bold text-blue-800" : "text-gray-700"}`}
>
</span>
</button>
)}
<div className="flex items-center gap-2 px-3 mb-1.5">
<div className="w-4 h-4 rounded-full bg-amber-500 flex items-center justify-center">
<svg
className="w-2.5 h-2.5 text-white"
fill="none"
stroke="currentColor"
strokeWidth={2.5}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.8 0A2.251 2.251 0 0113.5 2.25H15a2.25 2.25 0 012.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V19.5a2.25 2.25 0 002.25 2.25h.75"
/>
</svg>
</div>
<span className="text-base font-bold text-gray-400 uppercase tracking-wider">
</span>
</div>
<button
onClick={() => {
setActiveSection("result");
@@ -1302,27 +1294,16 @@ export function ProcessWork({ processId }: ProcessWorkProps) {
behavior: "smooth",
});
}}
className={`w-full flex items-center gap-2 px-3 py-2 text-left transition-all ${
className={`w-full flex items-center gap-3 mx-2 mb-1.5 px-3 py-3 rounded-xl text-left transition-all ${
activeSection === "result"
? "border-l-[3px] border-l-gray-900 bg-white"
: "border-l-[3px] border-l-transparent hover:bg-gray-50"
? "bg-blue-50 border-2 border-blue-400 shadow-sm"
: "bg-white border border-gray-200 hover:border-blue-300 hover:shadow-sm"
}`}
style={{ width: "calc(100% - 16px)" }}
>
<svg
className={`w-3.5 h-3.5 ${activeSection === "result" ? "text-blue-500" : "text-amber-500"}`}
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 9v6m3-3H9m12 0a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span className="text-base">📋</span>
<span
className={`text-sm ${activeSection === "result" ? "font-semibold text-blue-700" : "text-amber-700 font-medium"}`}
className={`text-sm font-medium ${activeSection === "result" ? "font-bold text-blue-800" : "text-gray-700"}`}
>
</span>
@@ -1333,28 +1314,6 @@ export function ProcessWork({ processId }: ProcessWorkProps) {
{/* Inventory section link */}
{isLastProcess && (
<div>
<div className="flex items-center gap-2 px-3 mb-1.5">
<div
className={`w-4 h-4 rounded-full flex items-center justify-center ${inboundDone ? "bg-green-500" : "bg-amber-500"}`}
>
<svg
className="w-2.5 h-2.5 text-white"
fill="none"
stroke="currentColor"
strokeWidth={2.5}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5M10 11.25h4M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z"
/>
</svg>
</div>
<span className="text-base font-bold text-gray-400 uppercase tracking-wider">
</span>
</div>
<button
onClick={() => {
setActiveSection("inventory");
@@ -1363,45 +1322,27 @@ export function ProcessWork({ processId }: ProcessWorkProps) {
behavior: "smooth",
});
}}
className={`w-full flex items-center gap-2 px-3 py-2 text-left transition-all ${
className={`w-full flex items-center gap-3 mx-2 mb-1.5 px-3 py-3 rounded-xl text-left transition-all ${
activeSection === "inventory"
? "border-l-[3px] border-l-gray-900 bg-white"
: "border-l-[3px] border-l-transparent hover:bg-gray-50"
? "bg-blue-50 border-2 border-blue-400 shadow-sm"
: inboundDone
? "bg-green-50 border border-green-300"
: "bg-white border border-gray-200 hover:border-blue-300 hover:shadow-sm"
}`}
style={{ width: "calc(100% - 16px)" }}
>
<svg
className={`w-3.5 h-3.5 ${activeSection === "inventory" ? "text-blue-500" : inboundDone ? "text-green-500" : "text-amber-500"}`}
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5M10 11.25h4M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z"
/>
</svg>
<span className="text-base">{inboundDone ? "✅" : "🏭"}</span>
<span
className={`text-sm ${activeSection === "inventory" ? "font-semibold text-blue-700" : inboundDone ? "text-green-700 font-medium" : "text-amber-700 font-medium"}`}
className={`text-sm font-medium ${
activeSection === "inventory"
? "font-bold text-blue-800"
: inboundDone
? "text-green-700 font-bold"
: "text-gray-700"
}`}
>
{inboundDone ? " 완료" : ""}
</span>
{inboundDone && (
<svg
className="w-3.5 h-3.5 ml-auto text-green-500"
fill="none"
stroke="currentColor"
strokeWidth={2.5}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M4.5 12.75l6 6 9-13.5"
/>
</svg>
)}
</button>
</div>
)}
@@ -2767,18 +2708,16 @@ function MaterialQtyInputRow({
}) {
const [open, setOpen] = useState(false);
return (
<div className="flex items-center gap-2">
<div className="flex items-center shrink-0">
<button
type="button"
onClick={() => setOpen(true)}
className="flex-1 px-4 py-3 rounded-xl border-2 border-gray-200 text-base font-bold text-left text-gray-900 hover:border-blue-400 active:scale-[0.98] transition-all bg-white"
style={{ minHeight: 56 }}
className="px-8 py-4 rounded-xl border-2 border-blue-300 text-xl font-bold text-blue-700 hover:border-blue-500 active:scale-[0.96] transition-all bg-blue-50 min-w-[120px] text-center"
>
{value || (
<span className="text-gray-300 font-normal"> </span>
<span className="text-blue-300 font-semibold"></span>
)}
</button>
<span className="text-sm text-gray-500 shrink-0">{material.unit}</span>
<MaterialQtyKeypad
open={open}
onClose={() => setOpen(false)}
@@ -2903,50 +2842,44 @@ function MaterialInputSection({ processId }: { processId: string }) {
}
return (
<div className="space-y-4">
{/* BOM 기준 자재 목록 */}
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-4">
<h3 className="text-base font-bold text-gray-900 mb-3">
BOM
</h3>
<div className="space-y-3">
{/* BOM 기준 자재 목록 — 컴팩트 */}
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-3">
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-bold text-gray-900">BOM </h3>
<span className="text-xs text-gray-400">{bomMaterials.length}</span>
</div>
{bomMaterials.length === 0 ? (
<p className="text-sm text-gray-400 py-4 text-center">
BOM
</p>
) : (
<div className="space-y-3">
{bomMaterials.map((m) => (
<div
key={m.id}
className="p-3 rounded-xl border border-gray-200 bg-gray-50"
>
<div className="flex items-center justify-between mb-2">
<div>
<p className="text-base font-semibold text-gray-900">
{m.child_item_name}
</p>
<p className="text-sm text-gray-400">{m.child_item_code}</p>
</div>
<div className="text-right">
<p className="text-sm text-gray-500"></p>
<p className="text-lg font-bold text-blue-600">
{m.required_qty} {m.unit}
</p>
<div>
<div className="divide-y divide-gray-200">
{bomMaterials.map((m) => (
<div key={m.id} className="flex items-center gap-2 py-3">
{/* 자재명(코드) + 소요량 */}
<div className="flex-1 min-w-0">
<span className="text-base font-bold text-gray-900">{m.child_item_name}</span>
<span className="text-sm text-gray-400 ml-1">({m.child_item_code})</span>
<span className="text-base font-bold text-blue-600 ml-3"> {m.required_qty}</span>
</div>
{/* 입력 버튼 + 단위 */}
<MaterialQtyInputRow
material={m}
value={inputValues[m.id] || ""}
onChange={(v) =>
setInputValues((prev) => ({ ...prev, [m.id]: v }))
}
/>
<span className="text-base font-semibold text-gray-500 shrink-0 w-8">{m.unit}</span>
</div>
<MaterialQtyInputRow
material={m}
value={inputValues[m.id] || ""}
onChange={(v) =>
setInputValues((prev) => ({ ...prev, [m.id]: v }))
}
/>
</div>
))}
))}
</div>
<button
onClick={handleSave}
disabled={saving}
className="w-full py-4 rounded-xl text-base font-bold text-white active:scale-[0.98] transition-all disabled:opacity-40"
className="w-full mt-4 py-4 rounded-xl text-lg font-bold text-white active:scale-[0.98] transition-all disabled:opacity-40"
style={{
background: "linear-gradient(135deg, #3b82f6, #1d4ed8)",
}}
@@ -2077,7 +2077,7 @@ export function WorkOrderList() {
(p) =>
p.parent_process_id &&
p.accepted_by === currentUserId &&
(p.status === "in_progress" || p.status === "completed"),
p.status === "in_progress",
)}
instructionMap={instructionMap}
onSwitch={(id) => setWorkModalProcessId(id)}