682 lines
28 KiB
TypeScript
682 lines
28 KiB
TypeScript
"use client";
|
||
|
||
import React, { useState, useMemo, useEffect, useCallback } from "react";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Input } from "@/components/ui/input";
|
||
import {
|
||
Select,
|
||
SelectContent,
|
||
SelectItem,
|
||
SelectTrigger,
|
||
SelectValue,
|
||
} from "@/components/ui/select";
|
||
// Card 제거 — rounded-lg border bg-card 패턴 사용
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { Checkbox } from "@/components/ui/checkbox";
|
||
import { Label } from "@/components/ui/label";
|
||
import {
|
||
ResizableHandle,
|
||
ResizablePanel,
|
||
ResizablePanelGroup,
|
||
} from "@/components/ui/resizable";
|
||
import {
|
||
Search,
|
||
RotateCcw,
|
||
Package,
|
||
ClipboardList,
|
||
Factory,
|
||
MapPin,
|
||
AlertTriangle,
|
||
CheckCircle2,
|
||
Loader2,
|
||
Inbox,
|
||
Settings2,
|
||
} from "lucide-react";
|
||
import { cn } from "@/lib/utils";
|
||
import {
|
||
getWorkOrders,
|
||
getMaterialStatus,
|
||
getWarehouses,
|
||
type WorkOrder,
|
||
type MaterialData,
|
||
type WarehouseData,
|
||
} 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: "계획번호" },
|
||
{ key: "item_code", label: "품목코드" },
|
||
{ key: "item_name", label: "품목명" },
|
||
{ key: "plan_qty", label: "수량" },
|
||
{ key: "plan_date", label: "일자" },
|
||
{ key: "status", label: "상태" },
|
||
];
|
||
|
||
const formatDate = (date: Date) => {
|
||
const y = date.getFullYear();
|
||
const m = String(date.getMonth() + 1).padStart(2, "0");
|
||
const d = String(date.getDate()).padStart(2, "0");
|
||
return `${y}-${m}-${d}`;
|
||
};
|
||
|
||
const FALLBACK_STATUS_MAP: Record<string, string> = {
|
||
planned: "계획",
|
||
in_progress: "진행중",
|
||
completed: "완료",
|
||
pending: "대기",
|
||
cancelled: "취소",
|
||
};
|
||
|
||
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() {
|
||
const ts = useTableSettings("c16-material-status", "work_instruction", GRID_COLUMNS);
|
||
const today = new Date();
|
||
const monthAgo = new Date(today);
|
||
monthAgo.setMonth(today.getMonth() - 1);
|
||
|
||
const [searchDateFrom, setSearchDateFrom] = useState(formatDate(monthAgo));
|
||
const [searchDateTo, setSearchDateTo] = useState(formatDate(today));
|
||
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[]>([]);
|
||
const [selectedWoId, setSelectedWoId] = useState<string | null>(null);
|
||
|
||
const [warehouses, setWarehouses] = useState<WarehouseData[]>([]);
|
||
const [warehouse, setWarehouse] = useState("");
|
||
const [materialSearch, setMaterialSearch] = useState("");
|
||
const [showShortageOnly, setShowShortageOnly] = useState(false);
|
||
const [materials, setMaterials] = useState<MaterialData[]>([]);
|
||
const [materialsLoading, setMaterialsLoading] = useState(false);
|
||
|
||
// 창고 목록 초기 로드
|
||
useEffect(() => {
|
||
(async () => {
|
||
const res = await getWarehouses();
|
||
if (res.success && res.data) {
|
||
setWarehouses(res.data);
|
||
}
|
||
})();
|
||
}, []);
|
||
|
||
// 작업지시 검색
|
||
const handleSearch = useCallback(async () => {
|
||
setWorkOrdersLoading(true);
|
||
try {
|
||
const res = await getWorkOrders({
|
||
dateFrom: searchDateFrom,
|
||
dateTo: searchDateTo,
|
||
itemCode: searchItemCode || undefined,
|
||
itemName: searchItemName || undefined,
|
||
});
|
||
if (res.success && res.data) {
|
||
setWorkOrders(res.data);
|
||
setCheckedWoIds([]);
|
||
setSelectedWoId(null);
|
||
setMaterials([]);
|
||
}
|
||
} finally {
|
||
setWorkOrdersLoading(false);
|
||
}
|
||
}, [searchDateFrom, searchDateTo, searchItemCode, searchItemName]);
|
||
|
||
// 초기 로드
|
||
useEffect(() => {
|
||
handleSearch();
|
||
}, []);
|
||
|
||
const isAllChecked =
|
||
workOrders.length > 0 && checkedWoIds.length === workOrders.length;
|
||
|
||
const handleCheckAll = useCallback(
|
||
(checked: boolean) => {
|
||
setCheckedWoIds(checked ? workOrders.map((wo) => wo.id) : []);
|
||
},
|
||
[workOrders]
|
||
);
|
||
|
||
const handleCheckWo = useCallback((id: string, checked: boolean) => {
|
||
setCheckedWoIds((prev) =>
|
||
checked ? [...prev, id] : prev.filter((i) => i !== id)
|
||
);
|
||
}, []);
|
||
|
||
const handleSelectWo = useCallback((id: string) => {
|
||
setSelectedWoId((prev) => (prev === id ? null : id));
|
||
}, []);
|
||
|
||
// 선택된 작업지시의 자재 조회
|
||
const handleLoadSelectedMaterials = useCallback(async () => {
|
||
if (checkedWoIds.length === 0) {
|
||
alert("자재를 조회할 작업지시를 선택해주세요.");
|
||
return;
|
||
}
|
||
|
||
setMaterialsLoading(true);
|
||
try {
|
||
const res = await getMaterialStatus({
|
||
planIds: checkedWoIds,
|
||
warehouseCode: warehouse || undefined,
|
||
});
|
||
if (res.success && res.data) {
|
||
setMaterials(res.data);
|
||
}
|
||
} finally {
|
||
setMaterialsLoading(false);
|
||
}
|
||
}, [checkedWoIds, warehouse]);
|
||
|
||
const handleResetSearch = useCallback(() => {
|
||
const t = new Date();
|
||
const m = new Date(t);
|
||
m.setMonth(t.getMonth() - 1);
|
||
setSearchDateFrom(formatDate(m));
|
||
setSearchDateTo(formatDate(t));
|
||
setSearchItemCode("");
|
||
setSearchItemName("");
|
||
setMaterialSearch("");
|
||
setShowShortageOnly(false);
|
||
}, []);
|
||
|
||
const filteredMaterials = useMemo(() => {
|
||
return materials.filter((m) => {
|
||
const searchLower = materialSearch.toLowerCase();
|
||
const matchesSearch =
|
||
!materialSearch ||
|
||
m.code.toLowerCase().includes(searchLower) ||
|
||
m.name.toLowerCase().includes(searchLower);
|
||
const matchesShortage = !showShortageOnly || m.current < m.required;
|
||
return matchesSearch && matchesShortage;
|
||
});
|
||
}, [materials, materialSearch, showShortageOnly]);
|
||
|
||
return (
|
||
<div className="flex h-[calc(100vh-4rem)] flex-col gap-3 p-3">
|
||
{/* 검색 영역 */}
|
||
<div className="shrink-0 flex flex-wrap items-end gap-3 rounded-lg border bg-card p-3">
|
||
<div className="flex flex-col gap-1">
|
||
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">기간</span>
|
||
<div className="flex items-center gap-1.5">
|
||
<Input
|
||
type="date"
|
||
className="h-9 w-[140px]"
|
||
value={searchDateFrom}
|
||
onChange={(e) => setSearchDateFrom(e.target.value)}
|
||
/>
|
||
<span className="text-muted-foreground/50 text-xs">~</span>
|
||
<Input
|
||
type="date"
|
||
className="h-9 w-[140px]"
|
||
value={searchDateTo}
|
||
onChange={(e) => setSearchDateTo(e.target.value)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex flex-col gap-1">
|
||
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">품목코드</span>
|
||
<Input
|
||
placeholder="품목코드"
|
||
className="h-9 w-[140px]"
|
||
value={searchItemCode}
|
||
onChange={(e) => setSearchItemCode(e.target.value)}
|
||
/>
|
||
</div>
|
||
|
||
<div className="flex flex-col gap-1">
|
||
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">품목명</span>
|
||
<Input
|
||
placeholder="품목명"
|
||
className="h-9 w-[140px]"
|
||
value={searchItemName}
|
||
onChange={(e) => setSearchItemName(e.target.value)}
|
||
/>
|
||
</div>
|
||
|
||
<div className="flex-1" />
|
||
|
||
<div className="flex items-end gap-2">
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
className="h-9"
|
||
onClick={handleResetSearch}
|
||
>
|
||
<RotateCcw className="mr-1 h-4 w-4" />
|
||
초기화
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
className="h-9"
|
||
onClick={handleSearch}
|
||
disabled={workOrdersLoading}
|
||
>
|
||
{workOrdersLoading ? (
|
||
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
|
||
) : (
|
||
<Search className="mr-1 h-4 w-4" />
|
||
)}
|
||
검색
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 메인 콘텐츠 (좌우 분할) */}
|
||
<div className="flex-1 overflow-hidden rounded-lg border bg-card">
|
||
<ResizablePanelGroup direction="horizontal">
|
||
{/* 왼쪽: 작업지시 리스트 */}
|
||
<ResizablePanel defaultSize={35} minSize={25}>
|
||
<div className="flex h-full flex-col">
|
||
{/* 패널 헤더 */}
|
||
<div className="flex items-center justify-between border-b bg-muted/30 px-3 py-2.5 shrink-0">
|
||
<div className="flex items-center gap-2">
|
||
<Checkbox
|
||
checked={isAllChecked}
|
||
onCheckedChange={handleCheckAll}
|
||
/>
|
||
<ClipboardList className="h-4 w-4 text-muted-foreground" />
|
||
<span className="text-sm font-semibold">작업지시 리스트</span>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<span className="rounded-full border border-primary/15 bg-primary/8 px-2 py-0.5 font-mono text-[11px] font-bold text-primary">
|
||
{workOrders.length}건
|
||
</span>
|
||
<Button
|
||
size="sm"
|
||
className="h-8"
|
||
onClick={handleLoadSelectedMaterials}
|
||
disabled={materialsLoading}
|
||
>
|
||
{materialsLoading ? (
|
||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||
) : (
|
||
<Search className="mr-1.5 h-3.5 w-3.5" />
|
||
)}
|
||
자재조회
|
||
</Button>
|
||
<Button variant="ghost" size="sm" className="h-8" onClick={() => ts.setOpen(true)} title="테이블 설정">
|
||
<Settings2 className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 작업지시 목록 */}
|
||
<div className="flex-1 space-y-2 overflow-auto p-3">
|
||
{workOrdersLoading ? (
|
||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||
<Loader2 className="mb-3 h-8 w-8 animate-spin text-primary" />
|
||
<p className="text-sm text-muted-foreground">
|
||
작업지시를 조회하고 있어요...
|
||
</p>
|
||
</div>
|
||
) : workOrders.length === 0 ? (
|
||
<div className="m-3 flex flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-border py-12 text-center">
|
||
<Inbox className="h-10 w-10 text-muted-foreground/30" />
|
||
<p className="text-sm text-muted-foreground">
|
||
조회된 작업지시가 없어요
|
||
</p>
|
||
</div>
|
||
) : (
|
||
ts.groupData(workOrders).map((wo) => {
|
||
if ((wo as any)._isGroupSummary || (wo as any)._isGroupHeader) return null;
|
||
return (
|
||
<div
|
||
key={wo.id}
|
||
className={cn(
|
||
"flex gap-3 rounded-lg border p-3 transition-all cursor-pointer",
|
||
"hover:border-primary/50 hover:shadow-sm",
|
||
selectedWoId === wo.id
|
||
? "border-primary bg-primary/5 shadow-sm"
|
||
: "border-border"
|
||
)}
|
||
onClick={() => handleSelectWo(wo.id)}
|
||
>
|
||
<div
|
||
className="flex items-start pt-0.5"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<Checkbox
|
||
checked={checkedWoIds.includes(wo.id)}
|
||
onCheckedChange={(c) =>
|
||
handleCheckWo(wo.id, c as boolean)
|
||
}
|
||
/>
|
||
</div>
|
||
<div className="flex flex-1 flex-col gap-1.5">
|
||
<div className="flex items-center gap-2">
|
||
{ts.isVisible("plan_no") && (
|
||
<span className="text-sm font-bold text-primary">
|
||
{wo.plan_no || wo.work_order_no || `WO-${wo.id}`}
|
||
</span>
|
||
)}
|
||
{ts.isVisible("status") && (
|
||
<span
|
||
className={cn(
|
||
"ml-auto rounded-md border px-2 py-0.5 text-[11px] font-semibold",
|
||
STATUS_STYLE_MAP[wo.status] || LABEL_STYLE_MAP[getStatusLabel(wo.status)] || "bg-muted text-muted-foreground border-border"
|
||
)}
|
||
>
|
||
{getStatusLabel(wo.status)}
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div className="flex items-center gap-1.5">
|
||
{ts.isVisible("item_name") && (
|
||
<span className="text-sm font-semibold">
|
||
{wo.item_name}
|
||
</span>
|
||
)}
|
||
{ts.isVisible("item_code") && (
|
||
<span className="text-xs text-muted-foreground">
|
||
({wo.item_code})
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||
{ts.isVisible("plan_qty") && (
|
||
<>
|
||
<span>수량:</span>
|
||
<span className="font-semibold text-foreground">
|
||
{Number(wo.plan_qty).toLocaleString()}개
|
||
</span>
|
||
</>
|
||
)}
|
||
{ts.isVisible("plan_qty") && ts.isVisible("plan_date") && (
|
||
<span className="mx-1">|</span>
|
||
)}
|
||
{ts.isVisible("plan_date") && (
|
||
<>
|
||
<span>일자:</span>
|
||
<span className="font-semibold text-foreground">
|
||
{wo.plan_date
|
||
? new Date(wo.plan_date)
|
||
.toISOString()
|
||
.slice(0, 10)
|
||
: "-"}
|
||
</span>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})
|
||
)}
|
||
</div>
|
||
</div>
|
||
</ResizablePanel>
|
||
|
||
<ResizableHandle withHandle />
|
||
|
||
{/* 오른쪽: 원자재 현황 */}
|
||
<ResizablePanel defaultSize={65} minSize={35}>
|
||
<div className="flex h-full flex-col">
|
||
{/* 패널 헤더 */}
|
||
<div className="flex items-center gap-2 border-b bg-muted/30 px-3 py-2.5 shrink-0">
|
||
<Factory className="h-4 w-4 text-muted-foreground" />
|
||
<span className="text-sm font-semibold">원자재 재고 현황</span>
|
||
</div>
|
||
|
||
{/* 필터 */}
|
||
<div className="flex flex-wrap items-center gap-3 border-b bg-muted/30 px-4 py-2.5 shrink-0">
|
||
<Input
|
||
placeholder="원자재 검색"
|
||
className="h-8 min-w-[150px] flex-1 text-xs"
|
||
value={materialSearch}
|
||
onChange={(e) => setMaterialSearch(e.target.value)}
|
||
/>
|
||
<Select value={warehouse} onValueChange={setWarehouse}>
|
||
<SelectTrigger className="h-8 w-[180px] text-xs">
|
||
<SelectValue placeholder="전체 창고" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="__all__">전체 창고</SelectItem>
|
||
{warehouses.map((wh) => (
|
||
<SelectItem
|
||
key={wh.warehouse_code}
|
||
value={wh.warehouse_code}
|
||
>
|
||
{wh.warehouse_name}
|
||
{wh.warehouse_type
|
||
? ` (${wh.warehouse_type})`
|
||
: ""}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
<label className="flex cursor-pointer items-center gap-2 text-xs font-medium">
|
||
<Checkbox
|
||
checked={showShortageOnly}
|
||
onCheckedChange={(c) => setShowShortageOnly(c as boolean)}
|
||
/>
|
||
<span>부족한 것만 보기</span>
|
||
</label>
|
||
<span className="ml-auto rounded-full border border-primary/15 bg-primary/8 px-2 py-0.5 font-mono text-[11px] font-bold text-primary">
|
||
{filteredMaterials.length}개 품목
|
||
</span>
|
||
</div>
|
||
|
||
{/* 원자재 목록 */}
|
||
<div className="flex-1 space-y-2 overflow-auto p-3">
|
||
{materialsLoading ? (
|
||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||
<Loader2 className="mb-3 h-8 w-8 animate-spin text-primary" />
|
||
<p className="text-sm text-muted-foreground">
|
||
자재현황을 조회하고 있어요...
|
||
</p>
|
||
</div>
|
||
) : materials.length === 0 ? (
|
||
<div className="m-3 flex flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-border py-12 text-center">
|
||
<Inbox className="h-10 w-10 text-muted-foreground/30" />
|
||
<p className="text-sm text-muted-foreground">
|
||
작업지시를 선택하고 자재조회 버튼을 클릭해주세요
|
||
</p>
|
||
</div>
|
||
) : filteredMaterials.length === 0 ? (
|
||
<div className="m-3 flex flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-border py-12 text-center">
|
||
<Package className="h-10 w-10 text-muted-foreground/30" />
|
||
<p className="text-sm text-muted-foreground">
|
||
조회된 원자재가 없어요
|
||
</p>
|
||
</div>
|
||
) : (
|
||
filteredMaterials.map((material) => {
|
||
const shortage = material.required - material.current;
|
||
const isShortage = shortage > 0;
|
||
const percentage =
|
||
material.required > 0
|
||
? Math.min(
|
||
(material.current / material.required) * 100,
|
||
100
|
||
)
|
||
: 100;
|
||
|
||
return (
|
||
<div
|
||
key={material.code}
|
||
className={cn(
|
||
"rounded-lg border p-3 transition-all hover:shadow-sm",
|
||
isShortage
|
||
? "border-destructive/30 bg-destructive/5"
|
||
: "border-primary/15 bg-primary/5"
|
||
)}
|
||
>
|
||
{/* 메인 정보 라인 */}
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<span className="text-sm font-bold">
|
||
{material.name}
|
||
</span>
|
||
<span className="text-xs text-muted-foreground">
|
||
({material.code})
|
||
</span>
|
||
{(material.width || material.height || material.thickness) && (
|
||
<>
|
||
<span className="text-xs text-muted-foreground">|</span>
|
||
<span className="inline-flex items-center gap-1 rounded-md border border-primary/20 bg-primary/10 px-1.5 py-0.5 text-[11px] font-mono text-primary">
|
||
{material.width && <span>W {material.width}</span>}
|
||
{material.height && (
|
||
<>
|
||
{material.width && <span className="opacity-50">×</span>}
|
||
<span>H {material.height}</span>
|
||
</>
|
||
)}
|
||
{material.thickness && (
|
||
<>
|
||
{(material.width || material.height) && <span className="opacity-50">×</span>}
|
||
<span>T {material.thickness}</span>
|
||
</>
|
||
)}
|
||
</span>
|
||
</>
|
||
)}
|
||
<span className="text-xs text-muted-foreground">
|
||
|
|
||
</span>
|
||
<span className="text-xs text-muted-foreground">
|
||
필요:
|
||
</span>
|
||
<span className="text-xs font-semibold text-primary">
|
||
{material.required.toLocaleString()}
|
||
{material.unit}
|
||
</span>
|
||
<span className="text-xs text-muted-foreground">
|
||
|
|
||
</span>
|
||
<span className="text-xs text-muted-foreground">
|
||
현재:
|
||
</span>
|
||
<span
|
||
className={cn(
|
||
"text-xs font-semibold",
|
||
isShortage
|
||
? "text-destructive"
|
||
: "text-foreground"
|
||
)}
|
||
>
|
||
{material.current.toLocaleString()}
|
||
{material.unit}
|
||
</span>
|
||
<span className="text-xs text-muted-foreground">
|
||
|
|
||
</span>
|
||
<span className="text-xs text-muted-foreground">
|
||
{isShortage ? "부족:" : "여유:"}
|
||
</span>
|
||
<span
|
||
className={cn(
|
||
"text-xs font-semibold",
|
||
isShortage
|
||
? "text-destructive"
|
||
: "text-primary"
|
||
)}
|
||
>
|
||
{Math.abs(shortage).toLocaleString()}
|
||
{material.unit}
|
||
</span>
|
||
<span className="text-xs font-semibold text-muted-foreground">
|
||
({percentage.toFixed(0)}%)
|
||
</span>
|
||
|
||
{isShortage ? (
|
||
<span className="ml-auto inline-flex items-center gap-1 rounded-md border border-destructive bg-destructive/10 px-2 py-0.5 text-[11px] font-semibold text-destructive">
|
||
<AlertTriangle className="h-3 w-3" />
|
||
부족
|
||
</span>
|
||
) : (
|
||
<span className="ml-auto inline-flex items-center gap-1 rounded-md border border-primary bg-primary/10 px-2 py-0.5 text-[11px] font-semibold text-primary">
|
||
<CheckCircle2 className="h-3 w-3" />
|
||
충분
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
{/* 위치별 재고 */}
|
||
{material.locations.length > 0 && (
|
||
<div className="mt-2 flex flex-wrap items-center gap-1.5">
|
||
<MapPin className="h-3.5 w-3.5 text-muted-foreground" />
|
||
{material.locations.map((loc, idx) => (
|
||
<span
|
||
key={idx}
|
||
className="inline-flex items-center gap-1 rounded bg-muted/40 px-2 py-0.5 text-xs transition-colors hover:bg-muted/60"
|
||
>
|
||
<span className="font-semibold font-mono text-primary">
|
||
{loc.warehouse_name || loc.location || loc.warehouse}
|
||
</span>
|
||
<span className="font-semibold">
|
||
{loc.qty.toLocaleString()}
|
||
{material.unit}
|
||
</span>
|
||
</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})
|
||
)}
|
||
</div>
|
||
</div>
|
||
</ResizablePanel>
|
||
</ResizablePanelGroup>
|
||
</div>
|
||
|
||
<TableSettingsModal
|
||
open={ts.open}
|
||
onOpenChange={ts.setOpen}
|
||
tableName={ts.tableName}
|
||
settingsId={ts.settingsId}
|
||
defaultVisibleKeys={ts.defaultVisibleKeys}
|
||
onSave={ts.applySettings}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|