96b9ac78db
- Updated API calls in the InventoryStatusPage and BomManagementPage to fetch user data with a limit of 9999 users, improving performance and ensuring all users are loaded. - Implemented user mapping to display user names instead of IDs, enhancing clarity in user-related data across multiple company implementations. - These changes aim to improve the overall user experience by providing clearer information and better data management in the logistics and BOM sections.
762 lines
28 KiB
TypeScript
762 lines
28 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* 재고현황 — 하드코딩 페이지 (Type B 마스터-디테일)
|
|
*
|
|
* 좌측: 재고 목록 (inventory_stock, item_info JOIN)
|
|
* 우측: 선택 품목의 재고 이동 이력 (inventory_history)
|
|
*
|
|
* ★ 재고 직접 등록/삭제 불가 — 입출고를 통해서만 변동, 조정만 가능
|
|
*/
|
|
|
|
import React, { useState, useEffect, useCallback } from "react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "@/components/ui/table";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import {
|
|
ResizableHandle,
|
|
ResizablePanel,
|
|
ResizablePanelGroup,
|
|
} from "@/components/ui/resizable";
|
|
import {
|
|
Package,
|
|
Loader2,
|
|
Download,
|
|
ClipboardEdit,
|
|
History,
|
|
AlertTriangle,
|
|
RefreshCw,
|
|
Settings2,
|
|
} from "lucide-react";
|
|
import { cn } from "@/lib/utils";
|
|
import { apiClient } from "@/lib/api/client";
|
|
import { useAuth } from "@/hooks/useAuth";
|
|
import { useTableSettings } from "@/hooks/useTableSettings";
|
|
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
|
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
|
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
|
|
import { toast } from "sonner";
|
|
import { exportToExcel } from "@/lib/utils/excelExport";
|
|
|
|
const STOCK_TABLE = "inventory_stock";
|
|
|
|
const STOCK_COLUMNS = [
|
|
{ key: "item_code", label: "품목코드" },
|
|
{ key: "item_name", label: "품명" },
|
|
{ key: "warehouse_code", label: "창고" },
|
|
{ key: "location_code", label: "위치" },
|
|
{ key: "current_qty", label: "현재수량", align: "right" as const },
|
|
{ key: "safety_qty", label: "안전재고", align: "right" as const },
|
|
{ key: "unit", label: "단위" },
|
|
{ key: "status", label: "상태" },
|
|
];
|
|
const HISTORY_TABLE = "inventory_history";
|
|
|
|
const getStatusVariant = (
|
|
status: string
|
|
): "default" | "secondary" | "outline" | "destructive" => {
|
|
switch (status) {
|
|
case "정상":
|
|
return "default";
|
|
case "부족":
|
|
return "destructive";
|
|
case "과잉":
|
|
return "secondary";
|
|
default:
|
|
return "outline";
|
|
}
|
|
};
|
|
|
|
const getHistoryTypeVariant = (
|
|
type: string
|
|
): "default" | "secondary" | "outline" | "destructive" => {
|
|
switch (type) {
|
|
case "입고":
|
|
return "default";
|
|
case "출고":
|
|
return "secondary";
|
|
case "조정":
|
|
return "outline";
|
|
case "입고취소":
|
|
case "이동":
|
|
return "destructive";
|
|
default:
|
|
return "outline";
|
|
}
|
|
};
|
|
|
|
export default function InventoryStatusPage() {
|
|
const { user } = useAuth();
|
|
const ts = useTableSettings("c16-inventory", STOCK_TABLE, STOCK_COLUMNS);
|
|
|
|
// 좌측: 재고 목록
|
|
const [stockItems, setStockItems] = useState<any[]>([]);
|
|
const [stockLoading, setStockLoading] = useState(false);
|
|
const [selectedStockId, setSelectedStockId] = useState<string | null>(null);
|
|
|
|
// 검색 필터
|
|
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
|
|
|
|
// 우측: 이동 이력
|
|
const [historyItems, setHistoryItems] = useState<any[]>([]);
|
|
const [historyLoading, setHistoryLoading] = useState(false);
|
|
|
|
// 조정 모달
|
|
const [adjustModalOpen, setAdjustModalOpen] = useState(false);
|
|
const [adjustForm, setAdjustForm] = useState<{
|
|
adjust_type: string;
|
|
adjust_qty: string;
|
|
reason: string;
|
|
}>({ adjust_type: "증가", adjust_qty: "", reason: "" });
|
|
const [adjustSaving, setAdjustSaving] = useState(false);
|
|
|
|
// 카테고리 옵션
|
|
const [categoryOptions, setCategoryOptions] = useState<
|
|
Record<string, { code: string; label: string }[]>
|
|
>({});
|
|
|
|
// 사용자 맵 (writer → 이름)
|
|
const [userMap, setUserMap] = useState<Record<string, string>>({});
|
|
|
|
// 카테고리 + 사용자 로드
|
|
useEffect(() => {
|
|
const load = async () => {
|
|
const optMap: Record<string, { code: string; label: string }[]> = {};
|
|
const flatten = (vals: any[]): { code: string; label: string }[] => {
|
|
const result: { code: string; label: string }[] = [];
|
|
for (const v of vals) {
|
|
result.push({ code: v.valueCode || v.value_code || v.code, label: v.valueLabel || v.value_label || v.label });
|
|
if (v.children?.length) result.push(...flatten(v.children));
|
|
}
|
|
return result;
|
|
};
|
|
// inventory_stock 카테고리
|
|
for (const col of ["status"]) {
|
|
try {
|
|
const res = await apiClient.get(`/table-categories/${STOCK_TABLE}/${col}/values`);
|
|
if (res.data?.success) optMap[col] = flatten(res.data.data || []);
|
|
} catch { /* skip */ }
|
|
}
|
|
// item_info 단위 카테고리
|
|
try {
|
|
const res = await apiClient.get("/table-categories/item_info/unit/values?filterCompanyCode=COMPANY_29");
|
|
if (res.data?.success) optMap["item_unit"] = flatten(res.data.data || []);
|
|
} catch { /* skip */ }
|
|
setCategoryOptions(optMap);
|
|
};
|
|
load();
|
|
// 사용자 목록 로드
|
|
apiClient.get("/admin/users", { params: { size: 9999 } }).then((res) => {
|
|
const users = res.data?.data || res.data || [];
|
|
const map: Record<string, string> = {};
|
|
for (const u of users) {
|
|
const id = u.userId || u.user_id || u.id;
|
|
const name = u.user_name || u.name || id;
|
|
if (id) map[id] = name;
|
|
}
|
|
setUserMap(map);
|
|
}).catch(() => {});
|
|
}, []);
|
|
|
|
// 재고 목록 조회
|
|
const fetchStock = useCallback(async () => {
|
|
setStockLoading(true);
|
|
try {
|
|
const filters = searchFilters.map((f) => ({ columnName: f.columnName, operator: f.operator, value: f.value }));
|
|
const [stockRes, itemRes, whRes] = await Promise.all([
|
|
apiClient.post(`/table-management/tables/${STOCK_TABLE}/data`, {
|
|
page: 1, size: 500,
|
|
dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined,
|
|
autoFilter: true,
|
|
sort: { columnName: "item_code", order: "asc" },
|
|
}),
|
|
apiClient.post(`/table-management/tables/item_info/data`, { page: 1, size: 500, autoFilter: true }),
|
|
apiClient.post(`/table-management/tables/warehouse_info/data`, { page: 1, size: 500, autoFilter: true }),
|
|
]);
|
|
const raw = stockRes.data?.data?.data || stockRes.data?.data?.rows || [];
|
|
const items = itemRes.data?.data?.data || itemRes.data?.data?.rows || [];
|
|
const warehouses = whRes.data?.data?.data || whRes.data?.data?.rows || [];
|
|
const itemMap = new Map(items.map((i: any) => [i.item_number || i.item_code, { name: i.item_name || "", unit: i.unit || "" }]));
|
|
const whMap = new Map(warehouses.map((w: any) => [w.warehouse_code, w.warehouse_name || w.warehouse_code]));
|
|
const resolve = (col: string, code: string) => {
|
|
if (!code) return "";
|
|
return categoryOptions[col]?.find((o) => o.code === code)?.label || code;
|
|
};
|
|
const data = raw.map((r: any) => {
|
|
const itemInfo = itemMap.get(r.item_code) as any;
|
|
const rawUnit = itemInfo?.unit || r.unit || "";
|
|
return {
|
|
...r,
|
|
item_name: itemInfo?.name || "",
|
|
unit: resolve("item_unit", rawUnit) || rawUnit,
|
|
warehouse_name: whMap.get(r.warehouse_code) || r.warehouse_code || "",
|
|
status: resolve("status", r.status),
|
|
_isLow: r.safety_qty && Number(r.current_qty) < Number(r.safety_qty),
|
|
};
|
|
});
|
|
setStockItems(data);
|
|
} catch {
|
|
toast.error("재고 목록을 불러오지 못했어요");
|
|
} finally {
|
|
setStockLoading(false);
|
|
}
|
|
}, [categoryOptions, searchFilters]);
|
|
|
|
useEffect(() => {
|
|
fetchStock();
|
|
}, [fetchStock]);
|
|
|
|
// 선택된 재고
|
|
const selectedStock = stockItems.find((s) => s.id === selectedStockId);
|
|
|
|
// 이력 조회
|
|
const fetchHistory = useCallback(async () => {
|
|
if (!selectedStock?.item_code) {
|
|
setHistoryItems([]);
|
|
return;
|
|
}
|
|
setHistoryLoading(true);
|
|
try {
|
|
const historyFilters: any[] = [
|
|
{
|
|
columnName: "item_code",
|
|
operator: "equals",
|
|
value: selectedStock.item_code,
|
|
},
|
|
];
|
|
if (selectedStock.warehouse_code) {
|
|
historyFilters.push({
|
|
columnName: "warehouse_code",
|
|
operator: "equals",
|
|
value: selectedStock.warehouse_code,
|
|
});
|
|
}
|
|
const res = await apiClient.post(
|
|
`/table-management/tables/${HISTORY_TABLE}/data`,
|
|
{
|
|
page: 1,
|
|
size: 500,
|
|
dataFilter: { enabled: true, filters: historyFilters },
|
|
autoFilter: true,
|
|
sort: { columnName: "transaction_date", order: "desc" },
|
|
}
|
|
);
|
|
const raw = res.data?.data?.data || res.data?.data?.rows || [];
|
|
setHistoryItems(raw);
|
|
} catch {
|
|
toast.error("재고 이력을 불러오지 못했어요");
|
|
} finally {
|
|
setHistoryLoading(false);
|
|
}
|
|
}, [selectedStock?.item_code, selectedStock?.warehouse_code]);
|
|
|
|
useEffect(() => {
|
|
fetchHistory();
|
|
}, [fetchHistory]);
|
|
|
|
// 재고 조정 저장
|
|
const handleAdjustSave = async () => {
|
|
if (!selectedStock) return;
|
|
const qty = Number(adjustForm.adjust_qty);
|
|
if (!qty || qty <= 0) {
|
|
toast.error("조정 수량을 입력해주세요");
|
|
return;
|
|
}
|
|
if (!adjustForm.reason.trim()) {
|
|
toast.error("조정 사유를 입력해주세요");
|
|
return;
|
|
}
|
|
setAdjustSaving(true);
|
|
try {
|
|
const changeQty = adjustForm.adjust_type === "증가" ? qty : -qty;
|
|
const afterQty = Number(selectedStock.current_qty || 0) + changeQty;
|
|
|
|
await apiClient.post(
|
|
`/table-management/tables/${HISTORY_TABLE}/add`,
|
|
{
|
|
id: crypto.randomUUID(),
|
|
item_code: selectedStock.item_code,
|
|
warehouse_code: selectedStock.warehouse_code || "",
|
|
location_code: selectedStock.location_code || "",
|
|
transaction_type: "조정",
|
|
transaction_date: new Date().toISOString(),
|
|
quantity: String(changeQty),
|
|
balance_qty: String(afterQty),
|
|
remark: adjustForm.reason.trim(),
|
|
}
|
|
);
|
|
|
|
await apiClient.put(
|
|
`/table-management/tables/${STOCK_TABLE}/edit`,
|
|
{
|
|
originalData: { id: selectedStock.id },
|
|
updatedData: { current_qty: afterQty },
|
|
}
|
|
);
|
|
|
|
toast.success("재고가 조정되었어요");
|
|
setAdjustModalOpen(false);
|
|
setAdjustForm({ adjust_type: "증가", adjust_qty: "", reason: "" });
|
|
fetchStock();
|
|
} catch {
|
|
toast.error("재고 조정에 실패했어요");
|
|
} finally {
|
|
setAdjustSaving(false);
|
|
}
|
|
};
|
|
|
|
// EDataTable 컬럼 정의
|
|
const stockColumns: EDataTableColumn[] = ts.visibleColumns.map((col) => {
|
|
const base: EDataTableColumn = { key: col.key, label: col.label, align: col.align };
|
|
if (col.key === "current_qty") {
|
|
return {
|
|
...base,
|
|
align: "right" as const,
|
|
render: (val: any, row: any) => (
|
|
<span className="font-mono">
|
|
<span className={cn(row._isLow && "text-destructive font-bold")}>
|
|
{Number(row.current_qty || 0).toLocaleString()}
|
|
</span>
|
|
{row._isLow && (
|
|
<AlertTriangle className="inline h-3 w-3 text-destructive ml-1" />
|
|
)}
|
|
</span>
|
|
),
|
|
};
|
|
}
|
|
if (col.key === "warehouse_code") {
|
|
return {
|
|
...base,
|
|
render: (_val: any, row: any) => row.warehouse_name || row.warehouse_code || "",
|
|
};
|
|
}
|
|
if (col.key === "safety_qty") {
|
|
return {
|
|
...base,
|
|
align: "right" as const,
|
|
formatNumber: true,
|
|
};
|
|
}
|
|
if (col.key === "status") {
|
|
return {
|
|
...base,
|
|
render: (val: any) => (
|
|
<Badge variant={getStatusVariant(val)} className="text-[10px]">
|
|
{val}
|
|
</Badge>
|
|
),
|
|
};
|
|
}
|
|
return base;
|
|
});
|
|
|
|
// 엑셀 내보내기
|
|
const handleExcelExport = () => {
|
|
if (stockItems.length === 0) {
|
|
toast.error("내보낼 데이터가 없어요");
|
|
return;
|
|
}
|
|
exportToExcel(
|
|
stockItems.map((r) => ({
|
|
품목코드: r.item_code,
|
|
품명: r.item_name,
|
|
창고: r.warehouse_name || r.warehouse_code,
|
|
위치: r.location_code,
|
|
현재수량: r.current_qty,
|
|
안전재고: r.safety_qty,
|
|
단위: r.unit,
|
|
상태: r.status,
|
|
})),
|
|
"재고현황"
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div className="flex flex-col h-full gap-3 p-3">
|
|
{/* 검색 바 */}
|
|
<DynamicSearchFilter
|
|
tableName={STOCK_TABLE}
|
|
filterId="c16-inventory"
|
|
onFilterChange={setSearchFilters}
|
|
externalFilterConfig={ts.filterConfig}
|
|
dataCount={stockItems.length}
|
|
extraActions={
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-8 gap-1 text-xs"
|
|
onClick={handleExcelExport}
|
|
>
|
|
<Download className="h-3.5 w-3.5" />
|
|
엑셀
|
|
</Button>
|
|
<Button variant="ghost" size="sm" className="h-8 px-2" onClick={() => ts.setOpen(true)} title="테이블 설정">
|
|
<Settings2 className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</div>
|
|
}
|
|
/>
|
|
|
|
{/* 마스터-디테일 패널 */}
|
|
<ResizablePanelGroup
|
|
direction="horizontal"
|
|
className="flex-1 rounded-lg border bg-card"
|
|
>
|
|
{/* 좌측: 재고 목록 */}
|
|
<ResizablePanel defaultSize={55} minSize={30}>
|
|
<div className="flex flex-col h-full">
|
|
<div className="flex items-center justify-between px-4 py-2.5 border-b bg-muted/50">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-[13px] font-bold">재고 목록</span>
|
|
<Badge variant="default" className="rounded-full text-[11px]">
|
|
{stockItems.length}건
|
|
</Badge>
|
|
</div>
|
|
</div>
|
|
|
|
<EDataTable
|
|
columns={stockColumns}
|
|
data={ts.groupData(stockItems)}
|
|
rowKey={(row) => row.id}
|
|
loading={stockLoading}
|
|
emptyMessage="등록된 재고가 없어요"
|
|
selectedId={selectedStockId}
|
|
onSelect={(id) => setSelectedStockId(id)}
|
|
showRowNumber
|
|
showPagination={false}
|
|
draggableColumns={false}
|
|
columnOrderKey="c16-inventory"
|
|
/>
|
|
</div>
|
|
</ResizablePanel>
|
|
|
|
<ResizableHandle withHandle />
|
|
|
|
{/* 우측: 상세 이력 */}
|
|
<ResizablePanel defaultSize={45} minSize={25}>
|
|
<div className="flex flex-col h-full">
|
|
{!selectedStock ? (
|
|
<div className="flex flex-col items-center justify-center flex-1 m-5 border-2 border-dashed rounded-lg border-border">
|
|
<Package className="h-12 w-12 text-muted-foreground/40 mb-4" />
|
|
<p className="text-sm font-semibold text-muted-foreground">
|
|
품목을 선택해주세요
|
|
</p>
|
|
<p className="text-xs text-muted-foreground/70 mt-1">
|
|
좌측에서 품목을 선택하면 재고 이력이 표시돼요
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{/* 패널 헤더 */}
|
|
<div className="flex items-center justify-between px-4 py-2.5 border-b bg-muted/50">
|
|
<div className="flex items-center gap-2">
|
|
<History className="h-4 w-4 text-muted-foreground" />
|
|
<span className="text-[13px] font-bold">
|
|
{selectedStock.item_name || selectedStock.item_code}
|
|
</span>
|
|
<Badge
|
|
variant="outline"
|
|
className="rounded-full text-[11px] font-mono"
|
|
>
|
|
{selectedStock.item_code}
|
|
</Badge>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<div className="flex items-center gap-1.5 text-xs">
|
|
<span className="text-muted-foreground">현재:</span>
|
|
<span
|
|
className={cn(
|
|
"font-bold font-mono",
|
|
selectedStock._isLow
|
|
? "text-destructive"
|
|
: "text-foreground"
|
|
)}
|
|
>
|
|
{Number(selectedStock.current_qty || 0).toLocaleString()}
|
|
</span>
|
|
{selectedStock._isLow && (
|
|
<AlertTriangle className="h-3.5 w-3.5 text-destructive" />
|
|
)}
|
|
</div>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-7 gap-1 text-xs"
|
|
onClick={() => {
|
|
setAdjustForm({
|
|
adjust_type: "증가",
|
|
adjust_qty: "",
|
|
reason: "",
|
|
});
|
|
setAdjustModalOpen(true);
|
|
}}
|
|
>
|
|
<ClipboardEdit className="h-3.5 w-3.5" />
|
|
재고 조정
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 재고 요약 카드 */}
|
|
<div className="grid grid-cols-4 gap-2 px-4 py-3 border-b">
|
|
<div className="flex flex-col items-center p-2 rounded-md bg-muted/50">
|
|
<span className="text-[10px] text-muted-foreground">현재수량</span>
|
|
<span className="text-sm font-bold font-mono">
|
|
{Number(selectedStock.current_qty || 0).toLocaleString()}
|
|
</span>
|
|
</div>
|
|
<div className="flex flex-col items-center p-2 rounded-md bg-muted/50">
|
|
<span className="text-[10px] text-muted-foreground">안전재고</span>
|
|
<span className="text-sm font-bold font-mono">
|
|
{Number(selectedStock.safety_qty || 0).toLocaleString()}
|
|
</span>
|
|
</div>
|
|
<div className="flex flex-col items-center p-2 rounded-md bg-muted/50">
|
|
<span className="text-[10px] text-muted-foreground">창고</span>
|
|
<span className="text-sm font-bold truncate max-w-full">
|
|
{selectedStock.warehouse_name || "-"}
|
|
</span>
|
|
</div>
|
|
<div className="flex flex-col items-center p-2 rounded-md bg-muted/50">
|
|
<span className="text-[10px] text-muted-foreground">상태</span>
|
|
<Badge
|
|
variant={getStatusVariant(selectedStock.status)}
|
|
className="text-[10px] mt-0.5"
|
|
>
|
|
{selectedStock.status || "-"}
|
|
</Badge>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 이력 서브헤더 */}
|
|
<div className="flex items-center justify-between px-4 py-2 border-b">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-xs font-semibold text-muted-foreground">
|
|
재고 이동 이력
|
|
</span>
|
|
<Badge variant="secondary" className="rounded-full text-[10px]">
|
|
{historyItems.length}건
|
|
</Badge>
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-7 gap-1 text-xs"
|
|
onClick={fetchHistory}
|
|
>
|
|
<RefreshCw className="h-3 w-3" />
|
|
새로고침
|
|
</Button>
|
|
</div>
|
|
|
|
{/* 이력 테이블 */}
|
|
<div className="flex-1 overflow-auto">
|
|
{historyLoading ? (
|
|
<div className="flex items-center justify-center h-20">
|
|
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
|
</div>
|
|
) : historyItems.length === 0 ? (
|
|
<div className="flex items-center justify-center h-20 text-xs text-muted-foreground">
|
|
재고 이동 이력이 없어요
|
|
</div>
|
|
) : (
|
|
<Table>
|
|
<TableHeader className="sticky top-0 z-10">
|
|
<TableRow className="bg-muted hover:bg-muted">
|
|
<TableHead className="w-8 text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">#</TableHead>
|
|
<TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">일자</TableHead>
|
|
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">유형</TableHead>
|
|
<TableHead className="w-[90px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">변동수량</TableHead>
|
|
<TableHead className="w-[90px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">이후수량</TableHead>
|
|
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">참조번호</TableHead>
|
|
<TableHead className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">사유</TableHead>
|
|
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">처리자</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{historyItems.map((h, idx) => (
|
|
<TableRow key={h.id || idx} className="text-xs">
|
|
<TableCell className="text-center text-muted-foreground">
|
|
{idx + 1}
|
|
</TableCell>
|
|
<TableCell className="font-mono">
|
|
{h.transaction_date ? String(h.transaction_date).slice(0, 10) : h.history_date || ""}
|
|
</TableCell>
|
|
<TableCell>
|
|
<Badge
|
|
variant={getHistoryTypeVariant(h.transaction_type || h.history_type)}
|
|
className="text-[10px]"
|
|
>
|
|
{h.transaction_type || h.history_type}
|
|
</Badge>
|
|
</TableCell>
|
|
<TableCell
|
|
className={cn(
|
|
"text-right font-mono",
|
|
Number(h.quantity ?? h.change_qty) > 0
|
|
? "text-primary"
|
|
: "text-destructive"
|
|
)}
|
|
>
|
|
{Number(h.quantity ?? h.change_qty) > 0 ? "+" : ""}
|
|
{Number(h.quantity ?? h.change_qty ?? 0).toLocaleString()}
|
|
</TableCell>
|
|
<TableCell className="text-right font-mono">
|
|
{Number(h.balance_qty ?? h.after_qty ?? 0).toLocaleString()}
|
|
</TableCell>
|
|
<TableCell className="font-mono truncate max-w-[120px]">
|
|
{h.reference_number || h.reference_no || ""}
|
|
</TableCell>
|
|
<TableCell className="truncate max-w-[150px]">
|
|
{h.remark || h.reason || ""}
|
|
</TableCell>
|
|
<TableCell>{userMap[h.writer] || userMap[h.created_by] || h.writer || h.created_by || ""}</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</ResizablePanel>
|
|
</ResizablePanelGroup>
|
|
|
|
{/* 테이블 설정 모달 */}
|
|
<TableSettingsModal
|
|
open={ts.open}
|
|
onOpenChange={ts.setOpen}
|
|
tableName={ts.tableName}
|
|
settingsId={ts.settingsId}
|
|
defaultVisibleKeys={ts.defaultVisibleKeys}
|
|
onSave={ts.applySettings}
|
|
/>
|
|
|
|
{/* 재고 조정 Dialog */}
|
|
<Dialog open={adjustModalOpen} onOpenChange={setAdjustModalOpen}>
|
|
<DialogContent className="max-w-lg">
|
|
<DialogHeader>
|
|
<DialogTitle>재고 조정</DialogTitle>
|
|
<DialogDescription>
|
|
{selectedStock
|
|
? `${selectedStock.item_name || selectedStock.item_code} — 현재 수량: ${Number(selectedStock.current_qty || 0).toLocaleString()}`
|
|
: ""}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="grid gap-4 py-2">
|
|
<div className="grid gap-1.5">
|
|
<Label className="text-[11px] font-semibold text-muted-foreground">
|
|
조정 유형
|
|
</Label>
|
|
<Select
|
|
value={adjustForm.adjust_type}
|
|
onValueChange={(v) =>
|
|
setAdjustForm((prev) => ({ ...prev, adjust_type: v }))
|
|
}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="조정 유형 선택" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="증가">증가 (입고 보정)</SelectItem>
|
|
<SelectItem value="감소">감소 (출고 보정)</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="grid gap-1.5">
|
|
<Label className="text-[11px] font-semibold text-muted-foreground">
|
|
조정 수량
|
|
</Label>
|
|
<Input
|
|
type="number"
|
|
min={1}
|
|
placeholder="수량을 입력해주세요"
|
|
value={adjustForm.adjust_qty}
|
|
onChange={(e) =>
|
|
setAdjustForm((prev) => ({
|
|
...prev,
|
|
adjust_qty: e.target.value,
|
|
}))
|
|
}
|
|
/>
|
|
{adjustForm.adjust_qty && selectedStock && (
|
|
<p className="text-xs text-muted-foreground">
|
|
조정 후 수량:{" "}
|
|
<span className="font-mono font-bold">
|
|
{(
|
|
Number(selectedStock.current_qty || 0) +
|
|
(adjustForm.adjust_type === "증가" ? 1 : -1) *
|
|
Number(adjustForm.adjust_qty || 0)
|
|
).toLocaleString()}
|
|
</span>
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="grid gap-1.5">
|
|
<Label className="text-[11px] font-semibold text-muted-foreground">
|
|
조정 사유 *
|
|
</Label>
|
|
<Textarea
|
|
placeholder="조정 사유를 입력해주세요"
|
|
rows={3}
|
|
value={adjustForm.reason}
|
|
onChange={(e) =>
|
|
setAdjustForm((prev) => ({
|
|
...prev,
|
|
reason: e.target.value,
|
|
}))
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setAdjustModalOpen(false)}
|
|
>
|
|
취소
|
|
</Button>
|
|
<Button onClick={handleAdjustSave} disabled={adjustSaving}>
|
|
{adjustSaving && (
|
|
<Loader2 className="h-4 w-4 animate-spin mr-1" />
|
|
)}
|
|
조정하기
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|