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:
kjs
2026-04-13 10:26:23 +09:00
parent 702d37e096
commit e693963c2a
21 changed files with 884 additions and 288 deletions
@@ -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">