feat: Enhance inbound-outbound and material status pages with remark parsing and category mapping
- Implemented a `parseRemark` function to convert JSON remarks into human-readable text, improving clarity in the inbound-outbound page. - Updated the category filtering logic to utilize parsed remarks, enhancing data representation. - Added unit label mapping for better display of item units in the inbound-outbound page. - Enhanced the material status page with a status mapping feature, allowing for dynamic styling based on status labels. - These changes aim to improve user experience by providing clearer information and better data management across multiple company implementations.
This commit is contained in:
@@ -38,6 +38,30 @@ const fmtDate = (v: any) => {
|
||||
return m ? `${m[1]}-${m[2]}-${m[3]}` : s.substring(0, 10);
|
||||
};
|
||||
|
||||
/** remark가 JSON이면 사람이 읽을 수 있는 텍스트로 변환 */
|
||||
const parseRemark = (remark: string | null | undefined): string => {
|
||||
if (!remark) return "";
|
||||
const trimmed = remark.trim();
|
||||
if (!trimmed.startsWith("{")) return trimmed;
|
||||
try {
|
||||
const d = JSON.parse(trimmed);
|
||||
switch (d.type) {
|
||||
case "move":
|
||||
return `창고이동 (${d.from_warehouse} → ${d.to_warehouse})`;
|
||||
case "adjust":
|
||||
return `재고조정 (${d.reason || "사유 없음"}, ${d.system_qty}→${d.actual_qty}, 차이:${d.diff >= 0 ? "+" : ""}${d.diff})`;
|
||||
case "confirm":
|
||||
return `재고확인 (${d.reason || "이상없음"})`;
|
||||
case "process_inbound":
|
||||
return "공정입고";
|
||||
default:
|
||||
return d.reason || d.memo || trimmed;
|
||||
}
|
||||
} catch {
|
||||
return trimmed;
|
||||
}
|
||||
};
|
||||
|
||||
export default function InboundOutboundPage() {
|
||||
const { user } = useAuth();
|
||||
|
||||
@@ -62,7 +86,6 @@ export default function InboundOutboundPage() {
|
||||
try {
|
||||
const filters: any[] = [];
|
||||
if (typeFilter !== "all") filters.push({ columnName: "transaction_type", operator: "equals", value: typeFilter });
|
||||
if (categoryFilter !== "all") filters.push({ columnName: "remark", operator: "equals", value: categoryFilter });
|
||||
|
||||
for (const f of searchFilters) {
|
||||
if (f.value) filters.push({ columnName: f.columnName, operator: f.operator, value: f.value });
|
||||
@@ -81,6 +104,21 @@ export default function InboundOutboundPage() {
|
||||
const itemCodes = [...new Set(rows.map((r: any) => r.item_code).filter(Boolean))];
|
||||
if (itemCodes.length > 0) {
|
||||
try {
|
||||
// 단위 카테고리 코드→라벨 매핑 로드
|
||||
let unitLabelMap: Record<string, string> = {};
|
||||
try {
|
||||
const catRes = await apiClient.get("/table-categories/item_info/unit/values");
|
||||
if (catRes.data?.success && catRes.data.data?.length > 0) {
|
||||
const flatten = (vals: any[]) => {
|
||||
for (const v of vals) {
|
||||
unitLabelMap[v.valueCode] = v.valueLabel;
|
||||
if (v.children?.length) flatten(v.children);
|
||||
}
|
||||
};
|
||||
flatten(catRes.data.data);
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
|
||||
const itemRes = await apiClient.post(`/table-management/tables/item_info/data`, {
|
||||
page: 1, size: itemCodes.length + 10,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: itemCodes }] },
|
||||
@@ -89,7 +127,8 @@ export default function InboundOutboundPage() {
|
||||
const items = itemRes.data?.data?.data || itemRes.data?.data?.rows || [];
|
||||
const map: Record<string, { item_name: string; unit: string }> = {};
|
||||
for (const i of items) {
|
||||
if (!map[i.item_number]) map[i.item_number] = { item_name: i.item_name || "", unit: i.unit || "" };
|
||||
const rawUnit = i.unit || "";
|
||||
if (!map[i.item_number]) map[i.item_number] = { item_name: i.item_name || "", unit: unitLabelMap[rawUnit] || rawUnit };
|
||||
}
|
||||
setItemMap(map);
|
||||
} catch { /* skip */ }
|
||||
@@ -124,7 +163,7 @@ export default function InboundOutboundPage() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [searchFilters, typeFilter, categoryFilter]);
|
||||
}, [searchFilters, typeFilter]);
|
||||
|
||||
useEffect(() => { fetchData(); }, [fetchData]);
|
||||
|
||||
@@ -132,20 +171,26 @@ export default function InboundOutboundPage() {
|
||||
|
||||
const categoryOptions = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
data.forEach((r) => { if (r.remark) set.add(r.remark); });
|
||||
data.forEach((r) => { if (r.remark) set.add(parseRemark(r.remark)); });
|
||||
return Array.from(set).sort();
|
||||
}, [data]);
|
||||
|
||||
// ════════ 그룹핑 ════════
|
||||
|
||||
// 카테고리 필터 (클라이언트)
|
||||
const filteredData = useMemo(() => {
|
||||
if (categoryFilter === "all") return data;
|
||||
return data.filter((r) => parseRemark(r.remark) === categoryFilter);
|
||||
}, [data, categoryFilter]);
|
||||
|
||||
const groupedData = useMemo(() => {
|
||||
if (groupBy === "none") return data;
|
||||
if (groupBy === "none") return filteredData;
|
||||
const groups = new Map<string, any[]>();
|
||||
for (const row of data) {
|
||||
for (const row of filteredData) {
|
||||
let key: string;
|
||||
switch (groupBy) {
|
||||
case "transaction_type": key = row.transaction_type || "미지정"; break;
|
||||
case "remark": key = row.remark || "미지정"; break;
|
||||
case "remark": key = parseRemark(row.remark) || "미지정"; break;
|
||||
case "warehouse_code": key = warehouseMap[row.warehouse_code] || row.warehouse_code || "미지정"; break;
|
||||
case "item_code": key = `${row.item_code} ${itemMap[row.item_code]?.item_name || ""}`.trim() || "미지정"; break;
|
||||
default: key = row[groupBy] || "미지정";
|
||||
@@ -191,7 +236,7 @@ export default function InboundOutboundPage() {
|
||||
const rows = data.map((r, i) => ({
|
||||
No: i + 1,
|
||||
입출고구분: r.transaction_type || "",
|
||||
카테고리: r.remark || "",
|
||||
카테고리: parseRemark(r.remark),
|
||||
처리일자: fmtDate(r.transaction_date),
|
||||
창고: warehouseMap[r.warehouse_code] || r.warehouse_code || "",
|
||||
위치: r.location_code || "",
|
||||
@@ -252,7 +297,7 @@ export default function InboundOutboundPage() {
|
||||
<div className="flex items-center gap-2">
|
||||
<Package className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm font-semibold">입출고 내역</span>
|
||||
<Badge variant="secondary" className="font-mono text-xs">{data.length}건</Badge>
|
||||
<Badge variant="secondary" className="font-mono text-xs">{filteredData.length}건</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={groupBy} onValueChange={(v) => { setGroupBy(v); setExpandedGroups(new Set()); }}>
|
||||
@@ -353,7 +398,7 @@ export default function InboundOutboundPage() {
|
||||
{row.transaction_type}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-[12px]">{row.remark || "-"}</TableCell>
|
||||
<TableCell className="text-center text-[12px]">{parseRemark(row.remark) || "-"}</TableCell>
|
||||
<TableCell className="text-center text-[12px]">{fmtDate(row.transaction_date)}</TableCell>
|
||||
<TableCell className="text-[12px]">{warehouseMap[row.warehouse_code] || row.warehouse_code || "-"}</TableCell>
|
||||
<TableCell className="text-center text-[12px]">{row.location_code || "-"}</TableCell>
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
} from "@/lib/api/materialStatus";
|
||||
import { useTableSettings } from "@/hooks/useTableSettings";
|
||||
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
|
||||
const GRID_COLUMNS = [
|
||||
{ key: "plan_no", label: "계획번호" },
|
||||
@@ -60,26 +61,31 @@ const formatDate = (date: Date) => {
|
||||
return `${y}-${m}-${d}`;
|
||||
};
|
||||
|
||||
const getStatusLabel = (status: string) => {
|
||||
const map: Record<string, string> = {
|
||||
planned: "계획",
|
||||
in_progress: "진행중",
|
||||
completed: "완료",
|
||||
pending: "대기",
|
||||
cancelled: "취소",
|
||||
};
|
||||
return map[status] || status;
|
||||
const FALLBACK_STATUS_MAP: Record<string, string> = {
|
||||
planned: "계획",
|
||||
in_progress: "진행중",
|
||||
completed: "완료",
|
||||
pending: "대기",
|
||||
cancelled: "취소",
|
||||
};
|
||||
|
||||
const getStatusStyle = (status: string) => {
|
||||
const map: Record<string, string> = {
|
||||
planned: "bg-secondary text-secondary-foreground border-border",
|
||||
pending: "bg-secondary text-secondary-foreground border-border",
|
||||
in_progress: "bg-primary/10 text-primary border-primary/20",
|
||||
completed: "bg-accent text-accent-foreground border-accent/50",
|
||||
cancelled: "bg-muted text-muted-foreground border-border",
|
||||
};
|
||||
return map[status] || "bg-muted text-muted-foreground border-border";
|
||||
const STATUS_STYLE_MAP: Record<string, string> = {
|
||||
planned: "bg-secondary text-secondary-foreground border-border",
|
||||
pending: "bg-secondary text-secondary-foreground border-border",
|
||||
in_progress: "bg-primary/10 text-primary border-primary/20",
|
||||
completed: "bg-accent text-accent-foreground border-accent/50",
|
||||
cancelled: "bg-muted text-muted-foreground border-border",
|
||||
};
|
||||
|
||||
// 카테고리 라벨 기반으로 스타일 매칭
|
||||
const LABEL_STYLE_MAP: Record<string, string> = {
|
||||
"일반": "bg-secondary text-secondary-foreground border-border",
|
||||
"긴급": "bg-destructive/10 text-destructive border-destructive/20",
|
||||
"계획": "bg-secondary text-secondary-foreground border-border",
|
||||
"대기": "bg-secondary text-secondary-foreground border-border",
|
||||
"진행중": "bg-primary/10 text-primary border-primary/20",
|
||||
"완료": "bg-accent text-accent-foreground border-accent/50",
|
||||
"취소": "bg-muted text-muted-foreground border-border",
|
||||
};
|
||||
|
||||
export default function MaterialStatusPage() {
|
||||
@@ -93,6 +99,32 @@ export default function MaterialStatusPage() {
|
||||
const [searchItemCode, setSearchItemCode] = useState("");
|
||||
const [searchItemName, setSearchItemName] = useState("");
|
||||
|
||||
// 카테고리 코드→라벨 매핑
|
||||
const [statusMap, setStatusMap] = useState<Record<string, string>>({});
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const res = await apiClient.get("/table-categories/work_instruction/status/values");
|
||||
if (res.data?.success && res.data.data?.length > 0) {
|
||||
const map: Record<string, string> = {};
|
||||
const flatten = (vals: any[]) => {
|
||||
for (const v of vals) {
|
||||
map[v.valueCode] = v.valueLabel;
|
||||
if (v.children?.length) flatten(v.children);
|
||||
}
|
||||
};
|
||||
flatten(res.data.data);
|
||||
setStatusMap(map);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const getStatusLabel = useCallback((status: string) => {
|
||||
return statusMap[status] || FALLBACK_STATUS_MAP[status] || status;
|
||||
}, [statusMap]);
|
||||
|
||||
const [workOrders, setWorkOrders] = useState<WorkOrder[]>([]);
|
||||
const [workOrdersLoading, setWorkOrdersLoading] = useState(false);
|
||||
const [checkedWoIds, setCheckedWoIds] = useState<string[]>([]);
|
||||
@@ -369,7 +401,7 @@ export default function MaterialStatusPage() {
|
||||
<span
|
||||
className={cn(
|
||||
"ml-auto rounded-md border px-2 py-0.5 text-[11px] font-semibold",
|
||||
getStatusStyle(wo.status)
|
||||
STATUS_STYLE_MAP[wo.status] || LABEL_STYLE_MAP[getStatusLabel(wo.status)] || "bg-muted text-muted-foreground border-border"
|
||||
)}
|
||||
>
|
||||
{getStatusLabel(wo.status)}
|
||||
|
||||
@@ -324,16 +324,24 @@ export default function OutboundPage() {
|
||||
return result;
|
||||
};
|
||||
const map: Record<string, Record<string, string>> = {};
|
||||
Promise.all(
|
||||
["material", "unit"].map(async (col) => {
|
||||
Promise.all([
|
||||
...["material", "unit"].map(async (col) => {
|
||||
try {
|
||||
const res = await apiClient.get(`/table-categories/item_info/${col}/values?filterCompanyCode=COMPANY_10`);
|
||||
const res = await apiClient.get(`/table-categories/item_info/${col}/values?filterCompanyCode=COMPANY_7`);
|
||||
const items = flatten(res.data?.data || []);
|
||||
map[col] = {};
|
||||
for (const item of items) map[col][item.code] = item.label;
|
||||
} catch { /* skip */ }
|
||||
})
|
||||
).then(() => setCatMap(map));
|
||||
}),
|
||||
(async () => {
|
||||
try {
|
||||
const res = await apiClient.get(`/table-categories/outbound_mng/outbound_type/values`);
|
||||
const items = flatten(res.data?.data || []);
|
||||
map["outbound_type"] = {};
|
||||
for (const item of items) map["outbound_type"][item.code] = item.label;
|
||||
} catch { /* skip */ }
|
||||
})(),
|
||||
]).then(() => setCatMap(map));
|
||||
}, []);
|
||||
const resolveCat = useCallback((col: string, code: string) => {
|
||||
if (!code) return "";
|
||||
@@ -372,7 +380,7 @@ export default function OutboundPage() {
|
||||
}
|
||||
const res = await getOutboundList(params);
|
||||
if (res.success) setData(res.data);
|
||||
} catch (err: any) {
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -389,7 +397,7 @@ export default function OutboundPage() {
|
||||
try {
|
||||
const res = await getOutboundWarehouses();
|
||||
if (res.success) setWarehouses(res.data);
|
||||
} catch (err: any) {
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
})();
|
||||
@@ -565,7 +573,7 @@ export default function OutboundPage() {
|
||||
const res = await getItemSources(keyword || undefined);
|
||||
if (res.success) setItems(res.data);
|
||||
}
|
||||
} catch (err: any) {
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setSourceLoading(false);
|
||||
@@ -597,7 +605,7 @@ export default function OutboundPage() {
|
||||
loadSourceData(defaultType),
|
||||
]);
|
||||
if (numRes.success) setModalOutboundNo(numRes.data);
|
||||
} catch (err: any) {
|
||||
} catch {
|
||||
setModalOutboundNo("");
|
||||
}
|
||||
};
|
||||
@@ -901,7 +909,8 @@ export default function OutboundPage() {
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
const msg = err?.response?.data?.message || (editMode ? "수정에 실패했어요" : "출고 등록 중 오류가 발생했습니다."); toast.error(msg);
|
||||
const msg = err?.response?.data?.message || (editMode ? "수정에 실패했어요" : "출고 등록 중 오류가 발생했습니다.");
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -1114,7 +1123,7 @@ export default function OutboundPage() {
|
||||
case "outbound_type": return (
|
||||
<TableCell key={col.key}>
|
||||
<Badge variant="outline" className={cn("text-[11px]", getTypeColor(master.outbound_type))}>
|
||||
{master.outbound_type || "-"}
|
||||
{resolveCat("outbound_type", master.outbound_type) || "-"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
);
|
||||
@@ -1532,7 +1541,7 @@ export default function OutboundPage() {
|
||||
<TableCell className="p-2 text-center">{idx + 1}</TableCell>
|
||||
<TableCell className="p-2">
|
||||
<Badge variant="outline" className={cn("text-[10px]", getTypeColor(item.outbound_type))}>
|
||||
{item.outbound_type || "-"}
|
||||
{resolveCat("outbound_type", item.outbound_type) || "-"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[180px] p-2">
|
||||
|
||||
@@ -38,6 +38,30 @@ const fmtDate = (v: any) => {
|
||||
return m ? `${m[1]}-${m[2]}-${m[3]}` : s.substring(0, 10);
|
||||
};
|
||||
|
||||
/** remark가 JSON이면 사람이 읽을 수 있는 텍스트로 변환 */
|
||||
const parseRemark = (remark: string | null | undefined): string => {
|
||||
if (!remark) return "";
|
||||
const trimmed = remark.trim();
|
||||
if (!trimmed.startsWith("{")) return trimmed;
|
||||
try {
|
||||
const d = JSON.parse(trimmed);
|
||||
switch (d.type) {
|
||||
case "move":
|
||||
return `창고이동 (${d.from_warehouse} → ${d.to_warehouse})`;
|
||||
case "adjust":
|
||||
return `재고조정 (${d.reason || "사유 없음"}, ${d.system_qty}→${d.actual_qty}, 차이:${d.diff >= 0 ? "+" : ""}${d.diff})`;
|
||||
case "confirm":
|
||||
return `재고확인 (${d.reason || "이상없음"})`;
|
||||
case "process_inbound":
|
||||
return "공정입고";
|
||||
default:
|
||||
return d.reason || d.memo || trimmed;
|
||||
}
|
||||
} catch {
|
||||
return trimmed;
|
||||
}
|
||||
};
|
||||
|
||||
export default function InboundOutboundPage() {
|
||||
const { user } = useAuth();
|
||||
|
||||
@@ -62,7 +86,6 @@ export default function InboundOutboundPage() {
|
||||
try {
|
||||
const filters: any[] = [];
|
||||
if (typeFilter !== "all") filters.push({ columnName: "transaction_type", operator: "equals", value: typeFilter });
|
||||
if (categoryFilter !== "all") filters.push({ columnName: "remark", operator: "equals", value: categoryFilter });
|
||||
|
||||
for (const f of searchFilters) {
|
||||
if (f.value) filters.push({ columnName: f.columnName, operator: f.operator, value: f.value });
|
||||
@@ -81,6 +104,21 @@ export default function InboundOutboundPage() {
|
||||
const itemCodes = [...new Set(rows.map((r: any) => r.item_code).filter(Boolean))];
|
||||
if (itemCodes.length > 0) {
|
||||
try {
|
||||
// 단위 카테고리 코드→라벨 매핑 로드
|
||||
let unitLabelMap: Record<string, string> = {};
|
||||
try {
|
||||
const catRes = await apiClient.get("/table-categories/item_info/unit/values");
|
||||
if (catRes.data?.success && catRes.data.data?.length > 0) {
|
||||
const flatten = (vals: any[]) => {
|
||||
for (const v of vals) {
|
||||
unitLabelMap[v.valueCode] = v.valueLabel;
|
||||
if (v.children?.length) flatten(v.children);
|
||||
}
|
||||
};
|
||||
flatten(catRes.data.data);
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
|
||||
const itemRes = await apiClient.post(`/table-management/tables/item_info/data`, {
|
||||
page: 1, size: itemCodes.length + 10,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: itemCodes }] },
|
||||
@@ -89,7 +127,8 @@ export default function InboundOutboundPage() {
|
||||
const items = itemRes.data?.data?.data || itemRes.data?.data?.rows || [];
|
||||
const map: Record<string, { item_name: string; unit: string }> = {};
|
||||
for (const i of items) {
|
||||
if (!map[i.item_number]) map[i.item_number] = { item_name: i.item_name || "", unit: i.unit || "" };
|
||||
const rawUnit = i.unit || "";
|
||||
if (!map[i.item_number]) map[i.item_number] = { item_name: i.item_name || "", unit: unitLabelMap[rawUnit] || rawUnit };
|
||||
}
|
||||
setItemMap(map);
|
||||
} catch { /* skip */ }
|
||||
@@ -124,7 +163,7 @@ export default function InboundOutboundPage() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [searchFilters, typeFilter, categoryFilter]);
|
||||
}, [searchFilters, typeFilter]);
|
||||
|
||||
useEffect(() => { fetchData(); }, [fetchData]);
|
||||
|
||||
@@ -132,20 +171,26 @@ export default function InboundOutboundPage() {
|
||||
|
||||
const categoryOptions = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
data.forEach((r) => { if (r.remark) set.add(r.remark); });
|
||||
data.forEach((r) => { if (r.remark) set.add(parseRemark(r.remark)); });
|
||||
return Array.from(set).sort();
|
||||
}, [data]);
|
||||
|
||||
// ════════ 그룹핑 ════════
|
||||
|
||||
// 카테고리 필터 (클라이언트)
|
||||
const filteredData = useMemo(() => {
|
||||
if (categoryFilter === "all") return data;
|
||||
return data.filter((r) => parseRemark(r.remark) === categoryFilter);
|
||||
}, [data, categoryFilter]);
|
||||
|
||||
const groupedData = useMemo(() => {
|
||||
if (groupBy === "none") return data;
|
||||
if (groupBy === "none") return filteredData;
|
||||
const groups = new Map<string, any[]>();
|
||||
for (const row of data) {
|
||||
for (const row of filteredData) {
|
||||
let key: string;
|
||||
switch (groupBy) {
|
||||
case "transaction_type": key = row.transaction_type || "미지정"; break;
|
||||
case "remark": key = row.remark || "미지정"; break;
|
||||
case "remark": key = parseRemark(row.remark) || "미지정"; break;
|
||||
case "warehouse_code": key = warehouseMap[row.warehouse_code] || row.warehouse_code || "미지정"; break;
|
||||
case "item_code": key = `${row.item_code} ${itemMap[row.item_code]?.item_name || ""}`.trim() || "미지정"; break;
|
||||
default: key = row[groupBy] || "미지정";
|
||||
@@ -191,7 +236,7 @@ export default function InboundOutboundPage() {
|
||||
const rows = data.map((r, i) => ({
|
||||
No: i + 1,
|
||||
입출고구분: r.transaction_type || "",
|
||||
카테고리: r.remark || "",
|
||||
카테고리: parseRemark(r.remark),
|
||||
처리일자: fmtDate(r.transaction_date),
|
||||
창고: warehouseMap[r.warehouse_code] || r.warehouse_code || "",
|
||||
위치: r.location_code || "",
|
||||
@@ -252,7 +297,7 @@ export default function InboundOutboundPage() {
|
||||
<div className="flex items-center gap-2">
|
||||
<Package className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm font-semibold">입출고 내역</span>
|
||||
<Badge variant="secondary" className="font-mono text-xs">{data.length}건</Badge>
|
||||
<Badge variant="secondary" className="font-mono text-xs">{filteredData.length}건</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={groupBy} onValueChange={(v) => { setGroupBy(v); setExpandedGroups(new Set()); }}>
|
||||
@@ -353,7 +398,7 @@ export default function InboundOutboundPage() {
|
||||
{row.transaction_type}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-[12px]">{row.remark || "-"}</TableCell>
|
||||
<TableCell className="text-center text-[12px]">{parseRemark(row.remark) || "-"}</TableCell>
|
||||
<TableCell className="text-center text-[12px]">{fmtDate(row.transaction_date)}</TableCell>
|
||||
<TableCell className="text-[12px]">{warehouseMap[row.warehouse_code] || row.warehouse_code || "-"}</TableCell>
|
||||
<TableCell className="text-center text-[12px]">{row.location_code || "-"}</TableCell>
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
} from "@/lib/api/materialStatus";
|
||||
import { useTableSettings } from "@/hooks/useTableSettings";
|
||||
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
|
||||
const GRID_COLUMNS = [
|
||||
{ key: "plan_no", label: "계획번호" },
|
||||
@@ -60,26 +61,31 @@ const formatDate = (date: Date) => {
|
||||
return `${y}-${m}-${d}`;
|
||||
};
|
||||
|
||||
const getStatusLabel = (status: string) => {
|
||||
const map: Record<string, string> = {
|
||||
planned: "계획",
|
||||
in_progress: "진행중",
|
||||
completed: "완료",
|
||||
pending: "대기",
|
||||
cancelled: "취소",
|
||||
};
|
||||
return map[status] || status;
|
||||
const FALLBACK_STATUS_MAP: Record<string, string> = {
|
||||
planned: "계획",
|
||||
in_progress: "진행중",
|
||||
completed: "완료",
|
||||
pending: "대기",
|
||||
cancelled: "취소",
|
||||
};
|
||||
|
||||
const getStatusStyle = (status: string) => {
|
||||
const map: Record<string, string> = {
|
||||
planned: "bg-secondary text-secondary-foreground border-border",
|
||||
pending: "bg-secondary text-secondary-foreground border-border",
|
||||
in_progress: "bg-primary/10 text-primary border-primary/20",
|
||||
completed: "bg-accent text-accent-foreground border-accent/50",
|
||||
cancelled: "bg-muted text-muted-foreground border-border",
|
||||
};
|
||||
return map[status] || "bg-muted text-muted-foreground border-border";
|
||||
const STATUS_STYLE_MAP: Record<string, string> = {
|
||||
planned: "bg-secondary text-secondary-foreground border-border",
|
||||
pending: "bg-secondary text-secondary-foreground border-border",
|
||||
in_progress: "bg-primary/10 text-primary border-primary/20",
|
||||
completed: "bg-accent text-accent-foreground border-accent/50",
|
||||
cancelled: "bg-muted text-muted-foreground border-border",
|
||||
};
|
||||
|
||||
// 카테고리 라벨 기반으로 스타일 매칭
|
||||
const LABEL_STYLE_MAP: Record<string, string> = {
|
||||
"일반": "bg-secondary text-secondary-foreground border-border",
|
||||
"긴급": "bg-destructive/10 text-destructive border-destructive/20",
|
||||
"계획": "bg-secondary text-secondary-foreground border-border",
|
||||
"대기": "bg-secondary text-secondary-foreground border-border",
|
||||
"진행중": "bg-primary/10 text-primary border-primary/20",
|
||||
"완료": "bg-accent text-accent-foreground border-accent/50",
|
||||
"취소": "bg-muted text-muted-foreground border-border",
|
||||
};
|
||||
|
||||
export default function MaterialStatusPage() {
|
||||
@@ -93,6 +99,32 @@ export default function MaterialStatusPage() {
|
||||
const [searchItemCode, setSearchItemCode] = useState("");
|
||||
const [searchItemName, setSearchItemName] = useState("");
|
||||
|
||||
// 카테고리 코드→라벨 매핑
|
||||
const [statusMap, setStatusMap] = useState<Record<string, string>>({});
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const res = await apiClient.get("/table-categories/work_instruction/status/values");
|
||||
if (res.data?.success && res.data.data?.length > 0) {
|
||||
const map: Record<string, string> = {};
|
||||
const flatten = (vals: any[]) => {
|
||||
for (const v of vals) {
|
||||
map[v.valueCode] = v.valueLabel;
|
||||
if (v.children?.length) flatten(v.children);
|
||||
}
|
||||
};
|
||||
flatten(res.data.data);
|
||||
setStatusMap(map);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const getStatusLabel = useCallback((status: string) => {
|
||||
return statusMap[status] || FALLBACK_STATUS_MAP[status] || status;
|
||||
}, [statusMap]);
|
||||
|
||||
const [workOrders, setWorkOrders] = useState<WorkOrder[]>([]);
|
||||
const [workOrdersLoading, setWorkOrdersLoading] = useState(false);
|
||||
const [checkedWoIds, setCheckedWoIds] = useState<string[]>([]);
|
||||
@@ -369,7 +401,7 @@ export default function MaterialStatusPage() {
|
||||
<span
|
||||
className={cn(
|
||||
"ml-auto rounded-md border px-2 py-0.5 text-[11px] font-semibold",
|
||||
getStatusStyle(wo.status)
|
||||
STATUS_STYLE_MAP[wo.status] || LABEL_STYLE_MAP[getStatusLabel(wo.status)] || "bg-muted text-muted-foreground border-border"
|
||||
)}
|
||||
>
|
||||
{getStatusLabel(wo.status)}
|
||||
|
||||
@@ -324,25 +324,28 @@ export default function OutboundPage() {
|
||||
return result;
|
||||
};
|
||||
const map: Record<string, Record<string, string>> = {};
|
||||
Promise.all(
|
||||
["material", "unit"].map(async (col) => {
|
||||
Promise.all([
|
||||
...["material", "unit"].map(async (col) => {
|
||||
try {
|
||||
const res = await apiClient.get(`/table-categories/item_info/${col}/values?filterCompanyCode=COMPANY_16`);
|
||||
const res = await apiClient.get(`/table-categories/item_info/${col}/values?filterCompanyCode=COMPANY_7`);
|
||||
const items = flatten(res.data?.data || []);
|
||||
map[col] = {};
|
||||
for (const item of items) map[col][item.code] = item.label;
|
||||
} catch (e: any) { console.error("[outbound] cat load error:", col, e?.message, e?.response?.status, e?.response?.data); }
|
||||
})
|
||||
).then(() => {
|
||||
console.log("[outbound] catMap loaded:", JSON.stringify(map).slice(0, 200));
|
||||
setCatMap(map);
|
||||
});
|
||||
} catch { /* skip */ }
|
||||
}),
|
||||
(async () => {
|
||||
try {
|
||||
const res = await apiClient.get(`/table-categories/outbound_mng/outbound_type/values`);
|
||||
const items = flatten(res.data?.data || []);
|
||||
map["outbound_type"] = {};
|
||||
for (const item of items) map["outbound_type"][item.code] = item.label;
|
||||
} catch { /* skip */ }
|
||||
})(),
|
||||
]).then(() => setCatMap(map));
|
||||
}, []);
|
||||
const resolveCat = useCallback((col: string, code: string) => {
|
||||
if (!code) return "";
|
||||
const result = catMap[col]?.[code] || code;
|
||||
if (code.startsWith("CAT_")) console.log("[outbound] resolveCat:", col, code, "->", result, "catMap keys:", Object.keys(catMap));
|
||||
return result;
|
||||
return catMap[col]?.[code] || code;
|
||||
}, [catMap]);
|
||||
|
||||
// 소스 데이터
|
||||
@@ -377,7 +380,7 @@ export default function OutboundPage() {
|
||||
}
|
||||
const res = await getOutboundList(params);
|
||||
if (res.success) setData(res.data);
|
||||
} catch (err: any) {
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -394,7 +397,7 @@ export default function OutboundPage() {
|
||||
try {
|
||||
const res = await getOutboundWarehouses();
|
||||
if (res.success) setWarehouses(res.data);
|
||||
} catch (err: any) {
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
})();
|
||||
@@ -570,7 +573,7 @@ export default function OutboundPage() {
|
||||
const res = await getItemSources(keyword || undefined);
|
||||
if (res.success) setItems(res.data);
|
||||
}
|
||||
} catch (err: any) {
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setSourceLoading(false);
|
||||
@@ -602,7 +605,7 @@ export default function OutboundPage() {
|
||||
loadSourceData(defaultType),
|
||||
]);
|
||||
if (numRes.success) setModalOutboundNo(numRes.data);
|
||||
} catch (err: any) {
|
||||
} catch {
|
||||
setModalOutboundNo("");
|
||||
}
|
||||
};
|
||||
@@ -906,7 +909,8 @@ export default function OutboundPage() {
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
const msg = err?.response?.data?.message || (editMode ? "수정에 실패했어요" : "출고 등록 중 오류가 발생했습니다."); toast.error(msg);
|
||||
const msg = err?.response?.data?.message || (editMode ? "수정에 실패했어요" : "출고 등록 중 오류가 발생했습니다.");
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -1119,7 +1123,7 @@ export default function OutboundPage() {
|
||||
case "outbound_type": return (
|
||||
<TableCell key={col.key}>
|
||||
<Badge variant="outline" className={cn("text-[11px]", getTypeColor(master.outbound_type))}>
|
||||
{master.outbound_type || "-"}
|
||||
{resolveCat("outbound_type", master.outbound_type) || "-"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
);
|
||||
@@ -1537,7 +1541,7 @@ export default function OutboundPage() {
|
||||
<TableCell className="p-2 text-center">{idx + 1}</TableCell>
|
||||
<TableCell className="p-2">
|
||||
<Badge variant="outline" className={cn("text-[10px]", getTypeColor(item.outbound_type))}>
|
||||
{item.outbound_type || "-"}
|
||||
{resolveCat("outbound_type", item.outbound_type) || "-"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[180px] p-2">
|
||||
|
||||
@@ -38,6 +38,30 @@ const fmtDate = (v: any) => {
|
||||
return m ? `${m[1]}-${m[2]}-${m[3]}` : s.substring(0, 10);
|
||||
};
|
||||
|
||||
/** remark가 JSON이면 사람이 읽을 수 있는 텍스트로 변환 */
|
||||
const parseRemark = (remark: string | null | undefined): string => {
|
||||
if (!remark) return "";
|
||||
const trimmed = remark.trim();
|
||||
if (!trimmed.startsWith("{")) return trimmed;
|
||||
try {
|
||||
const d = JSON.parse(trimmed);
|
||||
switch (d.type) {
|
||||
case "move":
|
||||
return `창고이동 (${d.from_warehouse} → ${d.to_warehouse})`;
|
||||
case "adjust":
|
||||
return `재고조정 (${d.reason || "사유 없음"}, ${d.system_qty}→${d.actual_qty}, 차이:${d.diff >= 0 ? "+" : ""}${d.diff})`;
|
||||
case "confirm":
|
||||
return `재고확인 (${d.reason || "이상없음"})`;
|
||||
case "process_inbound":
|
||||
return "공정입고";
|
||||
default:
|
||||
return d.reason || d.memo || trimmed;
|
||||
}
|
||||
} catch {
|
||||
return trimmed;
|
||||
}
|
||||
};
|
||||
|
||||
export default function InboundOutboundPage() {
|
||||
const { user } = useAuth();
|
||||
|
||||
@@ -62,7 +86,6 @@ export default function InboundOutboundPage() {
|
||||
try {
|
||||
const filters: any[] = [];
|
||||
if (typeFilter !== "all") filters.push({ columnName: "transaction_type", operator: "equals", value: typeFilter });
|
||||
if (categoryFilter !== "all") filters.push({ columnName: "remark", operator: "equals", value: categoryFilter });
|
||||
|
||||
for (const f of searchFilters) {
|
||||
if (f.value) filters.push({ columnName: f.columnName, operator: f.operator, value: f.value });
|
||||
@@ -81,6 +104,21 @@ export default function InboundOutboundPage() {
|
||||
const itemCodes = [...new Set(rows.map((r: any) => r.item_code).filter(Boolean))];
|
||||
if (itemCodes.length > 0) {
|
||||
try {
|
||||
// 단위 카테고리 코드→라벨 매핑 로드
|
||||
let unitLabelMap: Record<string, string> = {};
|
||||
try {
|
||||
const catRes = await apiClient.get("/table-categories/item_info/unit/values");
|
||||
if (catRes.data?.success && catRes.data.data?.length > 0) {
|
||||
const flatten = (vals: any[]) => {
|
||||
for (const v of vals) {
|
||||
unitLabelMap[v.valueCode] = v.valueLabel;
|
||||
if (v.children?.length) flatten(v.children);
|
||||
}
|
||||
};
|
||||
flatten(catRes.data.data);
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
|
||||
const itemRes = await apiClient.post(`/table-management/tables/item_info/data`, {
|
||||
page: 1, size: itemCodes.length + 10,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: itemCodes }] },
|
||||
@@ -89,7 +127,8 @@ export default function InboundOutboundPage() {
|
||||
const items = itemRes.data?.data?.data || itemRes.data?.data?.rows || [];
|
||||
const map: Record<string, { item_name: string; unit: string }> = {};
|
||||
for (const i of items) {
|
||||
if (!map[i.item_number]) map[i.item_number] = { item_name: i.item_name || "", unit: i.unit || "" };
|
||||
const rawUnit = i.unit || "";
|
||||
if (!map[i.item_number]) map[i.item_number] = { item_name: i.item_name || "", unit: unitLabelMap[rawUnit] || rawUnit };
|
||||
}
|
||||
setItemMap(map);
|
||||
} catch { /* skip */ }
|
||||
@@ -124,7 +163,7 @@ export default function InboundOutboundPage() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [searchFilters, typeFilter, categoryFilter]);
|
||||
}, [searchFilters, typeFilter]);
|
||||
|
||||
useEffect(() => { fetchData(); }, [fetchData]);
|
||||
|
||||
@@ -132,20 +171,26 @@ export default function InboundOutboundPage() {
|
||||
|
||||
const categoryOptions = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
data.forEach((r) => { if (r.remark) set.add(r.remark); });
|
||||
data.forEach((r) => { if (r.remark) set.add(parseRemark(r.remark)); });
|
||||
return Array.from(set).sort();
|
||||
}, [data]);
|
||||
|
||||
// ════════ 그룹핑 ════════
|
||||
|
||||
// 카테고리 필터 (클라이언트)
|
||||
const filteredData = useMemo(() => {
|
||||
if (categoryFilter === "all") return data;
|
||||
return data.filter((r) => parseRemark(r.remark) === categoryFilter);
|
||||
}, [data, categoryFilter]);
|
||||
|
||||
const groupedData = useMemo(() => {
|
||||
if (groupBy === "none") return data;
|
||||
if (groupBy === "none") return filteredData;
|
||||
const groups = new Map<string, any[]>();
|
||||
for (const row of data) {
|
||||
for (const row of filteredData) {
|
||||
let key: string;
|
||||
switch (groupBy) {
|
||||
case "transaction_type": key = row.transaction_type || "미지정"; break;
|
||||
case "remark": key = row.remark || "미지정"; break;
|
||||
case "remark": key = parseRemark(row.remark) || "미지정"; break;
|
||||
case "warehouse_code": key = warehouseMap[row.warehouse_code] || row.warehouse_code || "미지정"; break;
|
||||
case "item_code": key = `${row.item_code} ${itemMap[row.item_code]?.item_name || ""}`.trim() || "미지정"; break;
|
||||
default: key = row[groupBy] || "미지정";
|
||||
@@ -191,7 +236,7 @@ export default function InboundOutboundPage() {
|
||||
const rows = data.map((r, i) => ({
|
||||
No: i + 1,
|
||||
입출고구분: r.transaction_type || "",
|
||||
카테고리: r.remark || "",
|
||||
카테고리: parseRemark(r.remark),
|
||||
처리일자: fmtDate(r.transaction_date),
|
||||
창고: warehouseMap[r.warehouse_code] || r.warehouse_code || "",
|
||||
위치: r.location_code || "",
|
||||
@@ -252,7 +297,7 @@ export default function InboundOutboundPage() {
|
||||
<div className="flex items-center gap-2">
|
||||
<Package className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm font-semibold">입출고 내역</span>
|
||||
<Badge variant="secondary" className="font-mono text-xs">{data.length}건</Badge>
|
||||
<Badge variant="secondary" className="font-mono text-xs">{filteredData.length}건</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={groupBy} onValueChange={(v) => { setGroupBy(v); setExpandedGroups(new Set()); }}>
|
||||
@@ -353,7 +398,7 @@ export default function InboundOutboundPage() {
|
||||
{row.transaction_type}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-[12px]">{row.remark || "-"}</TableCell>
|
||||
<TableCell className="text-center text-[12px]">{parseRemark(row.remark) || "-"}</TableCell>
|
||||
<TableCell className="text-center text-[12px]">{fmtDate(row.transaction_date)}</TableCell>
|
||||
<TableCell className="text-[12px]">{warehouseMap[row.warehouse_code] || row.warehouse_code || "-"}</TableCell>
|
||||
<TableCell className="text-center text-[12px]">{row.location_code || "-"}</TableCell>
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
} from "@/lib/api/materialStatus";
|
||||
import { useTableSettings } from "@/hooks/useTableSettings";
|
||||
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
|
||||
const GRID_COLUMNS = [
|
||||
{ key: "plan_no", label: "계획번호" },
|
||||
@@ -60,26 +61,31 @@ const formatDate = (date: Date) => {
|
||||
return `${y}-${m}-${d}`;
|
||||
};
|
||||
|
||||
const getStatusLabel = (status: string) => {
|
||||
const map: Record<string, string> = {
|
||||
planned: "계획",
|
||||
in_progress: "진행중",
|
||||
completed: "완료",
|
||||
pending: "대기",
|
||||
cancelled: "취소",
|
||||
};
|
||||
return map[status] || status;
|
||||
const FALLBACK_STATUS_MAP: Record<string, string> = {
|
||||
planned: "계획",
|
||||
in_progress: "진행중",
|
||||
completed: "완료",
|
||||
pending: "대기",
|
||||
cancelled: "취소",
|
||||
};
|
||||
|
||||
const getStatusStyle = (status: string) => {
|
||||
const map: Record<string, string> = {
|
||||
planned: "bg-secondary text-secondary-foreground border-border",
|
||||
pending: "bg-secondary text-secondary-foreground border-border",
|
||||
in_progress: "bg-primary/10 text-primary border-primary/20",
|
||||
completed: "bg-accent text-accent-foreground border-accent/50",
|
||||
cancelled: "bg-muted text-muted-foreground border-border",
|
||||
};
|
||||
return map[status] || "bg-muted text-muted-foreground border-border";
|
||||
const STATUS_STYLE_MAP: Record<string, string> = {
|
||||
planned: "bg-secondary text-secondary-foreground border-border",
|
||||
pending: "bg-secondary text-secondary-foreground border-border",
|
||||
in_progress: "bg-primary/10 text-primary border-primary/20",
|
||||
completed: "bg-accent text-accent-foreground border-accent/50",
|
||||
cancelled: "bg-muted text-muted-foreground border-border",
|
||||
};
|
||||
|
||||
// 카테고리 라벨 기반으로 스타일 매칭
|
||||
const LABEL_STYLE_MAP: Record<string, string> = {
|
||||
"일반": "bg-secondary text-secondary-foreground border-border",
|
||||
"긴급": "bg-destructive/10 text-destructive border-destructive/20",
|
||||
"계획": "bg-secondary text-secondary-foreground border-border",
|
||||
"대기": "bg-secondary text-secondary-foreground border-border",
|
||||
"진행중": "bg-primary/10 text-primary border-primary/20",
|
||||
"완료": "bg-accent text-accent-foreground border-accent/50",
|
||||
"취소": "bg-muted text-muted-foreground border-border",
|
||||
};
|
||||
|
||||
export default function MaterialStatusPage() {
|
||||
@@ -93,6 +99,32 @@ export default function MaterialStatusPage() {
|
||||
const [searchItemCode, setSearchItemCode] = useState("");
|
||||
const [searchItemName, setSearchItemName] = useState("");
|
||||
|
||||
// 카테고리 코드→라벨 매핑
|
||||
const [statusMap, setStatusMap] = useState<Record<string, string>>({});
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const res = await apiClient.get("/table-categories/work_instruction/status/values");
|
||||
if (res.data?.success && res.data.data?.length > 0) {
|
||||
const map: Record<string, string> = {};
|
||||
const flatten = (vals: any[]) => {
|
||||
for (const v of vals) {
|
||||
map[v.valueCode] = v.valueLabel;
|
||||
if (v.children?.length) flatten(v.children);
|
||||
}
|
||||
};
|
||||
flatten(res.data.data);
|
||||
setStatusMap(map);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const getStatusLabel = useCallback((status: string) => {
|
||||
return statusMap[status] || FALLBACK_STATUS_MAP[status] || status;
|
||||
}, [statusMap]);
|
||||
|
||||
const [workOrders, setWorkOrders] = useState<WorkOrder[]>([]);
|
||||
const [workOrdersLoading, setWorkOrdersLoading] = useState(false);
|
||||
const [checkedWoIds, setCheckedWoIds] = useState<string[]>([]);
|
||||
@@ -369,7 +401,7 @@ export default function MaterialStatusPage() {
|
||||
<span
|
||||
className={cn(
|
||||
"ml-auto rounded-md border px-2 py-0.5 text-[11px] font-semibold",
|
||||
getStatusStyle(wo.status)
|
||||
STATUS_STYLE_MAP[wo.status] || LABEL_STYLE_MAP[getStatusLabel(wo.status)] || "bg-muted text-muted-foreground border-border"
|
||||
)}
|
||||
>
|
||||
{getStatusLabel(wo.status)}
|
||||
|
||||
@@ -324,16 +324,24 @@ export default function OutboundPage() {
|
||||
return result;
|
||||
};
|
||||
const map: Record<string, Record<string, string>> = {};
|
||||
Promise.all(
|
||||
["material", "unit"].map(async (col) => {
|
||||
Promise.all([
|
||||
...["material", "unit"].map(async (col) => {
|
||||
try {
|
||||
const res = await apiClient.get(`/table-categories/item_info/${col}/values?filterCompanyCode=COMPANY_29`);
|
||||
const res = await apiClient.get(`/table-categories/item_info/${col}/values?filterCompanyCode=COMPANY_7`);
|
||||
const items = flatten(res.data?.data || []);
|
||||
map[col] = {};
|
||||
for (const item of items) map[col][item.code] = item.label;
|
||||
} catch { /* skip */ }
|
||||
})
|
||||
).then(() => setCatMap(map));
|
||||
}),
|
||||
(async () => {
|
||||
try {
|
||||
const res = await apiClient.get(`/table-categories/outbound_mng/outbound_type/values`);
|
||||
const items = flatten(res.data?.data || []);
|
||||
map["outbound_type"] = {};
|
||||
for (const item of items) map["outbound_type"][item.code] = item.label;
|
||||
} catch { /* skip */ }
|
||||
})(),
|
||||
]).then(() => setCatMap(map));
|
||||
}, []);
|
||||
const resolveCat = useCallback((col: string, code: string) => {
|
||||
if (!code) return "";
|
||||
@@ -372,7 +380,7 @@ export default function OutboundPage() {
|
||||
}
|
||||
const res = await getOutboundList(params);
|
||||
if (res.success) setData(res.data);
|
||||
} catch (err: any) {
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -389,7 +397,7 @@ export default function OutboundPage() {
|
||||
try {
|
||||
const res = await getOutboundWarehouses();
|
||||
if (res.success) setWarehouses(res.data);
|
||||
} catch (err: any) {
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
})();
|
||||
@@ -565,7 +573,7 @@ export default function OutboundPage() {
|
||||
const res = await getItemSources(keyword || undefined);
|
||||
if (res.success) setItems(res.data);
|
||||
}
|
||||
} catch (err: any) {
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setSourceLoading(false);
|
||||
@@ -597,7 +605,7 @@ export default function OutboundPage() {
|
||||
loadSourceData(defaultType),
|
||||
]);
|
||||
if (numRes.success) setModalOutboundNo(numRes.data);
|
||||
} catch (err: any) {
|
||||
} catch {
|
||||
setModalOutboundNo("");
|
||||
}
|
||||
};
|
||||
@@ -901,7 +909,8 @@ export default function OutboundPage() {
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
const msg = err?.response?.data?.message || (editMode ? "수정에 실패했어요" : "출고 등록 중 오류가 발생했습니다."); toast.error(msg);
|
||||
const msg = err?.response?.data?.message || (editMode ? "수정에 실패했어요" : "출고 등록 중 오류가 발생했습니다.");
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -1114,7 +1123,7 @@ export default function OutboundPage() {
|
||||
case "outbound_type": return (
|
||||
<TableCell key={col.key}>
|
||||
<Badge variant="outline" className={cn("text-[11px]", getTypeColor(master.outbound_type))}>
|
||||
{master.outbound_type || "-"}
|
||||
{resolveCat("outbound_type", master.outbound_type) || "-"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
);
|
||||
@@ -1532,7 +1541,7 @@ export default function OutboundPage() {
|
||||
<TableCell className="p-2 text-center">{idx + 1}</TableCell>
|
||||
<TableCell className="p-2">
|
||||
<Badge variant="outline" className={cn("text-[10px]", getTypeColor(item.outbound_type))}>
|
||||
{item.outbound_type || "-"}
|
||||
{resolveCat("outbound_type", item.outbound_type) || "-"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[180px] p-2">
|
||||
|
||||
@@ -38,6 +38,30 @@ const fmtDate = (v: any) => {
|
||||
return m ? `${m[1]}-${m[2]}-${m[3]}` : s.substring(0, 10);
|
||||
};
|
||||
|
||||
/** remark가 JSON이면 사람이 읽을 수 있는 텍스트로 변환 */
|
||||
const parseRemark = (remark: string | null | undefined): string => {
|
||||
if (!remark) return "";
|
||||
const trimmed = remark.trim();
|
||||
if (!trimmed.startsWith("{")) return trimmed;
|
||||
try {
|
||||
const d = JSON.parse(trimmed);
|
||||
switch (d.type) {
|
||||
case "move":
|
||||
return `창고이동 (${d.from_warehouse} → ${d.to_warehouse})`;
|
||||
case "adjust":
|
||||
return `재고조정 (${d.reason || "사유 없음"}, ${d.system_qty}→${d.actual_qty}, 차이:${d.diff >= 0 ? "+" : ""}${d.diff})`;
|
||||
case "confirm":
|
||||
return `재고확인 (${d.reason || "이상없음"})`;
|
||||
case "process_inbound":
|
||||
return "공정입고";
|
||||
default:
|
||||
return d.reason || d.memo || trimmed;
|
||||
}
|
||||
} catch {
|
||||
return trimmed;
|
||||
}
|
||||
};
|
||||
|
||||
export default function InboundOutboundPage() {
|
||||
const { user } = useAuth();
|
||||
|
||||
@@ -62,7 +86,6 @@ export default function InboundOutboundPage() {
|
||||
try {
|
||||
const filters: any[] = [];
|
||||
if (typeFilter !== "all") filters.push({ columnName: "transaction_type", operator: "equals", value: typeFilter });
|
||||
if (categoryFilter !== "all") filters.push({ columnName: "remark", operator: "equals", value: categoryFilter });
|
||||
|
||||
for (const f of searchFilters) {
|
||||
if (f.value) filters.push({ columnName: f.columnName, operator: f.operator, value: f.value });
|
||||
@@ -81,6 +104,21 @@ export default function InboundOutboundPage() {
|
||||
const itemCodes = [...new Set(rows.map((r: any) => r.item_code).filter(Boolean))];
|
||||
if (itemCodes.length > 0) {
|
||||
try {
|
||||
// 단위 카테고리 코드→라벨 매핑 로드
|
||||
let unitLabelMap: Record<string, string> = {};
|
||||
try {
|
||||
const catRes = await apiClient.get("/table-categories/item_info/unit/values");
|
||||
if (catRes.data?.success && catRes.data.data?.length > 0) {
|
||||
const flatten = (vals: any[]) => {
|
||||
for (const v of vals) {
|
||||
unitLabelMap[v.valueCode] = v.valueLabel;
|
||||
if (v.children?.length) flatten(v.children);
|
||||
}
|
||||
};
|
||||
flatten(catRes.data.data);
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
|
||||
const itemRes = await apiClient.post(`/table-management/tables/item_info/data`, {
|
||||
page: 1, size: itemCodes.length + 10,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: itemCodes }] },
|
||||
@@ -89,7 +127,8 @@ export default function InboundOutboundPage() {
|
||||
const items = itemRes.data?.data?.data || itemRes.data?.data?.rows || [];
|
||||
const map: Record<string, { item_name: string; unit: string }> = {};
|
||||
for (const i of items) {
|
||||
if (!map[i.item_number]) map[i.item_number] = { item_name: i.item_name || "", unit: i.unit || "" };
|
||||
const rawUnit = i.unit || "";
|
||||
if (!map[i.item_number]) map[i.item_number] = { item_name: i.item_name || "", unit: unitLabelMap[rawUnit] || rawUnit };
|
||||
}
|
||||
setItemMap(map);
|
||||
} catch { /* skip */ }
|
||||
@@ -124,7 +163,7 @@ export default function InboundOutboundPage() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [searchFilters, typeFilter, categoryFilter]);
|
||||
}, [searchFilters, typeFilter]);
|
||||
|
||||
useEffect(() => { fetchData(); }, [fetchData]);
|
||||
|
||||
@@ -132,20 +171,26 @@ export default function InboundOutboundPage() {
|
||||
|
||||
const categoryOptions = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
data.forEach((r) => { if (r.remark) set.add(r.remark); });
|
||||
data.forEach((r) => { if (r.remark) set.add(parseRemark(r.remark)); });
|
||||
return Array.from(set).sort();
|
||||
}, [data]);
|
||||
|
||||
// ════════ 그룹핑 ════════
|
||||
|
||||
// 카테고리 필터 (클라이언트)
|
||||
const filteredData = useMemo(() => {
|
||||
if (categoryFilter === "all") return data;
|
||||
return data.filter((r) => parseRemark(r.remark) === categoryFilter);
|
||||
}, [data, categoryFilter]);
|
||||
|
||||
const groupedData = useMemo(() => {
|
||||
if (groupBy === "none") return data;
|
||||
if (groupBy === "none") return filteredData;
|
||||
const groups = new Map<string, any[]>();
|
||||
for (const row of data) {
|
||||
for (const row of filteredData) {
|
||||
let key: string;
|
||||
switch (groupBy) {
|
||||
case "transaction_type": key = row.transaction_type || "미지정"; break;
|
||||
case "remark": key = row.remark || "미지정"; break;
|
||||
case "remark": key = parseRemark(row.remark) || "미지정"; break;
|
||||
case "warehouse_code": key = warehouseMap[row.warehouse_code] || row.warehouse_code || "미지정"; break;
|
||||
case "item_code": key = `${row.item_code} ${itemMap[row.item_code]?.item_name || ""}`.trim() || "미지정"; break;
|
||||
default: key = row[groupBy] || "미지정";
|
||||
@@ -191,7 +236,7 @@ export default function InboundOutboundPage() {
|
||||
const rows = data.map((r, i) => ({
|
||||
No: i + 1,
|
||||
입출고구분: r.transaction_type || "",
|
||||
카테고리: r.remark || "",
|
||||
카테고리: parseRemark(r.remark),
|
||||
처리일자: fmtDate(r.transaction_date),
|
||||
창고: warehouseMap[r.warehouse_code] || r.warehouse_code || "",
|
||||
위치: r.location_code || "",
|
||||
@@ -252,7 +297,7 @@ export default function InboundOutboundPage() {
|
||||
<div className="flex items-center gap-2">
|
||||
<Package className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm font-semibold">입출고 내역</span>
|
||||
<Badge variant="secondary" className="font-mono text-xs">{data.length}건</Badge>
|
||||
<Badge variant="secondary" className="font-mono text-xs">{filteredData.length}건</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={groupBy} onValueChange={(v) => { setGroupBy(v); setExpandedGroups(new Set()); }}>
|
||||
@@ -353,7 +398,7 @@ export default function InboundOutboundPage() {
|
||||
{row.transaction_type}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-[12px]">{row.remark || "-"}</TableCell>
|
||||
<TableCell className="text-center text-[12px]">{parseRemark(row.remark) || "-"}</TableCell>
|
||||
<TableCell className="text-center text-[12px]">{fmtDate(row.transaction_date)}</TableCell>
|
||||
<TableCell className="text-[12px]">{warehouseMap[row.warehouse_code] || row.warehouse_code || "-"}</TableCell>
|
||||
<TableCell className="text-center text-[12px]">{row.location_code || "-"}</TableCell>
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
} from "@/lib/api/materialStatus";
|
||||
import { useTableSettings } from "@/hooks/useTableSettings";
|
||||
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
|
||||
const GRID_COLUMNS = [
|
||||
{ key: "plan_no", label: "계획번호" },
|
||||
@@ -60,26 +61,31 @@ const formatDate = (date: Date) => {
|
||||
return `${y}-${m}-${d}`;
|
||||
};
|
||||
|
||||
const getStatusLabel = (status: string) => {
|
||||
const map: Record<string, string> = {
|
||||
planned: "계획",
|
||||
in_progress: "진행중",
|
||||
completed: "완료",
|
||||
pending: "대기",
|
||||
cancelled: "취소",
|
||||
};
|
||||
return map[status] || status;
|
||||
const FALLBACK_STATUS_MAP: Record<string, string> = {
|
||||
planned: "계획",
|
||||
in_progress: "진행중",
|
||||
completed: "완료",
|
||||
pending: "대기",
|
||||
cancelled: "취소",
|
||||
};
|
||||
|
||||
const getStatusStyle = (status: string) => {
|
||||
const map: Record<string, string> = {
|
||||
planned: "bg-secondary text-secondary-foreground border-border",
|
||||
pending: "bg-secondary text-secondary-foreground border-border",
|
||||
in_progress: "bg-primary/10 text-primary border-primary/20",
|
||||
completed: "bg-accent text-accent-foreground border-accent/50",
|
||||
cancelled: "bg-muted text-muted-foreground border-border",
|
||||
};
|
||||
return map[status] || "bg-muted text-muted-foreground border-border";
|
||||
const STATUS_STYLE_MAP: Record<string, string> = {
|
||||
planned: "bg-secondary text-secondary-foreground border-border",
|
||||
pending: "bg-secondary text-secondary-foreground border-border",
|
||||
in_progress: "bg-primary/10 text-primary border-primary/20",
|
||||
completed: "bg-accent text-accent-foreground border-accent/50",
|
||||
cancelled: "bg-muted text-muted-foreground border-border",
|
||||
};
|
||||
|
||||
// 카테고리 라벨 기반으로 스타일 매칭
|
||||
const LABEL_STYLE_MAP: Record<string, string> = {
|
||||
"일반": "bg-secondary text-secondary-foreground border-border",
|
||||
"긴급": "bg-destructive/10 text-destructive border-destructive/20",
|
||||
"계획": "bg-secondary text-secondary-foreground border-border",
|
||||
"대기": "bg-secondary text-secondary-foreground border-border",
|
||||
"진행중": "bg-primary/10 text-primary border-primary/20",
|
||||
"완료": "bg-accent text-accent-foreground border-accent/50",
|
||||
"취소": "bg-muted text-muted-foreground border-border",
|
||||
};
|
||||
|
||||
export default function MaterialStatusPage() {
|
||||
@@ -93,6 +99,32 @@ export default function MaterialStatusPage() {
|
||||
const [searchItemCode, setSearchItemCode] = useState("");
|
||||
const [searchItemName, setSearchItemName] = useState("");
|
||||
|
||||
// 카테고리 코드→라벨 매핑
|
||||
const [statusMap, setStatusMap] = useState<Record<string, string>>({});
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const res = await apiClient.get("/table-categories/work_instruction/status/values");
|
||||
if (res.data?.success && res.data.data?.length > 0) {
|
||||
const map: Record<string, string> = {};
|
||||
const flatten = (vals: any[]) => {
|
||||
for (const v of vals) {
|
||||
map[v.valueCode] = v.valueLabel;
|
||||
if (v.children?.length) flatten(v.children);
|
||||
}
|
||||
};
|
||||
flatten(res.data.data);
|
||||
setStatusMap(map);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const getStatusLabel = useCallback((status: string) => {
|
||||
return statusMap[status] || FALLBACK_STATUS_MAP[status] || status;
|
||||
}, [statusMap]);
|
||||
|
||||
const [workOrders, setWorkOrders] = useState<WorkOrder[]>([]);
|
||||
const [workOrdersLoading, setWorkOrdersLoading] = useState(false);
|
||||
const [checkedWoIds, setCheckedWoIds] = useState<string[]>([]);
|
||||
@@ -369,7 +401,7 @@ export default function MaterialStatusPage() {
|
||||
<span
|
||||
className={cn(
|
||||
"ml-auto rounded-md border px-2 py-0.5 text-[11px] font-semibold",
|
||||
getStatusStyle(wo.status)
|
||||
STATUS_STYLE_MAP[wo.status] || LABEL_STYLE_MAP[getStatusLabel(wo.status)] || "bg-muted text-muted-foreground border-border"
|
||||
)}
|
||||
>
|
||||
{getStatusLabel(wo.status)}
|
||||
|
||||
@@ -324,16 +324,24 @@ export default function OutboundPage() {
|
||||
return result;
|
||||
};
|
||||
const map: Record<string, Record<string, string>> = {};
|
||||
Promise.all(
|
||||
["material", "unit"].map(async (col) => {
|
||||
Promise.all([
|
||||
...["material", "unit"].map(async (col) => {
|
||||
try {
|
||||
const res = await apiClient.get(`/table-categories/item_info/${col}/values?filterCompanyCode=COMPANY_30`);
|
||||
const res = await apiClient.get(`/table-categories/item_info/${col}/values?filterCompanyCode=COMPANY_7`);
|
||||
const items = flatten(res.data?.data || []);
|
||||
map[col] = {};
|
||||
for (const item of items) map[col][item.code] = item.label;
|
||||
} catch { /* skip */ }
|
||||
})
|
||||
).then(() => setCatMap(map));
|
||||
}),
|
||||
(async () => {
|
||||
try {
|
||||
const res = await apiClient.get(`/table-categories/outbound_mng/outbound_type/values`);
|
||||
const items = flatten(res.data?.data || []);
|
||||
map["outbound_type"] = {};
|
||||
for (const item of items) map["outbound_type"][item.code] = item.label;
|
||||
} catch { /* skip */ }
|
||||
})(),
|
||||
]).then(() => setCatMap(map));
|
||||
}, []);
|
||||
const resolveCat = useCallback((col: string, code: string) => {
|
||||
if (!code) return "";
|
||||
@@ -372,7 +380,7 @@ export default function OutboundPage() {
|
||||
}
|
||||
const res = await getOutboundList(params);
|
||||
if (res.success) setData(res.data);
|
||||
} catch (err: any) {
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -389,7 +397,7 @@ export default function OutboundPage() {
|
||||
try {
|
||||
const res = await getOutboundWarehouses();
|
||||
if (res.success) setWarehouses(res.data);
|
||||
} catch (err: any) {
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
})();
|
||||
@@ -565,7 +573,7 @@ export default function OutboundPage() {
|
||||
const res = await getItemSources(keyword || undefined);
|
||||
if (res.success) setItems(res.data);
|
||||
}
|
||||
} catch (err: any) {
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setSourceLoading(false);
|
||||
@@ -597,7 +605,7 @@ export default function OutboundPage() {
|
||||
loadSourceData(defaultType),
|
||||
]);
|
||||
if (numRes.success) setModalOutboundNo(numRes.data);
|
||||
} catch (err: any) {
|
||||
} catch {
|
||||
setModalOutboundNo("");
|
||||
}
|
||||
};
|
||||
@@ -901,7 +909,8 @@ export default function OutboundPage() {
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
const msg = err?.response?.data?.message || (editMode ? "수정에 실패했어요" : "출고 등록 중 오류가 발생했습니다."); toast.error(msg);
|
||||
const msg = err?.response?.data?.message || (editMode ? "수정에 실패했어요" : "출고 등록 중 오류가 발생했습니다.");
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -1114,7 +1123,7 @@ export default function OutboundPage() {
|
||||
case "outbound_type": return (
|
||||
<TableCell key={col.key}>
|
||||
<Badge variant="outline" className={cn("text-[11px]", getTypeColor(master.outbound_type))}>
|
||||
{master.outbound_type || "-"}
|
||||
{resolveCat("outbound_type", master.outbound_type) || "-"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
);
|
||||
@@ -1532,7 +1541,7 @@ export default function OutboundPage() {
|
||||
<TableCell className="p-2 text-center">{idx + 1}</TableCell>
|
||||
<TableCell className="p-2">
|
||||
<Badge variant="outline" className={cn("text-[10px]", getTypeColor(item.outbound_type))}>
|
||||
{item.outbound_type || "-"}
|
||||
{resolveCat("outbound_type", item.outbound_type) || "-"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[180px] p-2">
|
||||
|
||||
@@ -38,6 +38,30 @@ const fmtDate = (v: any) => {
|
||||
return m ? `${m[1]}-${m[2]}-${m[3]}` : s.substring(0, 10);
|
||||
};
|
||||
|
||||
/** remark가 JSON이면 사람이 읽을 수 있는 텍스트로 변환 */
|
||||
const parseRemark = (remark: string | null | undefined): string => {
|
||||
if (!remark) return "";
|
||||
const trimmed = remark.trim();
|
||||
if (!trimmed.startsWith("{")) return trimmed;
|
||||
try {
|
||||
const d = JSON.parse(trimmed);
|
||||
switch (d.type) {
|
||||
case "move":
|
||||
return `창고이동 (${d.from_warehouse} → ${d.to_warehouse})`;
|
||||
case "adjust":
|
||||
return `재고조정 (${d.reason || "사유 없음"}, ${d.system_qty}→${d.actual_qty}, 차이:${d.diff >= 0 ? "+" : ""}${d.diff})`;
|
||||
case "confirm":
|
||||
return `재고확인 (${d.reason || "이상없음"})`;
|
||||
case "process_inbound":
|
||||
return "공정입고";
|
||||
default:
|
||||
return d.reason || d.memo || trimmed;
|
||||
}
|
||||
} catch {
|
||||
return trimmed;
|
||||
}
|
||||
};
|
||||
|
||||
export default function InboundOutboundPage() {
|
||||
const { user } = useAuth();
|
||||
|
||||
@@ -62,7 +86,6 @@ export default function InboundOutboundPage() {
|
||||
try {
|
||||
const filters: any[] = [];
|
||||
if (typeFilter !== "all") filters.push({ columnName: "transaction_type", operator: "equals", value: typeFilter });
|
||||
if (categoryFilter !== "all") filters.push({ columnName: "remark", operator: "equals", value: categoryFilter });
|
||||
|
||||
for (const f of searchFilters) {
|
||||
if (f.value) filters.push({ columnName: f.columnName, operator: f.operator, value: f.value });
|
||||
@@ -81,6 +104,21 @@ export default function InboundOutboundPage() {
|
||||
const itemCodes = [...new Set(rows.map((r: any) => r.item_code).filter(Boolean))];
|
||||
if (itemCodes.length > 0) {
|
||||
try {
|
||||
// 단위 카테고리 코드→라벨 매핑 로드
|
||||
let unitLabelMap: Record<string, string> = {};
|
||||
try {
|
||||
const catRes = await apiClient.get("/table-categories/item_info/unit/values");
|
||||
if (catRes.data?.success && catRes.data.data?.length > 0) {
|
||||
const flatten = (vals: any[]) => {
|
||||
for (const v of vals) {
|
||||
unitLabelMap[v.valueCode] = v.valueLabel;
|
||||
if (v.children?.length) flatten(v.children);
|
||||
}
|
||||
};
|
||||
flatten(catRes.data.data);
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
|
||||
const itemRes = await apiClient.post(`/table-management/tables/item_info/data`, {
|
||||
page: 1, size: itemCodes.length + 10,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: itemCodes }] },
|
||||
@@ -89,7 +127,8 @@ export default function InboundOutboundPage() {
|
||||
const items = itemRes.data?.data?.data || itemRes.data?.data?.rows || [];
|
||||
const map: Record<string, { item_name: string; unit: string }> = {};
|
||||
for (const i of items) {
|
||||
if (!map[i.item_number]) map[i.item_number] = { item_name: i.item_name || "", unit: i.unit || "" };
|
||||
const rawUnit = i.unit || "";
|
||||
if (!map[i.item_number]) map[i.item_number] = { item_name: i.item_name || "", unit: unitLabelMap[rawUnit] || rawUnit };
|
||||
}
|
||||
setItemMap(map);
|
||||
} catch { /* skip */ }
|
||||
@@ -124,7 +163,7 @@ export default function InboundOutboundPage() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [searchFilters, typeFilter, categoryFilter]);
|
||||
}, [searchFilters, typeFilter]);
|
||||
|
||||
useEffect(() => { fetchData(); }, [fetchData]);
|
||||
|
||||
@@ -132,20 +171,26 @@ export default function InboundOutboundPage() {
|
||||
|
||||
const categoryOptions = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
data.forEach((r) => { if (r.remark) set.add(r.remark); });
|
||||
data.forEach((r) => { if (r.remark) set.add(parseRemark(r.remark)); });
|
||||
return Array.from(set).sort();
|
||||
}, [data]);
|
||||
|
||||
// ════════ 그룹핑 ════════
|
||||
|
||||
// 카테고리 필터 (클라이언트)
|
||||
const filteredData = useMemo(() => {
|
||||
if (categoryFilter === "all") return data;
|
||||
return data.filter((r) => parseRemark(r.remark) === categoryFilter);
|
||||
}, [data, categoryFilter]);
|
||||
|
||||
const groupedData = useMemo(() => {
|
||||
if (groupBy === "none") return data;
|
||||
if (groupBy === "none") return filteredData;
|
||||
const groups = new Map<string, any[]>();
|
||||
for (const row of data) {
|
||||
for (const row of filteredData) {
|
||||
let key: string;
|
||||
switch (groupBy) {
|
||||
case "transaction_type": key = row.transaction_type || "미지정"; break;
|
||||
case "remark": key = row.remark || "미지정"; break;
|
||||
case "remark": key = parseRemark(row.remark) || "미지정"; break;
|
||||
case "warehouse_code": key = warehouseMap[row.warehouse_code] || row.warehouse_code || "미지정"; break;
|
||||
case "item_code": key = `${row.item_code} ${itemMap[row.item_code]?.item_name || ""}`.trim() || "미지정"; break;
|
||||
default: key = row[groupBy] || "미지정";
|
||||
@@ -191,7 +236,7 @@ export default function InboundOutboundPage() {
|
||||
const rows = data.map((r, i) => ({
|
||||
No: i + 1,
|
||||
입출고구분: r.transaction_type || "",
|
||||
카테고리: r.remark || "",
|
||||
카테고리: parseRemark(r.remark),
|
||||
처리일자: fmtDate(r.transaction_date),
|
||||
창고: warehouseMap[r.warehouse_code] || r.warehouse_code || "",
|
||||
위치: r.location_code || "",
|
||||
@@ -252,7 +297,7 @@ export default function InboundOutboundPage() {
|
||||
<div className="flex items-center gap-2">
|
||||
<Package className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm font-semibold">입출고 내역</span>
|
||||
<Badge variant="secondary" className="font-mono text-xs">{data.length}건</Badge>
|
||||
<Badge variant="secondary" className="font-mono text-xs">{filteredData.length}건</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={groupBy} onValueChange={(v) => { setGroupBy(v); setExpandedGroups(new Set()); }}>
|
||||
@@ -353,7 +398,7 @@ export default function InboundOutboundPage() {
|
||||
{row.transaction_type}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-[12px]">{row.remark || "-"}</TableCell>
|
||||
<TableCell className="text-center text-[12px]">{parseRemark(row.remark) || "-"}</TableCell>
|
||||
<TableCell className="text-center text-[12px]">{fmtDate(row.transaction_date)}</TableCell>
|
||||
<TableCell className="text-[12px]">{warehouseMap[row.warehouse_code] || row.warehouse_code || "-"}</TableCell>
|
||||
<TableCell className="text-center text-[12px]">{row.location_code || "-"}</TableCell>
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
} from "@/lib/api/materialStatus";
|
||||
import { useTableSettings } from "@/hooks/useTableSettings";
|
||||
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
|
||||
const GRID_COLUMNS = [
|
||||
{ key: "plan_no", label: "계획번호" },
|
||||
@@ -60,26 +61,31 @@ const formatDate = (date: Date) => {
|
||||
return `${y}-${m}-${d}`;
|
||||
};
|
||||
|
||||
const getStatusLabel = (status: string) => {
|
||||
const map: Record<string, string> = {
|
||||
planned: "계획",
|
||||
in_progress: "진행중",
|
||||
completed: "완료",
|
||||
pending: "대기",
|
||||
cancelled: "취소",
|
||||
};
|
||||
return map[status] || status;
|
||||
const FALLBACK_STATUS_MAP: Record<string, string> = {
|
||||
planned: "계획",
|
||||
in_progress: "진행중",
|
||||
completed: "완료",
|
||||
pending: "대기",
|
||||
cancelled: "취소",
|
||||
};
|
||||
|
||||
const getStatusStyle = (status: string) => {
|
||||
const map: Record<string, string> = {
|
||||
planned: "bg-secondary text-secondary-foreground border-border",
|
||||
pending: "bg-secondary text-secondary-foreground border-border",
|
||||
in_progress: "bg-primary/10 text-primary border-primary/20",
|
||||
completed: "bg-accent text-accent-foreground border-accent/50",
|
||||
cancelled: "bg-muted text-muted-foreground border-border",
|
||||
};
|
||||
return map[status] || "bg-muted text-muted-foreground border-border";
|
||||
const STATUS_STYLE_MAP: Record<string, string> = {
|
||||
planned: "bg-secondary text-secondary-foreground border-border",
|
||||
pending: "bg-secondary text-secondary-foreground border-border",
|
||||
in_progress: "bg-primary/10 text-primary border-primary/20",
|
||||
completed: "bg-accent text-accent-foreground border-accent/50",
|
||||
cancelled: "bg-muted text-muted-foreground border-border",
|
||||
};
|
||||
|
||||
// 카테고리 라벨 기반으로 스타일 매칭
|
||||
const LABEL_STYLE_MAP: Record<string, string> = {
|
||||
"일반": "bg-secondary text-secondary-foreground border-border",
|
||||
"긴급": "bg-destructive/10 text-destructive border-destructive/20",
|
||||
"계획": "bg-secondary text-secondary-foreground border-border",
|
||||
"대기": "bg-secondary text-secondary-foreground border-border",
|
||||
"진행중": "bg-primary/10 text-primary border-primary/20",
|
||||
"완료": "bg-accent text-accent-foreground border-accent/50",
|
||||
"취소": "bg-muted text-muted-foreground border-border",
|
||||
};
|
||||
|
||||
export default function MaterialStatusPage() {
|
||||
@@ -93,6 +99,32 @@ export default function MaterialStatusPage() {
|
||||
const [searchItemCode, setSearchItemCode] = useState("");
|
||||
const [searchItemName, setSearchItemName] = useState("");
|
||||
|
||||
// 카테고리 코드→라벨 매핑
|
||||
const [statusMap, setStatusMap] = useState<Record<string, string>>({});
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const res = await apiClient.get("/table-categories/work_instruction/status/values");
|
||||
if (res.data?.success && res.data.data?.length > 0) {
|
||||
const map: Record<string, string> = {};
|
||||
const flatten = (vals: any[]) => {
|
||||
for (const v of vals) {
|
||||
map[v.valueCode] = v.valueLabel;
|
||||
if (v.children?.length) flatten(v.children);
|
||||
}
|
||||
};
|
||||
flatten(res.data.data);
|
||||
setStatusMap(map);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const getStatusLabel = useCallback((status: string) => {
|
||||
return statusMap[status] || FALLBACK_STATUS_MAP[status] || status;
|
||||
}, [statusMap]);
|
||||
|
||||
const [workOrders, setWorkOrders] = useState<WorkOrder[]>([]);
|
||||
const [workOrdersLoading, setWorkOrdersLoading] = useState(false);
|
||||
const [checkedWoIds, setCheckedWoIds] = useState<string[]>([]);
|
||||
@@ -369,7 +401,7 @@ export default function MaterialStatusPage() {
|
||||
<span
|
||||
className={cn(
|
||||
"ml-auto rounded-md border px-2 py-0.5 text-[11px] font-semibold",
|
||||
getStatusStyle(wo.status)
|
||||
STATUS_STYLE_MAP[wo.status] || LABEL_STYLE_MAP[getStatusLabel(wo.status)] || "bg-muted text-muted-foreground border-border"
|
||||
)}
|
||||
>
|
||||
{getStatusLabel(wo.status)}
|
||||
|
||||
@@ -324,16 +324,24 @@ export default function OutboundPage() {
|
||||
return result;
|
||||
};
|
||||
const map: Record<string, Record<string, string>> = {};
|
||||
Promise.all(
|
||||
["material", "unit"].map(async (col) => {
|
||||
Promise.all([
|
||||
...["material", "unit"].map(async (col) => {
|
||||
try {
|
||||
const res = await apiClient.get(`/table-categories/item_info/${col}/values?filterCompanyCode=COMPANY_7`);
|
||||
const items = flatten(res.data?.data || []);
|
||||
map[col] = {};
|
||||
for (const item of items) map[col][item.code] = item.label;
|
||||
} catch { /* skip */ }
|
||||
})
|
||||
).then(() => setCatMap(map));
|
||||
}),
|
||||
(async () => {
|
||||
try {
|
||||
const res = await apiClient.get(`/table-categories/outbound_mng/outbound_type/values`);
|
||||
const items = flatten(res.data?.data || []);
|
||||
map["outbound_type"] = {};
|
||||
for (const item of items) map["outbound_type"][item.code] = item.label;
|
||||
} catch { /* skip */ }
|
||||
})(),
|
||||
]).then(() => setCatMap(map));
|
||||
}, []);
|
||||
const resolveCat = useCallback((col: string, code: string) => {
|
||||
if (!code) return "";
|
||||
@@ -1115,7 +1123,7 @@ export default function OutboundPage() {
|
||||
case "outbound_type": return (
|
||||
<TableCell key={col.key}>
|
||||
<Badge variant="outline" className={cn("text-[11px]", getTypeColor(master.outbound_type))}>
|
||||
{master.outbound_type || "-"}
|
||||
{resolveCat("outbound_type", master.outbound_type) || "-"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
);
|
||||
@@ -1533,7 +1541,7 @@ export default function OutboundPage() {
|
||||
<TableCell className="p-2 text-center">{idx + 1}</TableCell>
|
||||
<TableCell className="p-2">
|
||||
<Badge variant="outline" className={cn("text-[10px]", getTypeColor(item.outbound_type))}>
|
||||
{item.outbound_type || "-"}
|
||||
{resolveCat("outbound_type", item.outbound_type) || "-"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[180px] p-2">
|
||||
|
||||
@@ -38,6 +38,30 @@ const fmtDate = (v: any) => {
|
||||
return m ? `${m[1]}-${m[2]}-${m[3]}` : s.substring(0, 10);
|
||||
};
|
||||
|
||||
/** remark가 JSON이면 사람이 읽을 수 있는 텍스트로 변환 */
|
||||
const parseRemark = (remark: string | null | undefined): string => {
|
||||
if (!remark) return "";
|
||||
const trimmed = remark.trim();
|
||||
if (!trimmed.startsWith("{")) return trimmed;
|
||||
try {
|
||||
const d = JSON.parse(trimmed);
|
||||
switch (d.type) {
|
||||
case "move":
|
||||
return `창고이동 (${d.from_warehouse} → ${d.to_warehouse})`;
|
||||
case "adjust":
|
||||
return `재고조정 (${d.reason || "사유 없음"}, ${d.system_qty}→${d.actual_qty}, 차이:${d.diff >= 0 ? "+" : ""}${d.diff})`;
|
||||
case "confirm":
|
||||
return `재고확인 (${d.reason || "이상없음"})`;
|
||||
case "process_inbound":
|
||||
return "공정입고";
|
||||
default:
|
||||
return d.reason || d.memo || trimmed;
|
||||
}
|
||||
} catch {
|
||||
return trimmed;
|
||||
}
|
||||
};
|
||||
|
||||
export default function InboundOutboundPage() {
|
||||
const { user } = useAuth();
|
||||
|
||||
@@ -62,7 +86,6 @@ export default function InboundOutboundPage() {
|
||||
try {
|
||||
const filters: any[] = [];
|
||||
if (typeFilter !== "all") filters.push({ columnName: "transaction_type", operator: "equals", value: typeFilter });
|
||||
if (categoryFilter !== "all") filters.push({ columnName: "remark", operator: "equals", value: categoryFilter });
|
||||
|
||||
for (const f of searchFilters) {
|
||||
if (f.value) filters.push({ columnName: f.columnName, operator: f.operator, value: f.value });
|
||||
@@ -81,6 +104,21 @@ export default function InboundOutboundPage() {
|
||||
const itemCodes = [...new Set(rows.map((r: any) => r.item_code).filter(Boolean))];
|
||||
if (itemCodes.length > 0) {
|
||||
try {
|
||||
// 단위 카테고리 코드→라벨 매핑 로드
|
||||
let unitLabelMap: Record<string, string> = {};
|
||||
try {
|
||||
const catRes = await apiClient.get("/table-categories/item_info/unit/values");
|
||||
if (catRes.data?.success && catRes.data.data?.length > 0) {
|
||||
const flatten = (vals: any[]) => {
|
||||
for (const v of vals) {
|
||||
unitLabelMap[v.valueCode] = v.valueLabel;
|
||||
if (v.children?.length) flatten(v.children);
|
||||
}
|
||||
};
|
||||
flatten(catRes.data.data);
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
|
||||
const itemRes = await apiClient.post(`/table-management/tables/item_info/data`, {
|
||||
page: 1, size: itemCodes.length + 10,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: itemCodes }] },
|
||||
@@ -89,7 +127,8 @@ export default function InboundOutboundPage() {
|
||||
const items = itemRes.data?.data?.data || itemRes.data?.data?.rows || [];
|
||||
const map: Record<string, { item_name: string; unit: string }> = {};
|
||||
for (const i of items) {
|
||||
if (!map[i.item_number]) map[i.item_number] = { item_name: i.item_name || "", unit: i.unit || "" };
|
||||
const rawUnit = i.unit || "";
|
||||
if (!map[i.item_number]) map[i.item_number] = { item_name: i.item_name || "", unit: unitLabelMap[rawUnit] || rawUnit };
|
||||
}
|
||||
setItemMap(map);
|
||||
} catch { /* skip */ }
|
||||
@@ -124,7 +163,7 @@ export default function InboundOutboundPage() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [searchFilters, typeFilter, categoryFilter]);
|
||||
}, [searchFilters, typeFilter]);
|
||||
|
||||
useEffect(() => { fetchData(); }, [fetchData]);
|
||||
|
||||
@@ -132,20 +171,26 @@ export default function InboundOutboundPage() {
|
||||
|
||||
const categoryOptions = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
data.forEach((r) => { if (r.remark) set.add(r.remark); });
|
||||
data.forEach((r) => { if (r.remark) set.add(parseRemark(r.remark)); });
|
||||
return Array.from(set).sort();
|
||||
}, [data]);
|
||||
|
||||
// ════════ 그룹핑 ════════
|
||||
|
||||
// 카테고리 필터 (클라이언트)
|
||||
const filteredData = useMemo(() => {
|
||||
if (categoryFilter === "all") return data;
|
||||
return data.filter((r) => parseRemark(r.remark) === categoryFilter);
|
||||
}, [data, categoryFilter]);
|
||||
|
||||
const groupedData = useMemo(() => {
|
||||
if (groupBy === "none") return data;
|
||||
if (groupBy === "none") return filteredData;
|
||||
const groups = new Map<string, any[]>();
|
||||
for (const row of data) {
|
||||
for (const row of filteredData) {
|
||||
let key: string;
|
||||
switch (groupBy) {
|
||||
case "transaction_type": key = row.transaction_type || "미지정"; break;
|
||||
case "remark": key = row.remark || "미지정"; break;
|
||||
case "remark": key = parseRemark(row.remark) || "미지정"; break;
|
||||
case "warehouse_code": key = warehouseMap[row.warehouse_code] || row.warehouse_code || "미지정"; break;
|
||||
case "item_code": key = `${row.item_code} ${itemMap[row.item_code]?.item_name || ""}`.trim() || "미지정"; break;
|
||||
default: key = row[groupBy] || "미지정";
|
||||
@@ -191,7 +236,7 @@ export default function InboundOutboundPage() {
|
||||
const rows = data.map((r, i) => ({
|
||||
No: i + 1,
|
||||
입출고구분: r.transaction_type || "",
|
||||
카테고리: r.remark || "",
|
||||
카테고리: parseRemark(r.remark),
|
||||
처리일자: fmtDate(r.transaction_date),
|
||||
창고: warehouseMap[r.warehouse_code] || r.warehouse_code || "",
|
||||
위치: r.location_code || "",
|
||||
@@ -252,7 +297,7 @@ export default function InboundOutboundPage() {
|
||||
<div className="flex items-center gap-2">
|
||||
<Package className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm font-semibold">입출고 내역</span>
|
||||
<Badge variant="secondary" className="font-mono text-xs">{data.length}건</Badge>
|
||||
<Badge variant="secondary" className="font-mono text-xs">{filteredData.length}건</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={groupBy} onValueChange={(v) => { setGroupBy(v); setExpandedGroups(new Set()); }}>
|
||||
@@ -353,7 +398,7 @@ export default function InboundOutboundPage() {
|
||||
{row.transaction_type}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-[12px]">{row.remark || "-"}</TableCell>
|
||||
<TableCell className="text-center text-[12px]">{parseRemark(row.remark) || "-"}</TableCell>
|
||||
<TableCell className="text-center text-[12px]">{fmtDate(row.transaction_date)}</TableCell>
|
||||
<TableCell className="text-[12px]">{warehouseMap[row.warehouse_code] || row.warehouse_code || "-"}</TableCell>
|
||||
<TableCell className="text-center text-[12px]">{row.location_code || "-"}</TableCell>
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
} from "@/lib/api/materialStatus";
|
||||
import { useTableSettings } from "@/hooks/useTableSettings";
|
||||
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
|
||||
const GRID_COLUMNS = [
|
||||
{ key: "plan_no", label: "계획번호" },
|
||||
@@ -60,26 +61,31 @@ const formatDate = (date: Date) => {
|
||||
return `${y}-${m}-${d}`;
|
||||
};
|
||||
|
||||
const getStatusLabel = (status: string) => {
|
||||
const map: Record<string, string> = {
|
||||
planned: "계획",
|
||||
in_progress: "진행중",
|
||||
completed: "완료",
|
||||
pending: "대기",
|
||||
cancelled: "취소",
|
||||
};
|
||||
return map[status] || status;
|
||||
const FALLBACK_STATUS_MAP: Record<string, string> = {
|
||||
planned: "계획",
|
||||
in_progress: "진행중",
|
||||
completed: "완료",
|
||||
pending: "대기",
|
||||
cancelled: "취소",
|
||||
};
|
||||
|
||||
const getStatusStyle = (status: string) => {
|
||||
const map: Record<string, string> = {
|
||||
planned: "bg-secondary text-secondary-foreground border-border",
|
||||
pending: "bg-secondary text-secondary-foreground border-border",
|
||||
in_progress: "bg-primary/10 text-primary border-primary/20",
|
||||
completed: "bg-accent text-accent-foreground border-accent/50",
|
||||
cancelled: "bg-muted text-muted-foreground border-border",
|
||||
};
|
||||
return map[status] || "bg-muted text-muted-foreground border-border";
|
||||
const STATUS_STYLE_MAP: Record<string, string> = {
|
||||
planned: "bg-secondary text-secondary-foreground border-border",
|
||||
pending: "bg-secondary text-secondary-foreground border-border",
|
||||
in_progress: "bg-primary/10 text-primary border-primary/20",
|
||||
completed: "bg-accent text-accent-foreground border-accent/50",
|
||||
cancelled: "bg-muted text-muted-foreground border-border",
|
||||
};
|
||||
|
||||
// 카테고리 라벨 기반으로 스타일 매칭
|
||||
const LABEL_STYLE_MAP: Record<string, string> = {
|
||||
"일반": "bg-secondary text-secondary-foreground border-border",
|
||||
"긴급": "bg-destructive/10 text-destructive border-destructive/20",
|
||||
"계획": "bg-secondary text-secondary-foreground border-border",
|
||||
"대기": "bg-secondary text-secondary-foreground border-border",
|
||||
"진행중": "bg-primary/10 text-primary border-primary/20",
|
||||
"완료": "bg-accent text-accent-foreground border-accent/50",
|
||||
"취소": "bg-muted text-muted-foreground border-border",
|
||||
};
|
||||
|
||||
export default function MaterialStatusPage() {
|
||||
@@ -93,6 +99,32 @@ export default function MaterialStatusPage() {
|
||||
const [searchItemCode, setSearchItemCode] = useState("");
|
||||
const [searchItemName, setSearchItemName] = useState("");
|
||||
|
||||
// 카테고리 코드→라벨 매핑
|
||||
const [statusMap, setStatusMap] = useState<Record<string, string>>({});
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const res = await apiClient.get("/table-categories/work_instruction/status/values");
|
||||
if (res.data?.success && res.data.data?.length > 0) {
|
||||
const map: Record<string, string> = {};
|
||||
const flatten = (vals: any[]) => {
|
||||
for (const v of vals) {
|
||||
map[v.valueCode] = v.valueLabel;
|
||||
if (v.children?.length) flatten(v.children);
|
||||
}
|
||||
};
|
||||
flatten(res.data.data);
|
||||
setStatusMap(map);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const getStatusLabel = useCallback((status: string) => {
|
||||
return statusMap[status] || FALLBACK_STATUS_MAP[status] || status;
|
||||
}, [statusMap]);
|
||||
|
||||
const [workOrders, setWorkOrders] = useState<WorkOrder[]>([]);
|
||||
const [workOrdersLoading, setWorkOrdersLoading] = useState(false);
|
||||
const [checkedWoIds, setCheckedWoIds] = useState<string[]>([]);
|
||||
@@ -369,7 +401,7 @@ export default function MaterialStatusPage() {
|
||||
<span
|
||||
className={cn(
|
||||
"ml-auto rounded-md border px-2 py-0.5 text-[11px] font-semibold",
|
||||
getStatusStyle(wo.status)
|
||||
STATUS_STYLE_MAP[wo.status] || LABEL_STYLE_MAP[getStatusLabel(wo.status)] || "bg-muted text-muted-foreground border-border"
|
||||
)}
|
||||
>
|
||||
{getStatusLabel(wo.status)}
|
||||
|
||||
@@ -324,16 +324,24 @@ export default function OutboundPage() {
|
||||
return result;
|
||||
};
|
||||
const map: Record<string, Record<string, string>> = {};
|
||||
Promise.all(
|
||||
["material", "unit"].map(async (col) => {
|
||||
Promise.all([
|
||||
...["material", "unit"].map(async (col) => {
|
||||
try {
|
||||
const res = await apiClient.get(`/table-categories/item_info/${col}/values?filterCompanyCode=COMPANY_8`);
|
||||
const res = await apiClient.get(`/table-categories/item_info/${col}/values?filterCompanyCode=COMPANY_7`);
|
||||
const items = flatten(res.data?.data || []);
|
||||
map[col] = {};
|
||||
for (const item of items) map[col][item.code] = item.label;
|
||||
} catch { /* skip */ }
|
||||
})
|
||||
).then(() => setCatMap(map));
|
||||
}),
|
||||
(async () => {
|
||||
try {
|
||||
const res = await apiClient.get(`/table-categories/outbound_mng/outbound_type/values`);
|
||||
const items = flatten(res.data?.data || []);
|
||||
map["outbound_type"] = {};
|
||||
for (const item of items) map["outbound_type"][item.code] = item.label;
|
||||
} catch { /* skip */ }
|
||||
})(),
|
||||
]).then(() => setCatMap(map));
|
||||
}, []);
|
||||
const resolveCat = useCallback((col: string, code: string) => {
|
||||
if (!code) return "";
|
||||
@@ -372,7 +380,7 @@ export default function OutboundPage() {
|
||||
}
|
||||
const res = await getOutboundList(params);
|
||||
if (res.success) setData(res.data);
|
||||
} catch (err: any) {
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -389,7 +397,7 @@ export default function OutboundPage() {
|
||||
try {
|
||||
const res = await getOutboundWarehouses();
|
||||
if (res.success) setWarehouses(res.data);
|
||||
} catch (err: any) {
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
})();
|
||||
@@ -565,7 +573,7 @@ export default function OutboundPage() {
|
||||
const res = await getItemSources(keyword || undefined);
|
||||
if (res.success) setItems(res.data);
|
||||
}
|
||||
} catch (err: any) {
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setSourceLoading(false);
|
||||
@@ -597,7 +605,7 @@ export default function OutboundPage() {
|
||||
loadSourceData(defaultType),
|
||||
]);
|
||||
if (numRes.success) setModalOutboundNo(numRes.data);
|
||||
} catch (err: any) {
|
||||
} catch {
|
||||
setModalOutboundNo("");
|
||||
}
|
||||
};
|
||||
@@ -901,7 +909,8 @@ export default function OutboundPage() {
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
const msg = err?.response?.data?.message || (editMode ? "수정에 실패했어요" : "출고 등록 중 오류가 발생했습니다."); toast.error(msg);
|
||||
const msg = err?.response?.data?.message || (editMode ? "수정에 실패했어요" : "출고 등록 중 오류가 발생했습니다.");
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -1114,7 +1123,7 @@ export default function OutboundPage() {
|
||||
case "outbound_type": return (
|
||||
<TableCell key={col.key}>
|
||||
<Badge variant="outline" className={cn("text-[11px]", getTypeColor(master.outbound_type))}>
|
||||
{master.outbound_type || "-"}
|
||||
{resolveCat("outbound_type", master.outbound_type) || "-"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
);
|
||||
@@ -1532,7 +1541,7 @@ export default function OutboundPage() {
|
||||
<TableCell className="p-2 text-center">{idx + 1}</TableCell>
|
||||
<TableCell className="p-2">
|
||||
<Badge variant="outline" className={cn("text-[10px]", getTypeColor(item.outbound_type))}>
|
||||
{item.outbound_type || "-"}
|
||||
{resolveCat("outbound_type", item.outbound_type) || "-"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[180px] p-2">
|
||||
|
||||
@@ -38,6 +38,30 @@ const fmtDate = (v: any) => {
|
||||
return m ? `${m[1]}-${m[2]}-${m[3]}` : s.substring(0, 10);
|
||||
};
|
||||
|
||||
/** remark가 JSON이면 사람이 읽을 수 있는 텍스트로 변환 */
|
||||
const parseRemark = (remark: string | null | undefined): string => {
|
||||
if (!remark) return "";
|
||||
const trimmed = remark.trim();
|
||||
if (!trimmed.startsWith("{")) return trimmed;
|
||||
try {
|
||||
const d = JSON.parse(trimmed);
|
||||
switch (d.type) {
|
||||
case "move":
|
||||
return `창고이동 (${d.from_warehouse} → ${d.to_warehouse})`;
|
||||
case "adjust":
|
||||
return `재고조정 (${d.reason || "사유 없음"}, ${d.system_qty}→${d.actual_qty}, 차이:${d.diff >= 0 ? "+" : ""}${d.diff})`;
|
||||
case "confirm":
|
||||
return `재고확인 (${d.reason || "이상없음"})`;
|
||||
case "process_inbound":
|
||||
return "공정입고";
|
||||
default:
|
||||
return d.reason || d.memo || trimmed;
|
||||
}
|
||||
} catch {
|
||||
return trimmed;
|
||||
}
|
||||
};
|
||||
|
||||
export default function InboundOutboundPage() {
|
||||
const { user } = useAuth();
|
||||
|
||||
@@ -62,7 +86,6 @@ export default function InboundOutboundPage() {
|
||||
try {
|
||||
const filters: any[] = [];
|
||||
if (typeFilter !== "all") filters.push({ columnName: "transaction_type", operator: "equals", value: typeFilter });
|
||||
if (categoryFilter !== "all") filters.push({ columnName: "remark", operator: "equals", value: categoryFilter });
|
||||
|
||||
for (const f of searchFilters) {
|
||||
if (f.value) filters.push({ columnName: f.columnName, operator: f.operator, value: f.value });
|
||||
@@ -81,6 +104,21 @@ export default function InboundOutboundPage() {
|
||||
const itemCodes = [...new Set(rows.map((r: any) => r.item_code).filter(Boolean))];
|
||||
if (itemCodes.length > 0) {
|
||||
try {
|
||||
// 단위 카테고리 코드→라벨 매핑 로드
|
||||
let unitLabelMap: Record<string, string> = {};
|
||||
try {
|
||||
const catRes = await apiClient.get("/table-categories/item_info/unit/values");
|
||||
if (catRes.data?.success && catRes.data.data?.length > 0) {
|
||||
const flatten = (vals: any[]) => {
|
||||
for (const v of vals) {
|
||||
unitLabelMap[v.valueCode] = v.valueLabel;
|
||||
if (v.children?.length) flatten(v.children);
|
||||
}
|
||||
};
|
||||
flatten(catRes.data.data);
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
|
||||
const itemRes = await apiClient.post(`/table-management/tables/item_info/data`, {
|
||||
page: 1, size: itemCodes.length + 10,
|
||||
dataFilter: { enabled: true, filters: [{ columnName: "item_number", operator: "in", value: itemCodes }] },
|
||||
@@ -89,7 +127,8 @@ export default function InboundOutboundPage() {
|
||||
const items = itemRes.data?.data?.data || itemRes.data?.data?.rows || [];
|
||||
const map: Record<string, { item_name: string; unit: string }> = {};
|
||||
for (const i of items) {
|
||||
if (!map[i.item_number]) map[i.item_number] = { item_name: i.item_name || "", unit: i.unit || "" };
|
||||
const rawUnit = i.unit || "";
|
||||
if (!map[i.item_number]) map[i.item_number] = { item_name: i.item_name || "", unit: unitLabelMap[rawUnit] || rawUnit };
|
||||
}
|
||||
setItemMap(map);
|
||||
} catch { /* skip */ }
|
||||
@@ -124,7 +163,7 @@ export default function InboundOutboundPage() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [searchFilters, typeFilter, categoryFilter]);
|
||||
}, [searchFilters, typeFilter]);
|
||||
|
||||
useEffect(() => { fetchData(); }, [fetchData]);
|
||||
|
||||
@@ -132,20 +171,26 @@ export default function InboundOutboundPage() {
|
||||
|
||||
const categoryOptions = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
data.forEach((r) => { if (r.remark) set.add(r.remark); });
|
||||
data.forEach((r) => { if (r.remark) set.add(parseRemark(r.remark)); });
|
||||
return Array.from(set).sort();
|
||||
}, [data]);
|
||||
|
||||
// ════════ 그룹핑 ════════
|
||||
|
||||
// 카테고리 필터 (클라이언트)
|
||||
const filteredData = useMemo(() => {
|
||||
if (categoryFilter === "all") return data;
|
||||
return data.filter((r) => parseRemark(r.remark) === categoryFilter);
|
||||
}, [data, categoryFilter]);
|
||||
|
||||
const groupedData = useMemo(() => {
|
||||
if (groupBy === "none") return data;
|
||||
if (groupBy === "none") return filteredData;
|
||||
const groups = new Map<string, any[]>();
|
||||
for (const row of data) {
|
||||
for (const row of filteredData) {
|
||||
let key: string;
|
||||
switch (groupBy) {
|
||||
case "transaction_type": key = row.transaction_type || "미지정"; break;
|
||||
case "remark": key = row.remark || "미지정"; break;
|
||||
case "remark": key = parseRemark(row.remark) || "미지정"; break;
|
||||
case "warehouse_code": key = warehouseMap[row.warehouse_code] || row.warehouse_code || "미지정"; break;
|
||||
case "item_code": key = `${row.item_code} ${itemMap[row.item_code]?.item_name || ""}`.trim() || "미지정"; break;
|
||||
default: key = row[groupBy] || "미지정";
|
||||
@@ -191,7 +236,7 @@ export default function InboundOutboundPage() {
|
||||
const rows = data.map((r, i) => ({
|
||||
No: i + 1,
|
||||
입출고구분: r.transaction_type || "",
|
||||
카테고리: r.remark || "",
|
||||
카테고리: parseRemark(r.remark),
|
||||
처리일자: fmtDate(r.transaction_date),
|
||||
창고: warehouseMap[r.warehouse_code] || r.warehouse_code || "",
|
||||
위치: r.location_code || "",
|
||||
@@ -252,7 +297,7 @@ export default function InboundOutboundPage() {
|
||||
<div className="flex items-center gap-2">
|
||||
<Package className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm font-semibold">입출고 내역</span>
|
||||
<Badge variant="secondary" className="font-mono text-xs">{data.length}건</Badge>
|
||||
<Badge variant="secondary" className="font-mono text-xs">{filteredData.length}건</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={groupBy} onValueChange={(v) => { setGroupBy(v); setExpandedGroups(new Set()); }}>
|
||||
@@ -353,7 +398,7 @@ export default function InboundOutboundPage() {
|
||||
{row.transaction_type}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-[12px]">{row.remark || "-"}</TableCell>
|
||||
<TableCell className="text-center text-[12px]">{parseRemark(row.remark) || "-"}</TableCell>
|
||||
<TableCell className="text-center text-[12px]">{fmtDate(row.transaction_date)}</TableCell>
|
||||
<TableCell className="text-[12px]">{warehouseMap[row.warehouse_code] || row.warehouse_code || "-"}</TableCell>
|
||||
<TableCell className="text-center text-[12px]">{row.location_code || "-"}</TableCell>
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
} from "@/lib/api/materialStatus";
|
||||
import { useTableSettings } from "@/hooks/useTableSettings";
|
||||
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
|
||||
const GRID_COLUMNS = [
|
||||
{ key: "plan_no", label: "계획번호" },
|
||||
@@ -60,26 +61,31 @@ const formatDate = (date: Date) => {
|
||||
return `${y}-${m}-${d}`;
|
||||
};
|
||||
|
||||
const getStatusLabel = (status: string) => {
|
||||
const map: Record<string, string> = {
|
||||
planned: "계획",
|
||||
in_progress: "진행중",
|
||||
completed: "완료",
|
||||
pending: "대기",
|
||||
cancelled: "취소",
|
||||
};
|
||||
return map[status] || status;
|
||||
const FALLBACK_STATUS_MAP: Record<string, string> = {
|
||||
planned: "계획",
|
||||
in_progress: "진행중",
|
||||
completed: "완료",
|
||||
pending: "대기",
|
||||
cancelled: "취소",
|
||||
};
|
||||
|
||||
const getStatusStyle = (status: string) => {
|
||||
const map: Record<string, string> = {
|
||||
planned: "bg-secondary text-secondary-foreground border-border",
|
||||
pending: "bg-secondary text-secondary-foreground border-border",
|
||||
in_progress: "bg-primary/10 text-primary border-primary/20",
|
||||
completed: "bg-accent text-accent-foreground border-accent/50",
|
||||
cancelled: "bg-muted text-muted-foreground border-border",
|
||||
};
|
||||
return map[status] || "bg-muted text-muted-foreground border-border";
|
||||
const STATUS_STYLE_MAP: Record<string, string> = {
|
||||
planned: "bg-secondary text-secondary-foreground border-border",
|
||||
pending: "bg-secondary text-secondary-foreground border-border",
|
||||
in_progress: "bg-primary/10 text-primary border-primary/20",
|
||||
completed: "bg-accent text-accent-foreground border-accent/50",
|
||||
cancelled: "bg-muted text-muted-foreground border-border",
|
||||
};
|
||||
|
||||
// 카테고리 라벨 기반으로 스타일 매칭
|
||||
const LABEL_STYLE_MAP: Record<string, string> = {
|
||||
"일반": "bg-secondary text-secondary-foreground border-border",
|
||||
"긴급": "bg-destructive/10 text-destructive border-destructive/20",
|
||||
"계획": "bg-secondary text-secondary-foreground border-border",
|
||||
"대기": "bg-secondary text-secondary-foreground border-border",
|
||||
"진행중": "bg-primary/10 text-primary border-primary/20",
|
||||
"완료": "bg-accent text-accent-foreground border-accent/50",
|
||||
"취소": "bg-muted text-muted-foreground border-border",
|
||||
};
|
||||
|
||||
export default function MaterialStatusPage() {
|
||||
@@ -93,6 +99,32 @@ export default function MaterialStatusPage() {
|
||||
const [searchItemCode, setSearchItemCode] = useState("");
|
||||
const [searchItemName, setSearchItemName] = useState("");
|
||||
|
||||
// 카테고리 코드→라벨 매핑
|
||||
const [statusMap, setStatusMap] = useState<Record<string, string>>({});
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const res = await apiClient.get("/table-categories/work_instruction/status/values");
|
||||
if (res.data?.success && res.data.data?.length > 0) {
|
||||
const map: Record<string, string> = {};
|
||||
const flatten = (vals: any[]) => {
|
||||
for (const v of vals) {
|
||||
map[v.valueCode] = v.valueLabel;
|
||||
if (v.children?.length) flatten(v.children);
|
||||
}
|
||||
};
|
||||
flatten(res.data.data);
|
||||
setStatusMap(map);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const getStatusLabel = useCallback((status: string) => {
|
||||
return statusMap[status] || FALLBACK_STATUS_MAP[status] || status;
|
||||
}, [statusMap]);
|
||||
|
||||
const [workOrders, setWorkOrders] = useState<WorkOrder[]>([]);
|
||||
const [workOrdersLoading, setWorkOrdersLoading] = useState(false);
|
||||
const [checkedWoIds, setCheckedWoIds] = useState<string[]>([]);
|
||||
@@ -369,7 +401,7 @@ export default function MaterialStatusPage() {
|
||||
<span
|
||||
className={cn(
|
||||
"ml-auto rounded-md border px-2 py-0.5 text-[11px] font-semibold",
|
||||
getStatusStyle(wo.status)
|
||||
STATUS_STYLE_MAP[wo.status] || LABEL_STYLE_MAP[getStatusLabel(wo.status)] || "bg-muted text-muted-foreground border-border"
|
||||
)}
|
||||
>
|
||||
{getStatusLabel(wo.status)}
|
||||
|
||||
@@ -324,16 +324,24 @@ export default function OutboundPage() {
|
||||
return result;
|
||||
};
|
||||
const map: Record<string, Record<string, string>> = {};
|
||||
Promise.all(
|
||||
["material", "unit"].map(async (col) => {
|
||||
Promise.all([
|
||||
...["material", "unit"].map(async (col) => {
|
||||
try {
|
||||
const res = await apiClient.get(`/table-categories/item_info/${col}/values?filterCompanyCode=COMPANY_9`);
|
||||
const res = await apiClient.get(`/table-categories/item_info/${col}/values?filterCompanyCode=COMPANY_7`);
|
||||
const items = flatten(res.data?.data || []);
|
||||
map[col] = {};
|
||||
for (const item of items) map[col][item.code] = item.label;
|
||||
} catch { /* skip */ }
|
||||
})
|
||||
).then(() => setCatMap(map));
|
||||
}),
|
||||
(async () => {
|
||||
try {
|
||||
const res = await apiClient.get(`/table-categories/outbound_mng/outbound_type/values`);
|
||||
const items = flatten(res.data?.data || []);
|
||||
map["outbound_type"] = {};
|
||||
for (const item of items) map["outbound_type"][item.code] = item.label;
|
||||
} catch { /* skip */ }
|
||||
})(),
|
||||
]).then(() => setCatMap(map));
|
||||
}, []);
|
||||
const resolveCat = useCallback((col: string, code: string) => {
|
||||
if (!code) return "";
|
||||
@@ -372,7 +380,7 @@ export default function OutboundPage() {
|
||||
}
|
||||
const res = await getOutboundList(params);
|
||||
if (res.success) setData(res.data);
|
||||
} catch (err: any) {
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -389,7 +397,7 @@ export default function OutboundPage() {
|
||||
try {
|
||||
const res = await getOutboundWarehouses();
|
||||
if (res.success) setWarehouses(res.data);
|
||||
} catch (err: any) {
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
})();
|
||||
@@ -565,7 +573,7 @@ export default function OutboundPage() {
|
||||
const res = await getItemSources(keyword || undefined);
|
||||
if (res.success) setItems(res.data);
|
||||
}
|
||||
} catch (err: any) {
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setSourceLoading(false);
|
||||
@@ -597,7 +605,7 @@ export default function OutboundPage() {
|
||||
loadSourceData(defaultType),
|
||||
]);
|
||||
if (numRes.success) setModalOutboundNo(numRes.data);
|
||||
} catch (err: any) {
|
||||
} catch {
|
||||
setModalOutboundNo("");
|
||||
}
|
||||
};
|
||||
@@ -901,7 +909,8 @@ export default function OutboundPage() {
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
const msg = err?.response?.data?.message || (editMode ? "수정에 실패했어요" : "출고 등록 중 오류가 발생했습니다."); toast.error(msg);
|
||||
const msg = err?.response?.data?.message || (editMode ? "수정에 실패했어요" : "출고 등록 중 오류가 발생했습니다.");
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -1114,7 +1123,7 @@ export default function OutboundPage() {
|
||||
case "outbound_type": return (
|
||||
<TableCell key={col.key}>
|
||||
<Badge variant="outline" className={cn("text-[11px]", getTypeColor(master.outbound_type))}>
|
||||
{master.outbound_type || "-"}
|
||||
{resolveCat("outbound_type", master.outbound_type) || "-"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
);
|
||||
@@ -1532,7 +1541,7 @@ export default function OutboundPage() {
|
||||
<TableCell className="p-2 text-center">{idx + 1}</TableCell>
|
||||
<TableCell className="p-2">
|
||||
<Badge variant="outline" className={cn("text-[10px]", getTypeColor(item.outbound_type))}>
|
||||
{item.outbound_type || "-"}
|
||||
{resolveCat("outbound_type", item.outbound_type) || "-"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[180px] p-2">
|
||||
|
||||
Reference in New Issue
Block a user