feat: 그룹 설정 및 데이터 그룹핑 기능 추가123
This commit is contained in:
@@ -57,6 +57,7 @@ import {
|
|||||||
import { useTableSettings } from "@/hooks/useTableSettings";
|
import { useTableSettings } from "@/hooks/useTableSettings";
|
||||||
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
||||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||||
|
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
|
||||||
|
|
||||||
// --- Types ---
|
// --- Types ---
|
||||||
type ChangeType = "설계오류" | "원가절감" | "고객요청" | "공정개선" | "법규대응";
|
type ChangeType = "설계오류" | "원가절감" | "고객요청" | "공정개선" | "법규대응";
|
||||||
@@ -866,185 +867,52 @@ export default function DesignChangeManagementPage() {
|
|||||||
<div className="flex-1 flex flex-col overflow-hidden border rounded-lg bg-card">
|
<div className="flex-1 flex flex-col overflow-hidden border rounded-lg bg-card">
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
{currentTab === "ecr" ? (
|
{currentTab === "ecr" ? (
|
||||||
<Table style={{ tableLayout: "fixed" }}>
|
<EDataTable
|
||||||
<TableHeader className="sticky top-0 z-10">
|
columns={[
|
||||||
<TableRow className="bg-muted hover:bg-muted">
|
{ key: "id", label: "ECR번호", width: "w-[140px]", render: (val: any) => <span className="font-semibold text-primary">{val}</span> },
|
||||||
<TableHead className="w-[50px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">No</TableHead>
|
{ key: "changeType", label: "변경유형", width: "w-[90px]", align: "center" as const, render: (val: any) => <span className={cn("px-2 py-0.5 rounded-full text-[11px] font-medium border", getChangeTypeStyle(val))}>{val}</span> },
|
||||||
{tsEcr.visibleColumns.map((col) => (
|
{ key: "status", label: "상태", width: "w-[90px]", align: "center" as const, render: (val: any) => <span className={cn("px-2 py-0.5 rounded-full text-[11px] font-medium border", getEcrStatusStyle(val))}>{val}</span> },
|
||||||
<TableHead
|
{ key: "urgency", label: "긴급", width: "w-[60px]", align: "center" as const, render: (val: any) => val === "긴급" ? <span className="px-2 py-0.5 rounded-full text-[11px] font-medium border bg-destructive/10 text-destructive border-destructive/20">긴급</span> : <span>-</span> },
|
||||||
key={col.key}
|
{ key: "target", label: "대상 품목/설비", width: "w-[200px]" },
|
||||||
className={cn(
|
{ key: "drawingNo", label: "도면번호", width: "w-[150px]" },
|
||||||
col.key === "request_no" && "w-[140px]",
|
{ key: "reqDept", label: "요청부서", width: "w-[80px]" },
|
||||||
col.key === "change_type" && "w-[90px] text-center",
|
{ key: "requester", label: "요청자", width: "w-[70px]" },
|
||||||
col.key === "status" && "w-[90px] text-center",
|
{ key: "date", label: "요청일자", width: "w-[100px]" },
|
||||||
col.key === "urgency" && "w-[60px] text-center",
|
{ key: "ecnNo", label: "관련 ECN", width: "w-[130px]", render: (val: any) => val ? <button className="px-2 py-0.5 rounded-full text-[11px] font-semibold bg-info/10 text-info border border-info/20 hover:bg-info/20 transition-colors" onClick={(e) => { e.stopPropagation(); navigateToLink(val); }}>{val} <ArrowRight className="w-3 h-3 inline" /></button> : <span>-</span> },
|
||||||
col.key === "target_name" && "w-[200px]",
|
] as EDataTableColumn<EcrItem>[]}
|
||||||
col.key === "drawing_no" && "w-[150px]",
|
data={tsEcr.groupData(filteredEcr)}
|
||||||
col.key === "req_dept" && "w-[80px]",
|
rowKey={(row) => row.id}
|
||||||
col.key === "requester" && "w-[70px]",
|
selectedId={selectedId}
|
||||||
col.key === "request_date" && "w-[100px]",
|
onSelect={(id) => { if (id) handleRowClick(id); }}
|
||||||
col.key === "ecn_no" && "w-[130px]",
|
onRowClick={(row) => handleRowClick(row.id)}
|
||||||
)}
|
emptyMessage="조건에 맞는 ECR이 없어요"
|
||||||
style={tsEcr.getWidth(col.key) ? { width: tsEcr.getWidth(col.key) } : undefined}
|
showRowNumber
|
||||||
>
|
showPagination={false}
|
||||||
{col.label}
|
draggableColumns={false}
|
||||||
</TableHead>
|
/>
|
||||||
))}
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{filteredEcr.length === 0 ? (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={tsEcr.visibleColumns.length + 1} className="h-32 text-center text-muted-foreground">
|
|
||||||
<div className="flex flex-col items-center gap-2">
|
|
||||||
<Inbox className="w-8 h-8 text-muted-foreground/50" />
|
|
||||||
<span>조건에 맞는 ECR이 없어요</span>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
) : (
|
) : (
|
||||||
filteredEcr.map((item, idx) => (
|
<EDataTable
|
||||||
<TableRow
|
columns={[
|
||||||
key={item.id}
|
{ key: "id", label: "ECN번호", width: "w-[140px]", render: (val: any) => <span className="font-semibold text-primary">{val}</span> },
|
||||||
className={cn(
|
{ key: "status", label: "상태", width: "w-[90px]", align: "center" as const, render: (val: any) => <span className={cn("px-2 py-0.5 rounded-full text-[11px] font-medium border", getEcnStatusStyle(val))}>{val}</span> },
|
||||||
"cursor-pointer hover:bg-muted/50 transition-colors",
|
{ key: "target", label: "대상 품목/설비", width: "w-[200px]" },
|
||||||
selectedId === item.id && "bg-primary/5"
|
{ key: "drawingAfter", label: "도면 (변경 후)", width: "w-[160px]", render: (val: any) => <span className="text-[13px] text-success font-medium">{val}</span> },
|
||||||
)}
|
{ key: "designer", label: "설계담당", width: "w-[80px]" },
|
||||||
onClick={() => handleRowClick(item.id)}
|
{ key: "date", label: "발행일자", width: "w-[100px]" },
|
||||||
>
|
{ key: "applyDate", label: "적용일자", width: "w-[100px]" },
|
||||||
<TableCell className="text-center text-muted-foreground">{idx + 1}</TableCell>
|
{ key: "notifyDepts", label: "통보 부서", width: "w-[140px]", render: (val: any) => <span className="text-[13px] text-muted-foreground">{Array.isArray(val) ? val.join(", ") : val}</span> },
|
||||||
{tsEcr.isVisible("request_no") && <TableCell style={tsEcr.thStyle("request_no")} className="font-semibold text-primary">{item.id}</TableCell>}
|
{ key: "ecrNo", label: "관련 ECR", width: "w-[130px]", render: (val: any) => <button className="px-2 py-0.5 rounded-full text-[11px] font-semibold bg-warning/10 text-warning border border-warning/20 hover:bg-warning/20 transition-colors" onClick={(e) => { e.stopPropagation(); navigateToLink(val); }}>{val} <ArrowRight className="w-3 h-3 inline" /></button> },
|
||||||
{tsEcr.isVisible("change_type") && (
|
] as EDataTableColumn<EcnItem>[]}
|
||||||
<TableCell style={tsEcr.thStyle("change_type")} className="text-center">
|
data={tsEcn.groupData(filteredEcn)}
|
||||||
<span className={cn("px-2 py-0.5 rounded-full text-[11px] font-medium border", getChangeTypeStyle(item.changeType))}>
|
rowKey={(row) => row.id}
|
||||||
{item.changeType}
|
selectedId={selectedId}
|
||||||
</span>
|
onSelect={(id) => { if (id) handleRowClick(id); }}
|
||||||
</TableCell>
|
onRowClick={(row) => handleRowClick(row.id)}
|
||||||
)}
|
emptyMessage="조건에 맞는 ECN이 없어요"
|
||||||
{tsEcr.isVisible("status") && (
|
showRowNumber
|
||||||
<TableCell style={tsEcr.thStyle("status")} className="text-center">
|
showPagination={false}
|
||||||
<span className={cn("px-2 py-0.5 rounded-full text-[11px] font-medium border", getEcrStatusStyle(item.status))}>
|
draggableColumns={false}
|
||||||
{item.status}
|
/>
|
||||||
</span>
|
|
||||||
</TableCell>
|
|
||||||
)}
|
|
||||||
{tsEcr.isVisible("urgency") && (
|
|
||||||
<TableCell style={tsEcr.thStyle("urgency")} className="text-center">
|
|
||||||
{item.urgency === "긴급" ? (
|
|
||||||
<span className="px-2 py-0.5 rounded-full text-[11px] font-medium border bg-destructive/10 text-destructive border-destructive/20">
|
|
||||||
긴급
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
"-"
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
)}
|
|
||||||
{tsEcr.isVisible("target_name") && <TableCell style={tsEcr.thStyle("target_name")} className="font-medium">{item.target}</TableCell>}
|
|
||||||
{tsEcr.isVisible("drawing_no") && <TableCell style={tsEcr.thStyle("drawing_no")} className="text-[13px] text-muted-foreground">{item.drawingNo}</TableCell>}
|
|
||||||
{tsEcr.isVisible("req_dept") && <TableCell style={tsEcr.thStyle("req_dept")}>{item.reqDept}</TableCell>}
|
|
||||||
{tsEcr.isVisible("requester") && <TableCell style={tsEcr.thStyle("requester")}>{item.requester}</TableCell>}
|
|
||||||
{tsEcr.isVisible("request_date") && <TableCell style={tsEcr.thStyle("request_date")}>{item.date}</TableCell>}
|
|
||||||
{tsEcr.isVisible("ecn_no") && (
|
|
||||||
<TableCell style={tsEcr.thStyle("ecn_no")}>
|
|
||||||
{item.ecnNo ? (
|
|
||||||
<button
|
|
||||||
className="px-2 py-0.5 rounded-full text-[11px] font-semibold bg-info/10 text-info border border-info/20 hover:bg-info/20 transition-colors"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
navigateToLink(item.ecnNo);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{item.ecnNo} <ArrowRight className="w-3 h-3 inline" />
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
"-"
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
)}
|
|
||||||
</TableRow>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
) : (
|
|
||||||
<Table style={{ tableLayout: "fixed" }}>
|
|
||||||
<TableHeader className="sticky top-0 z-10">
|
|
||||||
<TableRow className="bg-muted hover:bg-muted">
|
|
||||||
<TableHead className="w-[50px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">No</TableHead>
|
|
||||||
{tsEcn.visibleColumns.map((col) => (
|
|
||||||
<TableHead
|
|
||||||
key={col.key}
|
|
||||||
className={cn(
|
|
||||||
col.key === "ecn_no" && "w-[140px]",
|
|
||||||
col.key === "status" && "w-[90px] text-center",
|
|
||||||
col.key === "target" && "w-[200px]",
|
|
||||||
col.key === "drawing_after" && "w-[160px]",
|
|
||||||
col.key === "designer" && "w-[80px]",
|
|
||||||
col.key === "ecn_date" && "w-[100px]",
|
|
||||||
col.key === "apply_date" && "w-[100px]",
|
|
||||||
col.key === "notify_depts" && "w-[140px]",
|
|
||||||
col.key === "ecr_id" && "w-[130px]",
|
|
||||||
)}
|
|
||||||
style={tsEcn.getWidth(col.key) ? { width: tsEcn.getWidth(col.key) } : undefined}
|
|
||||||
>
|
|
||||||
{col.label}
|
|
||||||
</TableHead>
|
|
||||||
))}
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{filteredEcn.length === 0 ? (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={tsEcn.visibleColumns.length + 1} className="h-32 text-center text-muted-foreground">
|
|
||||||
<div className="flex flex-col items-center gap-2">
|
|
||||||
<Inbox className="w-8 h-8 text-muted-foreground/50" />
|
|
||||||
<span>조건에 맞는 ECN이 없어요</span>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
) : (
|
|
||||||
filteredEcn.map((item, idx) => (
|
|
||||||
<TableRow
|
|
||||||
key={item.id}
|
|
||||||
className={cn(
|
|
||||||
"cursor-pointer hover:bg-muted/50 transition-colors",
|
|
||||||
selectedId === item.id && "bg-primary/5"
|
|
||||||
)}
|
|
||||||
onClick={() => handleRowClick(item.id)}
|
|
||||||
>
|
|
||||||
<TableCell className="text-center text-muted-foreground">{idx + 1}</TableCell>
|
|
||||||
{tsEcn.isVisible("ecn_no") && <TableCell style={tsEcn.thStyle("ecn_no")} className="font-semibold text-primary">{item.id}</TableCell>}
|
|
||||||
{tsEcn.isVisible("status") && (
|
|
||||||
<TableCell style={tsEcn.thStyle("status")} className="text-center">
|
|
||||||
<span className={cn("px-2 py-0.5 rounded-full text-[11px] font-medium border", getEcnStatusStyle(item.status))}>
|
|
||||||
{item.status}
|
|
||||||
</span>
|
|
||||||
</TableCell>
|
|
||||||
)}
|
|
||||||
{tsEcn.isVisible("target") && <TableCell style={tsEcn.thStyle("target")} className="font-medium">{item.target}</TableCell>}
|
|
||||||
{tsEcn.isVisible("drawing_after") && <TableCell style={tsEcn.thStyle("drawing_after")} className="text-[13px] text-success font-medium">{item.drawingAfter}</TableCell>}
|
|
||||||
{tsEcn.isVisible("designer") && <TableCell style={tsEcn.thStyle("designer")}>{item.designer}</TableCell>}
|
|
||||||
{tsEcn.isVisible("ecn_date") && <TableCell style={tsEcn.thStyle("ecn_date")}>{item.date}</TableCell>}
|
|
||||||
{tsEcn.isVisible("apply_date") && <TableCell style={tsEcn.thStyle("apply_date")}>{item.applyDate}</TableCell>}
|
|
||||||
{tsEcn.isVisible("notify_depts") && <TableCell style={tsEcn.thStyle("notify_depts")} className="text-[13px] text-muted-foreground">{item.notifyDepts.join(", ")}</TableCell>}
|
|
||||||
{tsEcn.isVisible("ecr_id") && (
|
|
||||||
<TableCell style={tsEcn.thStyle("ecr_id")}>
|
|
||||||
<button
|
|
||||||
className="px-2 py-0.5 rounded-full text-[11px] font-semibold bg-warning/10 text-warning border border-warning/20 hover:bg-warning/20 transition-colors"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
navigateToLink(item.ecrNo);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{item.ecrNo} <ArrowRight className="w-3 h-3 inline" />
|
|
||||||
</button>
|
|
||||||
</TableCell>
|
|
||||||
)}
|
|
||||||
</TableRow>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
|
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
|
||||||
import {
|
import {
|
||||||
getDesignRequestList,
|
getDesignRequestList,
|
||||||
createDesignRequest,
|
createDesignRequest,
|
||||||
@@ -460,95 +461,42 @@ export default function DesignRequestPage() {
|
|||||||
|
|
||||||
{/* 테이블 영역 */}
|
{/* 테이블 영역 */}
|
||||||
<div className="flex-1 flex flex-col overflow-hidden border rounded-lg bg-card">
|
<div className="flex-1 flex flex-col overflow-hidden border rounded-lg bg-card">
|
||||||
<div className="flex-1 overflow-auto">
|
<EDataTable<DesignRequest>
|
||||||
{loading ? (
|
columns={ts.visibleColumns.map((col): EDataTableColumn<DesignRequest> => ({
|
||||||
<div className="flex items-center justify-center py-16">
|
key: col.key,
|
||||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
label: col.label,
|
||||||
<span className="ml-2 text-sm text-muted-foreground">불러오는 중...</span>
|
width: col.key === "request_no" ? "w-[100px]" : col.key === "design_type" ? "w-[70px]" : col.key === "status" ? "w-[70px]" : col.key === "priority" ? "w-[60px]" : col.key === "customer" ? "w-[90px]" : col.key === "designer" ? "w-[70px]" : col.key === "due_date" ? "w-[85px]" : col.key === "progress" ? "w-[65px]" : undefined,
|
||||||
</div>
|
align: (col.key === "design_type" || col.key === "status" || col.key === "priority" || col.key === "progress") ? "center" : undefined,
|
||||||
) : (
|
render: col.key === "request_no"
|
||||||
<Table style={{ tableLayout: "fixed" }}>
|
? (val: any) => <span className="text-[11px] font-semibold text-primary">{val || "-"}</span>
|
||||||
<TableHeader className="sticky top-0 z-10">
|
: col.key === "design_type"
|
||||||
<TableRow className="bg-muted hover:bg-muted">
|
? (val: any) => val ? <Badge className={cn("text-[9px]", TYPE_STYLES[val])}>{val}</Badge> : <span>-</span>
|
||||||
{ts.visibleColumns.map((col) => (
|
: col.key === "status"
|
||||||
<TableHead
|
? (val: any) => <Badge className={cn("text-[9px]", STATUS_STYLES[val])}>{val}</Badge>
|
||||||
key={col.key}
|
: col.key === "priority"
|
||||||
className={cn(
|
? (val: any) => <Badge className={cn("text-[9px]", PRIORITY_STYLES[val])}>{val}</Badge>
|
||||||
"text-[11px]",
|
: col.key === "progress"
|
||||||
col.key === "request_no" && "w-[100px]",
|
? (_val: any, row: DesignRequest) => {
|
||||||
col.key === "design_type" && "w-[70px] text-center",
|
const progress = STATUS_PROGRESS[row.status] ?? 0;
|
||||||
col.key === "status" && "w-[70px] text-center",
|
|
||||||
col.key === "priority" && "w-[60px] text-center",
|
|
||||||
col.key === "customer" && "w-[90px]",
|
|
||||||
col.key === "designer" && "w-[70px]",
|
|
||||||
col.key === "due_date" && "w-[85px]",
|
|
||||||
col.key === "progress" && "w-[65px] text-center",
|
|
||||||
)}
|
|
||||||
style={ts.thStyle(col.key)}
|
|
||||||
>
|
|
||||||
{col.label}
|
|
||||||
</TableHead>
|
|
||||||
))}
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{filteredRequests.length === 0 && (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={ts.visibleColumns.length} className="py-12 text-center">
|
|
||||||
<div className="flex flex-col items-center gap-1 text-muted-foreground">
|
|
||||||
<Inbox className="h-8 w-8" />
|
|
||||||
<span className="text-sm">등록된 설계의뢰가 없어요</span>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)}
|
|
||||||
{filteredRequests.map((item) => {
|
|
||||||
const progress = getProgress(item.status);
|
|
||||||
return (
|
return (
|
||||||
<TableRow
|
|
||||||
key={item.id}
|
|
||||||
className={cn("cursor-pointer", selectedId === item.id && "bg-accent")}
|
|
||||||
onClick={() => handleRowClick(item.id)}
|
|
||||||
>
|
|
||||||
{ts.isVisible("request_no") && <TableCell className="text-[11px] font-semibold text-primary" style={ts.thStyle("request_no")}>{item.request_no || "-"}</TableCell>}
|
|
||||||
{ts.isVisible("design_type") && (
|
|
||||||
<TableCell className="text-center" style={ts.thStyle("design_type")}>
|
|
||||||
{item.design_type ? (
|
|
||||||
<Badge className={cn("text-[9px]", TYPE_STYLES[item.design_type])}>{item.design_type}</Badge>
|
|
||||||
) : "-"}
|
|
||||||
</TableCell>
|
|
||||||
)}
|
|
||||||
{ts.isVisible("status") && (
|
|
||||||
<TableCell className="text-center" style={ts.thStyle("status")}>
|
|
||||||
<Badge className={cn("text-[9px]", STATUS_STYLES[item.status])}>{item.status}</Badge>
|
|
||||||
</TableCell>
|
|
||||||
)}
|
|
||||||
{ts.isVisible("priority") && (
|
|
||||||
<TableCell className="text-center" style={ts.thStyle("priority")}>
|
|
||||||
<Badge className={cn("text-[9px]", PRIORITY_STYLES[item.priority])}>{item.priority}</Badge>
|
|
||||||
</TableCell>
|
|
||||||
)}
|
|
||||||
{ts.isVisible("target_name") && <TableCell className="text-[13px] font-medium" style={ts.thStyle("target_name")}>{item.target_name || "-"}</TableCell>}
|
|
||||||
{ts.isVisible("customer") && <TableCell className="text-[11px]" style={ts.thStyle("customer")}>{item.customer || "-"}</TableCell>}
|
|
||||||
{ts.isVisible("designer") && <TableCell className="text-[11px]" style={ts.thStyle("designer")}>{item.designer || "-"}</TableCell>}
|
|
||||||
{ts.isVisible("due_date") && <TableCell className="text-[11px]" style={ts.thStyle("due_date")}>{item.due_date || "-"}</TableCell>}
|
|
||||||
{ts.isVisible("progress") && (
|
|
||||||
<TableCell style={ts.thStyle("progress")}>
|
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<div className="h-1.5 w-12 overflow-hidden rounded-full bg-muted">
|
<div className="h-1.5 w-12 overflow-hidden rounded-full bg-muted">
|
||||||
<div className={cn("h-full rounded-full transition-all", getProgressColor(progress))} style={{ width: `${progress}%` }} />
|
<div className={cn("h-full rounded-full transition-all", getProgressColor(progress))} style={{ width: `${progress}%` }} />
|
||||||
</div>
|
</div>
|
||||||
<span className={cn("text-[10px] font-semibold", getProgressTextColor(progress))}>{progress}%</span>
|
<span className={cn("text-[10px] font-semibold", getProgressTextColor(progress))}>{progress}%</span>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
|
||||||
)}
|
|
||||||
</TableRow>
|
|
||||||
);
|
);
|
||||||
})}
|
}
|
||||||
</TableBody>
|
: undefined,
|
||||||
</Table>
|
}))}
|
||||||
)}
|
data={ts.groupData(filteredRequests)}
|
||||||
</div>
|
loading={loading}
|
||||||
|
emptyMessage="등록된 설계의뢰가 없어요"
|
||||||
|
selectedId={selectedId}
|
||||||
|
onSelect={(id) => setSelectedId(id)}
|
||||||
|
onRowClick={(row) => handleRowClick(row.id)}
|
||||||
|
draggableColumns={false}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 상세 정보 다이얼로그 */}
|
{/* 상세 정보 다이얼로그 */}
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ import {
|
|||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
import {
|
import {
|
||||||
getMyWork,
|
getMyWork,
|
||||||
@@ -1244,66 +1245,50 @@ export default function MyWorkPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{viewMode === "list" && (
|
{viewMode === "list" && (
|
||||||
<Table>
|
<EDataTable
|
||||||
<TableHeader className="sticky top-0 z-10">
|
columns={[
|
||||||
<TableRow className="bg-muted hover:bg-muted">
|
{ key: "projectId", label: "프로젝트", width: "w-[90px]", render: (_v, row) => (
|
||||||
<TableHead className="w-[90px] text-[11px] font-bold uppercase tracking-wide">프로젝트</TableHead>
|
<div className="text-[10px]">
|
||||||
<TableHead className="text-[11px] font-bold uppercase tracking-wide">업무명</TableHead>
|
<span className="font-semibold text-primary">{row.projectId}</span>
|
||||||
<TableHead className="w-[65px] text-[11px] font-bold uppercase tracking-wide">유형</TableHead>
|
<br />
|
||||||
<TableHead className="w-[55px] text-center text-[11px] font-bold uppercase tracking-wide">상태</TableHead>
|
<span className="text-muted-foreground">{row.projectName}</span>
|
||||||
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide">종료일</TableHead>
|
</div>
|
||||||
<TableHead className="w-[70px] text-[11px] font-bold uppercase tracking-wide">진행률</TableHead>
|
)},
|
||||||
</TableRow>
|
{ key: "name", label: "업무명" },
|
||||||
</TableHeader>
|
{ key: "category", label: "유형", width: "w-[65px]" },
|
||||||
<TableBody>
|
{ key: "status", label: "상태", width: "w-[55px]", align: "center", render: (_v, row) => {
|
||||||
{filteredTasks
|
const isDelay = row.status !== "완료" && new Date(row.end) < today;
|
||||||
.sort((a, b) => {
|
const displayStatus = isDelay ? "지연" : row.status;
|
||||||
|
return <Badge className={cn("text-[10px]", STATUS_STYLES[displayStatus])}>{displayStatus}</Badge>;
|
||||||
|
}},
|
||||||
|
{ key: "end", label: "종료일", width: "w-[80px]", render: (v, row) => {
|
||||||
|
const isDelay = row.status !== "완료" && new Date(row.end) < today;
|
||||||
|
return <span className={cn("text-[11px]", isDelay && "font-semibold text-destructive")}>{v}</span>;
|
||||||
|
}},
|
||||||
|
{ key: "progress", label: "진행률", width: "w-[70px]", sortable: true, render: (v) => (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<div className="h-1 w-12 overflow-hidden rounded-full bg-muted">
|
||||||
|
<div className={cn("h-full rounded-full", getProgressBg(v))} style={{ width: `${v}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px]">{v}%</span>
|
||||||
|
</div>
|
||||||
|
)},
|
||||||
|
] as EDataTableColumn<MyTask>[]}
|
||||||
|
data={[...filteredTasks].sort((a, b) => {
|
||||||
const ad = a.status !== "완료" && new Date(a.end) < today;
|
const ad = a.status !== "완료" && new Date(a.end) < today;
|
||||||
const bd = b.status !== "완료" && new Date(b.end) < today;
|
const bd = b.status !== "완료" && new Date(b.end) < today;
|
||||||
if (ad && !bd) return -1;
|
if (ad && !bd) return -1;
|
||||||
if (!ad && bd) return 1;
|
if (!ad && bd) return 1;
|
||||||
const ord: Record<string, number> = { 진행중: 0, 대기: 1, 검토중: 2, 완료: 3 };
|
const ord: Record<string, number> = { 진행중: 0, 대기: 1, 검토중: 2, 완료: 3 };
|
||||||
return (ord[a.status] ?? 9) - (ord[b.status] ?? 9);
|
return (ord[a.status] ?? 9) - (ord[b.status] ?? 9);
|
||||||
})
|
|
||||||
.map((t) => {
|
|
||||||
const isDelay = t.status !== "완료" && new Date(t.end) < today;
|
|
||||||
const displayStatus = isDelay ? "지연" : t.status;
|
|
||||||
const isSelected = selectedTaskKey === `${t.projectId}||${t.name}`;
|
|
||||||
return (
|
|
||||||
<TableRow
|
|
||||||
key={`${t.projectId}-${t.name}`}
|
|
||||||
className={cn("cursor-pointer transition-colors hover:bg-muted/30", isSelected && "bg-primary/5")}
|
|
||||||
onClick={() => handleSelectTask(t.projectId, t.name)}
|
|
||||||
>
|
|
||||||
<TableCell className="text-[10px]">
|
|
||||||
<span className="font-semibold text-primary">{t.projectId}</span>
|
|
||||||
<br />
|
|
||||||
<span className="text-muted-foreground">{t.projectName}</span>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-[13px] font-medium">{t.name}</TableCell>
|
|
||||||
<TableCell className="text-[11px]">{t.category}</TableCell>
|
|
||||||
<TableCell className="text-center">
|
|
||||||
<Badge className={cn("text-[10px]", STATUS_STYLES[displayStatus])}>{displayStatus}</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className={cn("text-[11px]", isDelay && "font-semibold text-destructive")}>{t.end}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<div className="h-1 w-12 overflow-hidden rounded-full bg-muted">
|
|
||||||
<div className={cn("h-full rounded-full", getProgressBg(t.progress))} style={{ width: `${t.progress}%` }} />
|
|
||||||
</div>
|
|
||||||
<span className="text-[10px]">{t.progress}%</span>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
);
|
|
||||||
})}
|
})}
|
||||||
{filteredTasks.length === 0 && (
|
rowKey={(row) => `${row.projectId}||${row.name}`}
|
||||||
<TableRow>
|
selectedId={selectedTaskKey}
|
||||||
<TableCell colSpan={6} className="py-8 text-center text-[13px] text-muted-foreground">검색 결과가 없어요</TableCell>
|
onRowClick={(row) => handleSelectTask(row.projectId, row.name)}
|
||||||
</TableRow>
|
emptyMessage="검색 결과가 없어요"
|
||||||
)}
|
showPagination={false}
|
||||||
</TableBody>
|
draggableColumns={false}
|
||||||
</Table>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{viewMode === "timesheet" && (
|
{viewMode === "timesheet" && (
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ import {
|
|||||||
import { useTableSettings } from "@/hooks/useTableSettings";
|
import { useTableSettings } from "@/hooks/useTableSettings";
|
||||||
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
||||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||||
|
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
|
||||||
|
|
||||||
// --- Types ---
|
// --- Types ---
|
||||||
type ProjectStatus = "진행중" | "계획" | "보류" | "완료";
|
type ProjectStatus = "진행중" | "계획" | "보류" | "완료";
|
||||||
@@ -728,133 +729,63 @@ export default function DesignProjectPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
<Table style={{ tableLayout: "fixed" }}>
|
<EDataTable
|
||||||
<TableHeader className="sticky top-0 z-10">
|
columns={[
|
||||||
<TableRow className="bg-muted hover:bg-muted">
|
{ key: "projectNo", label: "프로젝트번호", width: "w-[160px]", render: (_val: any, row: any) => {
|
||||||
{ts.visibleColumns.map((col) => (
|
const depth = row._depth ?? 0;
|
||||||
<TableHead
|
const hasChildren = filteredProjects.some((c) => c.parentId === row.id);
|
||||||
key={col.key}
|
const isExpanded = expandedIds[row.id] !== false;
|
||||||
className={cn(
|
|
||||||
"text-[11px] uppercase tracking-wide",
|
|
||||||
col.key === "status" && "w-[80px] text-center",
|
|
||||||
col.key === "project_no" && "w-[160px]",
|
|
||||||
col.key === "name" && "w-[200px]",
|
|
||||||
col.key === "pm" && "w-[70px]",
|
|
||||||
col.key === "customer" && "w-[80px]",
|
|
||||||
col.key === "start_date" && "w-[90px]",
|
|
||||||
col.key === "end_date" && "w-[90px]",
|
|
||||||
col.key === "progress" && "w-[100px] text-center",
|
|
||||||
col.key === "source_no" && "w-[90px]",
|
|
||||||
)}
|
|
||||||
style={ts.thStyle(col.key)}
|
|
||||||
>
|
|
||||||
{col.label}
|
|
||||||
</TableHead>
|
|
||||||
))}
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{loading ? (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={ts.visibleColumns.length} className="h-32 text-center text-muted-foreground">
|
|
||||||
<Loader2 className="w-10 h-10 mx-auto mb-2 animate-spin text-muted-foreground" />
|
|
||||||
<div className="text-sm">로딩 중...</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
) : treeRows.length === 0 ? (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={ts.visibleColumns.length} className="h-32 text-center">
|
|
||||||
<div className="flex flex-col items-center gap-2 py-6 mx-auto max-w-xs border border-dashed border-border rounded-lg">
|
|
||||||
<Rocket className="w-10 h-10 text-muted-foreground/30" />
|
|
||||||
<span className="text-sm text-muted-foreground">조건에 맞는 프로젝트가 없어요</span>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
) : (
|
|
||||||
treeRows.map(({ project: p, depth }) => {
|
|
||||||
const hasChildren = filteredProjects.some((c) => c.parentId === p.id);
|
|
||||||
const isExpanded = expandedIds[p.id] !== false;
|
|
||||||
const childCount = getAllDescendants(projects, p.id).length;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRow
|
|
||||||
key={p.id}
|
|
||||||
className={cn(
|
|
||||||
"cursor-pointer hover:bg-muted/50 transition-colors border-l-[3px]",
|
|
||||||
selectedId === p.id
|
|
||||||
? "bg-primary/5 border-l-primary"
|
|
||||||
: "border-l-transparent",
|
|
||||||
depth === 1 && "bg-muted/30",
|
|
||||||
depth >= 2 && "bg-muted/15"
|
|
||||||
)}
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedId(p.id);
|
|
||||||
setDetailTab("wbs");
|
|
||||||
fetchTaskDetails(p.id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{ts.isVisible("project_no") && (
|
|
||||||
<TableCell style={ts.thStyle("project_no")}>
|
|
||||||
<div className="flex items-center gap-1" style={{ paddingLeft: depth * 20 }}>
|
<div className="flex items-center gap-1" style={{ paddingLeft: depth * 20 }}>
|
||||||
{hasChildren ? (
|
{hasChildren ? (
|
||||||
<button
|
<button className="p-0.5 rounded hover:bg-muted transition-transform" onClick={(e) => { e.stopPropagation(); toggleExpand(row.id); }}>
|
||||||
className="p-0.5 rounded hover:bg-muted transition-transform"
|
|
||||||
onClick={(e) => { e.stopPropagation(); toggleExpand(p.id); }}
|
|
||||||
>
|
|
||||||
<ChevronRight className={cn("w-3.5 h-3.5 text-muted-foreground transition-transform", isExpanded && "rotate-90")} />
|
<ChevronRight className={cn("w-3.5 h-3.5 text-muted-foreground transition-transform", isExpanded && "rotate-90")} />
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (<span className="w-4" />)}
|
||||||
<span className="w-4" />
|
<span className={cn("font-semibold text-xs", depth === 0 ? "text-primary" : depth === 1 ? "text-primary/80" : "text-primary/60")}>{row.projectNo}</span>
|
||||||
)}
|
{row.relation && (<span className={cn("px-1.5 py-0.5 rounded text-[10px] font-medium", getRelationColor(row.relation))}>{getRelationLabel(row.relation)}</span>)}
|
||||||
<span className={cn("font-semibold text-xs", depth === 0 ? "text-primary" : depth === 1 ? "text-primary/80" : "text-primary/60")}>
|
|
||||||
{p.projectNo}
|
|
||||||
</span>
|
|
||||||
{p.relation && (
|
|
||||||
<span className={cn("px-1.5 py-0.5 rounded text-[10px] font-medium", getRelationColor(p.relation))}>
|
|
||||||
{getRelationLabel(p.relation)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
);
|
||||||
)}
|
}},
|
||||||
{ts.isVisible("status") && (
|
{ key: "status", label: "상태", width: "w-[80px]", align: "center" as const, render: (val: any) => <span className={cn("px-2 py-0.5 rounded-full text-[11px] font-medium border", getStatusColor(val))}>{val}</span> },
|
||||||
<TableCell className="text-center" style={ts.thStyle("status")}>
|
{ key: "name", label: "프로젝트명", width: "w-[200px]", render: (val: any, row: any) => {
|
||||||
<span className={cn("px-2 py-0.5 rounded-full text-[11px] font-medium border", getStatusColor(p.status))}>
|
const childCount = getAllDescendants(projects, row.id).length;
|
||||||
{p.status}
|
return (<><span className="font-medium text-sm">{val}</span>{childCount > 0 && <Badge variant="outline" className="ml-1.5 text-[10px] py-0 px-1.5 font-normal"><FolderOpen className="w-3 h-3 mr-0.5" /> {childCount}</Badge>}</>);
|
||||||
</span>
|
}},
|
||||||
</TableCell>
|
{ key: "pm", label: "PM", width: "w-[70px]" },
|
||||||
)}
|
{ key: "customer", label: "고객", width: "w-[80px]" },
|
||||||
{ts.isVisible("name") && (
|
{ key: "startDate", label: "시작일", width: "w-[90px]" },
|
||||||
<TableCell className="font-medium text-sm" style={ts.thStyle("name")}>
|
{ key: "endDate", label: "종료예정", width: "w-[90px]" },
|
||||||
{p.name}
|
{ key: "progress", label: "진행률", width: "w-[100px]", align: "center" as const, render: (val: any) => (
|
||||||
{childCount > 0 && (
|
|
||||||
<Badge variant="outline" className="ml-1.5 text-[10px] py-0 px-1.5 font-normal">
|
|
||||||
<FolderOpen className="w-3 h-3 mr-0.5" /> {childCount}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
)}
|
|
||||||
{ts.isVisible("pm") && <TableCell className="text-[13px]" style={ts.thStyle("pm")}>{p.pm}</TableCell>}
|
|
||||||
{ts.isVisible("customer") && <TableCell className="text-[13px]" style={ts.thStyle("customer")}>{p.customer}</TableCell>}
|
|
||||||
{ts.isVisible("start_date") && <TableCell className="text-[13px] text-muted-foreground" style={ts.thStyle("start_date")}>{p.startDate}</TableCell>}
|
|
||||||
{ts.isVisible("end_date") && <TableCell className="text-[13px] text-muted-foreground" style={ts.thStyle("end_date")}>{p.endDate}</TableCell>}
|
|
||||||
{ts.isVisible("progress") && (
|
|
||||||
<TableCell style={ts.thStyle("progress")}>
|
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<div className="flex-1 h-1.5 bg-muted rounded-full overflow-hidden">
|
<div className="flex-1 h-1.5 bg-muted rounded-full overflow-hidden">
|
||||||
<div className={cn("h-full rounded-full transition-all", progressColor(p.progress))} style={{ width: `${p.progress}%` }} />
|
<div className={cn("h-full rounded-full transition-all", progressColor(val))} style={{ width: `${val}%` }} />
|
||||||
</div>
|
</div>
|
||||||
<span className={cn("text-[11px] font-medium min-w-[28px] text-right", progressTextColor(p.progress))}>{p.progress}%</span>
|
<span className={cn("text-[11px] font-medium min-w-[28px] text-right", progressTextColor(val))}>{val}%</span>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
)},
|
||||||
)}
|
{ key: "sourceNo", label: "원접수번호", width: "w-[90px]", render: (val: any) => <span className="text-[13px] text-muted-foreground">{val || "-"}</span> },
|
||||||
{ts.isVisible("source_no") && <TableCell className="text-[13px] text-muted-foreground" style={ts.thStyle("source_no")}>{p.sourceNo || "-"}</TableCell>}
|
] as EDataTableColumn[]}
|
||||||
</TableRow>
|
data={ts.groupData(treeRows.map(({ project: p, depth }) => ({ ...p, _depth: depth })))}
|
||||||
);
|
rowKey={(row) => row.id}
|
||||||
})
|
loading={loading}
|
||||||
)}
|
emptyMessage="조건에 맞는 프로젝트가 없어요"
|
||||||
</TableBody>
|
selectedId={selectedId}
|
||||||
</Table>
|
onSelect={(id) => {
|
||||||
|
if (id) {
|
||||||
|
setSelectedId(id);
|
||||||
|
setDetailTab("wbs");
|
||||||
|
fetchTaskDetails(id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onRowClick={(row) => {
|
||||||
|
setSelectedId(row.id);
|
||||||
|
setDetailTab("wbs");
|
||||||
|
fetchTaskDetails(row.id);
|
||||||
|
}}
|
||||||
|
showPagination={false}
|
||||||
|
draggableColumns={false}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ import {
|
|||||||
Users,
|
Users,
|
||||||
Settings2,
|
Settings2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
@@ -749,108 +750,49 @@ export default function DesignTaskManagementPage() {
|
|||||||
<Settings2 className="h-4 w-4" />
|
<Settings2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-auto">
|
<EDataTable<TaskItem>
|
||||||
<Table style={{ tableLayout: "fixed" }}>
|
columns={ts.visibleColumns.map((col): EDataTableColumn<TaskItem> => ({
|
||||||
<TableHeader className="sticky top-0 z-10">
|
key: col.key === "request_no" ? "id" : col.key === "target_name" ? "targetName" : col.key === "req_dept" ? "reqDept" : col.key === "request_date" ? "date" : col.key === "due_date" ? "dueDate" : col.key === "source_type" ? "sourceType" : col.key,
|
||||||
<TableRow className="bg-muted hover:bg-muted">
|
label: col.label,
|
||||||
{ts.visibleColumns.map((col) => (
|
width: col.key === "source_type" ? "w-[60px]" : col.key === "request_no" ? "w-[130px]" : col.key === "status" ? "w-[90px]" : col.key === "priority" ? "w-[80px]" : col.key === "target_name" ? "min-w-[180px]" : col.key === "req_dept" ? "w-[90px]" : col.key === "requester" ? "w-[80px]" : col.key === "request_date" ? "w-[100px]" : col.key === "due_date" ? "w-[100px]" : col.key === "designer" ? "w-[80px]" : undefined,
|
||||||
<TableHead
|
align: (col.key === "source_type" || col.key === "status" || col.key === "priority") ? "center" : undefined,
|
||||||
key={col.key}
|
render: col.key === "source_type"
|
||||||
className={cn(
|
? (val: any, row: TaskItem) => (
|
||||||
"text-[11px] uppercase tracking-wide",
|
<Badge variant="outline" className={cn("text-[10px] font-bold", getSourceBadge(row.sourceType))}>
|
||||||
col.key === "source_type" && "w-[60px] text-center",
|
{row.sourceType === "dr" ? "DR" : "ECR"}
|
||||||
col.key === "request_no" && "w-[130px]",
|
|
||||||
col.key === "status" && "w-[90px] text-center",
|
|
||||||
col.key === "priority" && "w-[80px] text-center",
|
|
||||||
col.key === "target_name" && "min-w-[180px]",
|
|
||||||
col.key === "req_dept" && "w-[90px]",
|
|
||||||
col.key === "requester" && "w-[80px]",
|
|
||||||
col.key === "request_date" && "w-[100px]",
|
|
||||||
col.key === "due_date" && "w-[100px]",
|
|
||||||
col.key === "designer" && "w-[80px]",
|
|
||||||
)}
|
|
||||||
style={ts.thStyle(col.key)}
|
|
||||||
>
|
|
||||||
{col.label}
|
|
||||||
</TableHead>
|
|
||||||
))}
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{loading && allTasks.length === 0 ? (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={ts.visibleColumns.length}>
|
|
||||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
|
||||||
<Loader2 className="mb-2 h-10 w-10 animate-spin" />
|
|
||||||
<span className="text-sm">로딩 중...</span>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
) : filteredData.length === 0 ? (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={ts.visibleColumns.length}>
|
|
||||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
|
||||||
<Inbox className="mb-2 h-10 w-10" />
|
|
||||||
<span className="text-sm">조건에 맞는 업무가 없어요</span>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
) : (
|
|
||||||
filteredData.map((item) => (
|
|
||||||
<TableRow
|
|
||||||
key={item.id}
|
|
||||||
className={cn(
|
|
||||||
"cursor-pointer transition-colors border-l-[3px]",
|
|
||||||
selectedTaskId === item.dbId
|
|
||||||
? "bg-primary/5 border-l-primary"
|
|
||||||
: item.status === "신규접수"
|
|
||||||
? "bg-primary/5 border-l-primary/40"
|
|
||||||
: "border-l-transparent"
|
|
||||||
)}
|
|
||||||
onClick={() => handleSelectTask(item.dbId)}
|
|
||||||
>
|
|
||||||
{ts.isVisible("source_type") && (
|
|
||||||
<TableCell className="text-center" style={ts.thStyle("source_type")}>
|
|
||||||
<Badge variant="outline" className={cn("text-[10px] font-bold", getSourceBadge(item.sourceType))}>
|
|
||||||
{item.sourceType === "dr" ? "DR" : "ECR"}
|
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
)
|
||||||
)}
|
: col.key === "request_no"
|
||||||
{ts.isVisible("request_no") && (
|
? (val: any, row: TaskItem) => (
|
||||||
<TableCell className={cn("text-[13px] font-semibold", item.sourceType === "dr" ? "text-primary" : "text-secondary-foreground")} style={ts.thStyle("request_no")}>
|
<span className={cn("text-[13px] font-semibold", row.sourceType === "dr" ? "text-primary" : "text-secondary-foreground")}>
|
||||||
{item.id}
|
{val}
|
||||||
</TableCell>
|
</span>
|
||||||
)}
|
)
|
||||||
{ts.isVisible("status") && (
|
: col.key === "status"
|
||||||
<TableCell className="text-center" style={ts.thStyle("status")}>
|
? (val: any) => (
|
||||||
<Badge variant="outline" className={cn("text-[10px]", getStatusVariant(item.status))}>
|
<Badge variant="outline" className={cn("text-[10px]", getStatusVariant(val))}>
|
||||||
{item.status}
|
{val}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
)
|
||||||
)}
|
: col.key === "priority"
|
||||||
{ts.isVisible("priority") && (
|
? (val: any) => (
|
||||||
<TableCell className="text-center" style={ts.thStyle("priority")}>
|
<Badge variant="outline" className={cn("text-[10px]", getPriorityVariant(val))}>
|
||||||
<Badge variant="outline" className={cn("text-[10px]", getPriorityVariant(item.priority))}>
|
{val}
|
||||||
{item.priority}
|
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
)
|
||||||
)}
|
: col.key === "designer"
|
||||||
{ts.isVisible("target_name") && <TableCell className="max-w-[200px] truncate text-[13px] font-medium" style={ts.thStyle("target_name")}>{item.targetName}</TableCell>}
|
? (val: any) => val ? <span>{val}</span> : <span className="text-muted-foreground">미배정</span>
|
||||||
{ts.isVisible("req_dept") && <TableCell className="text-[13px]" style={ts.thStyle("req_dept")}>{item.reqDept}</TableCell>}
|
: undefined,
|
||||||
{ts.isVisible("requester") && <TableCell className="text-[13px]" style={ts.thStyle("requester")}>{item.requester}</TableCell>}
|
}))}
|
||||||
{ts.isVisible("request_date") && <TableCell className="text-[13px]" style={ts.thStyle("request_date")}>{item.date}</TableCell>}
|
data={ts.groupData(filteredData)}
|
||||||
{ts.isVisible("due_date") && <TableCell className="text-[13px]" style={ts.thStyle("due_date")}>{item.dueDate}</TableCell>}
|
loading={loading}
|
||||||
{ts.isVisible("designer") && (
|
emptyMessage="조건에 맞는 업무가 없어요"
|
||||||
<TableCell className="text-[13px]" style={ts.thStyle("designer")}>
|
rowKey={(row) => row.dbId}
|
||||||
{item.designer || <span className="text-muted-foreground">미배정</span>}
|
selectedId={selectedTaskId}
|
||||||
</TableCell>
|
onSelect={(id) => handleSelectTask(id ?? "")}
|
||||||
)}
|
draggableColumns={false}
|
||||||
</TableRow>
|
showPagination={false}
|
||||||
))
|
/>
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
* 점검항목 복사 기능 포함
|
* 점검항목 복사 기능 포함
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback } from "react";
|
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
@@ -34,6 +34,7 @@ import { ImageUpload } from "@/components/common/ImageUpload";
|
|||||||
import { useTableSettings } from "@/hooks/useTableSettings";
|
import { useTableSettings } from "@/hooks/useTableSettings";
|
||||||
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
||||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||||
|
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
|
||||||
|
|
||||||
const EQUIP_TABLE = "equipment_mng";
|
const EQUIP_TABLE = "equipment_mng";
|
||||||
const INSPECTION_TABLE = "equipment_inspection_item";
|
const INSPECTION_TABLE = "equipment_inspection_item";
|
||||||
@@ -138,6 +139,17 @@ export default function EquipmentInfoPage() {
|
|||||||
return catOptions[col]?.find((o) => o.code === code)?.label || code;
|
return catOptions[col]?.find((o) => o.code === code)?.label || code;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const mainTableColumns = useMemo<EDataTableColumn[]>(() => {
|
||||||
|
const cols: EDataTableColumn[] = [];
|
||||||
|
if (ts.isVisible("equipment_code")) cols.push({ key: "equipment_code", label: "설비코드", width: "w-[110px]" });
|
||||||
|
if (ts.isVisible("equipment_name")) cols.push({ key: "equipment_name", label: "설비명", minWidth: "min-w-[130px]", truncate: true, render: (v) => v || "-" });
|
||||||
|
if (ts.isVisible("equipment_type")) cols.push({ key: "equipment_type", label: "설비유형", width: "w-[90px]", render: (v) => v || "-" });
|
||||||
|
if (ts.isVisible("manufacturer")) cols.push({ key: "manufacturer", label: "제조사", width: "w-[100px]", render: (v) => v || "-" });
|
||||||
|
if (ts.isVisible("installation_location")) cols.push({ key: "installation_location", label: "설치장소", width: "w-[100px]", render: (v) => v || "-" });
|
||||||
|
if (ts.isVisible("operation_status")) cols.push({ key: "operation_status", label: "가동상태", width: "w-[80px]", render: (v) => v || "-" });
|
||||||
|
return cols;
|
||||||
|
}, [ts.visibleColumns]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
// 설비 조회
|
// 설비 조회
|
||||||
const fetchEquipments = useCallback(async () => {
|
const fetchEquipments = useCallback(async () => {
|
||||||
setEquipLoading(true);
|
setEquipLoading(true);
|
||||||
@@ -431,48 +443,18 @@ export default function EquipmentInfoPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-auto">
|
<EDataTable
|
||||||
{equipLoading ? (
|
columns={mainTableColumns}
|
||||||
<div className="flex items-center justify-center py-12">
|
data={ts.groupData(equipments)}
|
||||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
loading={equipLoading}
|
||||||
</div>
|
emptyMessage="등록된 설비가 없어요"
|
||||||
) : equipments.length === 0 ? (
|
selectedId={selectedEquipId}
|
||||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
onSelect={(id) => setSelectedEquipId(id)}
|
||||||
<Inbox className="w-8 h-8 mb-2 opacity-40" />
|
onRowDoubleClick={() => openEquipEdit()}
|
||||||
<p className="text-sm">등록된 설비가 없어요</p>
|
showPagination={true}
|
||||||
</div>
|
draggableColumns={false}
|
||||||
) : (
|
columnOrderKey="c16-equipment-info-main"
|
||||||
<Table noWrapper style={{ tableLayout: "fixed" }}>
|
/>
|
||||||
<thead className="sticky top-0 z-10 bg-card">
|
|
||||||
<TableRow>
|
|
||||||
{ts.isVisible("equipment_code") && <TableHead style={ts.thStyle("equipment_code")} className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">설비코드</TableHead>}
|
|
||||||
{ts.isVisible("equipment_name") && <TableHead style={ts.thStyle("equipment_name")} className="min-w-[130px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">설비명</TableHead>}
|
|
||||||
{ts.isVisible("equipment_type") && <TableHead style={ts.thStyle("equipment_type")} className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">설비유형</TableHead>}
|
|
||||||
{ts.isVisible("manufacturer") && <TableHead style={ts.thStyle("manufacturer")} className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">제조사</TableHead>}
|
|
||||||
{ts.isVisible("installation_location") && <TableHead style={ts.thStyle("installation_location")} className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">설치장소</TableHead>}
|
|
||||||
{ts.isVisible("operation_status") && <TableHead style={ts.thStyle("operation_status")} className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">가동상태</TableHead>}
|
|
||||||
</TableRow>
|
|
||||||
</thead>
|
|
||||||
<TableBody>
|
|
||||||
{equipments.map((equip) => (
|
|
||||||
<TableRow
|
|
||||||
key={equip.id}
|
|
||||||
className={cn("cursor-pointer", selectedEquipId === equip.id && "border-l-2 border-l-primary bg-primary/[0.08]")}
|
|
||||||
onClick={() => setSelectedEquipId(equip.id)}
|
|
||||||
onDoubleClick={openEquipEdit}
|
|
||||||
>
|
|
||||||
{ts.isVisible("equipment_code") && <TableCell style={ts.thStyle("equipment_code")} className="text-[13px] font-mono">{equip.equipment_code}</TableCell>}
|
|
||||||
{ts.isVisible("equipment_name") && <TableCell style={ts.thStyle("equipment_name")} className="text-sm max-w-[150px] truncate" title={equip.equipment_name}>{equip.equipment_name || "-"}</TableCell>}
|
|
||||||
{ts.isVisible("equipment_type") && <TableCell style={ts.thStyle("equipment_type")} className="text-[13px]">{equip.equipment_type || "-"}</TableCell>}
|
|
||||||
{ts.isVisible("manufacturer") && <TableCell style={ts.thStyle("manufacturer")} className="text-[13px]">{equip.manufacturer || "-"}</TableCell>}
|
|
||||||
{ts.isVisible("installation_location") && <TableCell style={ts.thStyle("installation_location")} className="text-[13px]">{equip.installation_location || "-"}</TableCell>}
|
|
||||||
{ts.isVisible("operation_status") && <TableCell style={ts.thStyle("operation_status")} className="text-[13px]">{equip.operation_status || "-"}</TableCell>}
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { useAuth } from "@/hooks/useAuth";
|
|||||||
import { useTableSettings } from "@/hooks/useTableSettings";
|
import { useTableSettings } from "@/hooks/useTableSettings";
|
||||||
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
||||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||||
|
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
||||||
|
|
||||||
@@ -285,46 +286,25 @@ export default function PlcSettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="border rounded-lg overflow-hidden">
|
<div className="border rounded-lg overflow-hidden">
|
||||||
<Table style={{ tableLayout: "fixed" }}>
|
<EDataTable
|
||||||
<TableHeader className="sticky top-0 z-10">
|
columns={ts.visibleColumns.map((col): EDataTableColumn => ({
|
||||||
<TableRow className="bg-muted hover:bg-muted">
|
key: col.key,
|
||||||
<TableHead className="w-10">
|
label: col.label,
|
||||||
<Checkbox
|
align: col.key === "is_active" ? "center" : undefined,
|
||||||
checked={datatypes.length > 0 && dtChecked.length === datatypes.length}
|
render: col.key === "is_active"
|
||||||
onCheckedChange={(v) => setDtChecked(v ? datatypes.map(r => r.id) : [])}
|
? (val: any) => <Badge variant={val ? "default" : "secondary"} className="text-xs">{val ? "사용" : "미사용"}</Badge>
|
||||||
|
: undefined,
|
||||||
|
}))}
|
||||||
|
data={ts.groupData(datatypes)}
|
||||||
|
loading={dtLoading}
|
||||||
|
emptyMessage="등록된 PLC 데이터타입이 없어요"
|
||||||
|
showCheckbox
|
||||||
|
checkedIds={dtChecked}
|
||||||
|
onCheckedChange={setDtChecked}
|
||||||
|
onRowDoubleClick={(row) => openDtEdit(row)}
|
||||||
|
showPagination={false}
|
||||||
|
draggableColumns={false}
|
||||||
/>
|
/>
|
||||||
</TableHead>
|
|
||||||
{ts.visibleColumns.map((col) => (
|
|
||||||
<TableHead key={col.key} style={ts.thStyle(col.key)} className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">{col.label}</TableHead>
|
|
||||||
))}
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{dtLoading ? (
|
|
||||||
<TableRow><TableCell colSpan={ts.visibleColumns.length + 1} className="text-center py-8"><Loader2 className="w-5 h-5 animate-spin mx-auto text-muted-foreground" /></TableCell></TableRow>
|
|
||||||
) : datatypes.length === 0 ? (
|
|
||||||
<TableRow><TableCell colSpan={ts.visibleColumns.length + 1} className="text-center py-10 text-muted-foreground"><Inbox className="w-8 h-8 mx-auto mb-2 opacity-40" /><p className="text-sm">등록된 PLC 데이터타입이 없어요</p></TableCell></TableRow>
|
|
||||||
) : datatypes.map((row) => (
|
|
||||||
<TableRow
|
|
||||||
key={row.id}
|
|
||||||
className={cn("cursor-pointer", dtChecked.includes(row.id) && "bg-primary/5")}
|
|
||||||
onClick={() => setDtChecked(prev => prev.includes(row.id) ? prev.filter(id => id !== row.id) : [...prev, row.id])}
|
|
||||||
onDoubleClick={() => openDtEdit(row)}
|
|
||||||
>
|
|
||||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
|
||||||
<Checkbox checked={dtChecked.includes(row.id)} onCheckedChange={(v) => setDtChecked(prev => v ? [...prev, row.id] : prev.filter(id => id !== row.id))} />
|
|
||||||
</TableCell>
|
|
||||||
{ts.visibleColumns.map((col) => (
|
|
||||||
<TableCell key={col.key} style={ts.thStyle(col.key)} className={col.key === "is_active" ? "text-center" : ""}>
|
|
||||||
{col.key === "is_active"
|
|
||||||
? <Badge variant={row.is_active ? "default" : "secondary"} className="text-xs">{row.is_active ? "사용" : "미사용"}</Badge>
|
|
||||||
: row[col.key] ?? ""}
|
|
||||||
</TableCell>
|
|
||||||
))}
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
@@ -360,52 +340,26 @@ export default function PlcSettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="border rounded-lg overflow-hidden">
|
<div className="border rounded-lg overflow-hidden">
|
||||||
<Table>
|
<EDataTable
|
||||||
<TableHeader className="sticky top-0 z-10">
|
columns={[
|
||||||
<TableRow className="bg-muted hover:bg-muted">
|
{ key: "config_name", label: "설정명" },
|
||||||
<TableHead className="w-10">
|
{ key: "source_connection_id", label: "소스연결ID", width: "w-[110px]" },
|
||||||
<Checkbox
|
{ key: "source_table", label: "소스테이블", width: "w-[120px]" },
|
||||||
checked={configs.length > 0 && cfgChecked.length === configs.length}
|
{ key: "target_table", label: "대상테이블", width: "w-[120px]" },
|
||||||
onCheckedChange={(v) => setCfgChecked(v ? configs.map(r => r.id) : [])}
|
{ key: "collection_type", label: "수집유형", width: "w-[90px]" },
|
||||||
|
{ key: "schedule_cron", label: "스케줄(Cron)", width: "w-[120px]", render: (val: any) => <span className="font-mono text-[13px]">{val}</span> },
|
||||||
|
{ key: "is_active", label: "사용여부", width: "w-[80px]", align: "center" as const, render: (val: any) => <Badge variant={val ? "default" : "secondary"} className="text-xs">{val ? "사용" : "미사용"}</Badge> },
|
||||||
|
] as EDataTableColumn[]}
|
||||||
|
data={configs}
|
||||||
|
loading={cfgLoading}
|
||||||
|
emptyMessage="등록된 수집 설정이 없어요"
|
||||||
|
showCheckbox
|
||||||
|
checkedIds={cfgChecked}
|
||||||
|
onCheckedChange={setCfgChecked}
|
||||||
|
onRowDoubleClick={(row) => openCfgEdit(row)}
|
||||||
|
showPagination={false}
|
||||||
|
draggableColumns={false}
|
||||||
/>
|
/>
|
||||||
</TableHead>
|
|
||||||
<TableHead className="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">소스연결ID</TableHead>
|
|
||||||
<TableHead className="w-[120px] 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="w-[90px] 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">스케줄(Cron)</TableHead>
|
|
||||||
<TableHead className="w-[80px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">사용여부</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{cfgLoading ? (
|
|
||||||
<TableRow><TableCell colSpan={8} className="text-center py-8"><Loader2 className="w-5 h-5 animate-spin mx-auto text-muted-foreground" /></TableCell></TableRow>
|
|
||||||
) : configs.length === 0 ? (
|
|
||||||
<TableRow><TableCell colSpan={8} className="text-center py-10 text-muted-foreground"><Inbox className="w-8 h-8 mx-auto mb-2 opacity-40" /><p className="text-sm">등록된 수집 설정이 없어요</p></TableCell></TableRow>
|
|
||||||
) : configs.map((row) => (
|
|
||||||
<TableRow
|
|
||||||
key={row.id}
|
|
||||||
className={cn("cursor-pointer", cfgChecked.includes(row.id) && "bg-primary/5")}
|
|
||||||
onClick={() => setCfgChecked(prev => prev.includes(row.id) ? prev.filter(id => id !== row.id) : [...prev, row.id])}
|
|
||||||
onDoubleClick={() => openCfgEdit(row)}
|
|
||||||
>
|
|
||||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
|
||||||
<Checkbox checked={cfgChecked.includes(row.id)} onCheckedChange={(v) => setCfgChecked(prev => v ? [...prev, row.id] : prev.filter(id => id !== row.id))} />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>{row.config_name}</TableCell>
|
|
||||||
<TableCell>{row.source_connection_id}</TableCell>
|
|
||||||
<TableCell>{row.source_table}</TableCell>
|
|
||||||
<TableCell>{row.target_table}</TableCell>
|
|
||||||
<TableCell>{row.collection_type}</TableCell>
|
|
||||||
<TableCell className="font-mono text-[13px]">{row.schedule_cron}</TableCell>
|
|
||||||
<TableCell className="text-center">
|
|
||||||
<Badge variant={row.is_active ? "default" : "secondary"} className="text-xs">{row.is_active ? "사용" : "미사용"}</Badge>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ import { exportToExcel } from "@/lib/utils/excelExport";
|
|||||||
import { useTableSettings } from "@/hooks/useTableSettings";
|
import { useTableSettings } from "@/hooks/useTableSettings";
|
||||||
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
||||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||||
|
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
|
||||||
|
|
||||||
// ========== 타입 & 상수 ==========
|
// ========== 타입 & 상수 ==========
|
||||||
type TabKey = "carrier" | "cost" | "contract" | "route" | "vehicle";
|
type TabKey = "carrier" | "cost" | "contract" | "route" | "vehicle";
|
||||||
@@ -757,95 +758,25 @@ export default function LogisticsInfoPage() {
|
|||||||
|
|
||||||
{/* 테이블 영역 */}
|
{/* 테이블 영역 */}
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
{tabLoading[tab.key] ? (
|
<EDataTable
|
||||||
<div className="flex h-40 items-center justify-center gap-2 text-muted-foreground">
|
columns={getVisibleColumns(tab.key).map((col): EDataTableColumn => ({
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
key: col.key,
|
||||||
<span className="text-sm">불러오는 중...</span>
|
label: col.label,
|
||||||
</div>
|
align: col.align,
|
||||||
) : displayData.length === 0 ? (
|
formatNumber: col.formatNumber,
|
||||||
<div className="flex h-40 flex-col items-center justify-center gap-2 text-muted-foreground">
|
truncate: true,
|
||||||
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-muted/50">
|
}))}
|
||||||
<Inbox className="h-6 w-6 opacity-40" />
|
data={tsMap[tab.key].groupData(displayData)}
|
||||||
</div>
|
rowKey={(row: any) => String(row.id)}
|
||||||
<span className="text-sm">등록된 {tab.label} 정보가 없어요</span>
|
loading={tabLoading[tab.key]}
|
||||||
<span className="text-xs text-muted-foreground/60">
|
emptyMessage={`등록된 ${tab.label} 정보가 없어요`}
|
||||||
등록 버튼을 눌러 새 항목을 추가해주세요
|
showCheckbox
|
||||||
</span>
|
checkedIds={tabChecked[tab.key]}
|
||||||
</div>
|
onCheckedChange={(ids) => setTabChecked((prev) => ({ ...prev, [tab.key]: ids }))}
|
||||||
) : (
|
onRowDoubleClick={(row) => handleOpenEdit(row)}
|
||||||
<Table style={{ tableLayout: "fixed" }}>
|
showPagination={false}
|
||||||
<TableHeader className="sticky top-0 z-10">
|
draggableColumns={false}
|
||||||
<TableRow className="bg-muted hover:bg-muted">
|
|
||||||
<TableHead className="w-[40px] p-2">
|
|
||||||
<Checkbox
|
|
||||||
checked={isAllChecked}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
toggleAllCheck(tab.key, !!checked)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</TableHead>
|
|
||||||
{getVisibleColumns(tab.key).map((col) => (
|
|
||||||
<TableHead
|
|
||||||
key={col.key}
|
|
||||||
className={cn(
|
|
||||||
"text-[11px] font-bold uppercase tracking-wide text-muted-foreground",
|
|
||||||
col.align === "right" && "text-right",
|
|
||||||
col.align === "center" && "text-center"
|
|
||||||
)}
|
|
||||||
style={tsMap[tab.key].thStyle(col.key)}
|
|
||||||
>
|
|
||||||
{col.label}
|
|
||||||
</TableHead>
|
|
||||||
))}
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{displayData.map((row: any, idx: number) => {
|
|
||||||
const rowId = String(row.id);
|
|
||||||
const isChecked = tabChecked[tab.key].includes(rowId);
|
|
||||||
return (
|
|
||||||
<TableRow
|
|
||||||
key={row.id ?? idx}
|
|
||||||
className={cn(
|
|
||||||
"cursor-pointer transition-colors hover:bg-accent/50",
|
|
||||||
isChecked && "bg-primary/5 hover:bg-primary/10"
|
|
||||||
)}
|
|
||||||
onClick={() => toggleRowCheck(tab.key, rowId)}
|
|
||||||
onDoubleClick={() => handleOpenEdit(row)}
|
|
||||||
>
|
|
||||||
<TableCell className="w-[40px] p-2">
|
|
||||||
<Checkbox
|
|
||||||
checked={isChecked}
|
|
||||||
onCheckedChange={() => toggleRowCheck(tab.key, rowId)}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
{getVisibleColumns(tab.key).map((col) => {
|
|
||||||
const val = row[col.key];
|
|
||||||
const display =
|
|
||||||
col.formatNumber && val != null && val !== ""
|
|
||||||
? Number(val).toLocaleString()
|
|
||||||
: val ?? "";
|
|
||||||
return (
|
|
||||||
<TableCell
|
|
||||||
key={col.key}
|
|
||||||
className={cn(
|
|
||||||
"max-w-[240px] truncate p-2 text-sm",
|
|
||||||
col.align === "right" && "text-right",
|
|
||||||
col.align === "center" && "text-center"
|
|
||||||
)}
|
|
||||||
style={tsMap[tab.key].thStyle(col.key)}
|
|
||||||
>
|
|
||||||
{display}
|
|
||||||
</TableCell>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</TableRow>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ import { useAuth } from "@/hooks/useAuth";
|
|||||||
import { useTableSettings } from "@/hooks/useTableSettings";
|
import { useTableSettings } from "@/hooks/useTableSettings";
|
||||||
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
||||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||||
|
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { exportToExcel } from "@/lib/utils/excelExport";
|
import { exportToExcel } from "@/lib/utils/excelExport";
|
||||||
|
|
||||||
@@ -303,6 +304,45 @@ export default function InventoryStatusPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 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 === "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 = () => {
|
const handleExcelExport = () => {
|
||||||
if (stockItems.length === 0) {
|
if (stockItems.length === 0) {
|
||||||
@@ -368,86 +408,19 @@ export default function InventoryStatusPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-auto">
|
<EDataTable
|
||||||
{stockLoading ? (
|
columns={stockColumns}
|
||||||
<div className="flex items-center justify-center h-32">
|
data={ts.groupData(stockItems)}
|
||||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
rowKey={(row) => row.id}
|
||||||
</div>
|
loading={stockLoading}
|
||||||
) : stockItems.length === 0 ? (
|
emptyMessage="등록된 재고가 없어요"
|
||||||
<div className="flex items-center justify-center h-32 text-sm text-muted-foreground">
|
selectedId={selectedStockId}
|
||||||
등록된 재고가 없어요
|
onSelect={(id) => setSelectedStockId(id)}
|
||||||
</div>
|
showRowNumber
|
||||||
) : (
|
showPagination={false}
|
||||||
<Table style={{ tableLayout: "fixed" }}>
|
draggableColumns={false}
|
||||||
<TableHeader className="sticky top-0 z-10">
|
columnOrderKey="c16-inventory"
|
||||||
<TableRow className="bg-muted hover:bg-muted">
|
/>
|
||||||
<TableHead className="w-8 text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">#</TableHead>
|
|
||||||
{ts.visibleColumns.map((col) => (
|
|
||||||
<TableHead
|
|
||||||
key={col.key}
|
|
||||||
style={ts.thStyle(col.key)}
|
|
||||||
className={cn(col.align === "right" && "text-right")}
|
|
||||||
>
|
|
||||||
{col.label}
|
|
||||||
</TableHead>
|
|
||||||
))}
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{stockItems.map((item, idx) => (
|
|
||||||
<TableRow
|
|
||||||
key={item.id}
|
|
||||||
className={cn(
|
|
||||||
"cursor-pointer text-xs",
|
|
||||||
selectedStockId === item.id && "bg-primary/10",
|
|
||||||
item._isLow && "bg-destructive/5"
|
|
||||||
)}
|
|
||||||
onClick={() => setSelectedStockId(item.id)}
|
|
||||||
>
|
|
||||||
<TableCell className="text-center text-muted-foreground">
|
|
||||||
{idx + 1}
|
|
||||||
</TableCell>
|
|
||||||
{ts.visibleColumns.map((col) => {
|
|
||||||
if (col.key === "current_qty") {
|
|
||||||
return (
|
|
||||||
<TableCell key={col.key} style={ts.thStyle(col.key)} className="text-right font-mono">
|
|
||||||
<span className={cn(item._isLow && "text-destructive font-bold")}>
|
|
||||||
{Number(item.current_qty || 0).toLocaleString()}
|
|
||||||
</span>
|
|
||||||
{item._isLow && (
|
|
||||||
<AlertTriangle className="inline h-3 w-3 text-destructive ml-1" />
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (col.key === "safety_qty") {
|
|
||||||
return (
|
|
||||||
<TableCell key={col.key} style={ts.thStyle(col.key)} className="text-right font-mono">
|
|
||||||
{Number(item.safety_qty || 0).toLocaleString()}
|
|
||||||
</TableCell>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (col.key === "status") {
|
|
||||||
return (
|
|
||||||
<TableCell key={col.key} style={ts.thStyle(col.key)}>
|
|
||||||
<Badge variant={getStatusVariant(item.status)} className="text-[10px]">
|
|
||||||
{item.status}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<TableCell key={col.key} style={ts.thStyle(col.key)} className="truncate max-w-[150px]">
|
|
||||||
{item[col.key] ?? ""}
|
|
||||||
</TableCell>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ import { toast } from "sonner";
|
|||||||
import { useTableSettings } from "@/hooks/useTableSettings";
|
import { useTableSettings } from "@/hooks/useTableSettings";
|
||||||
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
||||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||||
|
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
|
||||||
// API: /outbound/*
|
// API: /outbound/*
|
||||||
import {
|
import {
|
||||||
getOutboundList,
|
getOutboundList,
|
||||||
@@ -603,139 +604,40 @@ export default function OutboundPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-auto">
|
<EDataTable
|
||||||
<Table style={{ tableLayout: "fixed" }}>
|
columns={[
|
||||||
<TableHeader className="sticky top-0 z-10">
|
{ key: "outbound_number", label: "출고번호", width: "w-[130px]" },
|
||||||
<TableRow className="bg-muted hover:bg-muted">
|
{ key: "outbound_type", label: "출고유형", width: "w-[90px]", render: (v) => (
|
||||||
<TableHead className="w-[40px] text-center text-[11px] uppercase tracking-wide">
|
<Badge variant="outline" className={cn("text-[11px]", getTypeColor(v))}>{v || "-"}</Badge>
|
||||||
<Checkbox
|
)},
|
||||||
checked={allChecked}
|
{ key: "outbound_date", label: "출고일", width: "w-[100px]", render: (v) => v ? new Date(v).toLocaleDateString("ko-KR") : "-" },
|
||||||
onCheckedChange={toggleCheckAll}
|
{ key: "reference_number", label: "참조번호", width: "w-[120px]" },
|
||||||
|
{ key: "source_type", label: "데이터출처", width: "w-[80px]", render: (v) => v ? SOURCE_TYPE_LABEL[v] || v : "-" },
|
||||||
|
{ key: "customer_name", label: "거래처", width: "w-[120px]" },
|
||||||
|
{ key: "item_code", label: "품목코드", width: "w-[100px]" },
|
||||||
|
{ key: "item_name", label: "품목명", minWidth: "min-w-[150px]" },
|
||||||
|
{ key: "specification", label: "규격", width: "w-[80px]" },
|
||||||
|
{ key: "outbound_qty", label: "출고수량", width: "w-[80px]", align: "right", formatNumber: true },
|
||||||
|
{ key: "unit_price", label: "단가", width: "w-[90px]", align: "right", formatNumber: true },
|
||||||
|
{ key: "total_amount", label: "금액", width: "w-[100px]", align: "right", formatNumber: true },
|
||||||
|
{ key: "warehouse_name", label: "창고", width: "w-[100px]", render: (_v, row) => row.warehouse_name || row.warehouse_code || "-" },
|
||||||
|
{ key: "outbound_status", label: "출고상태", width: "w-[90px]", align: "center", render: (v) => (
|
||||||
|
<Badge variant="outline" className={cn("text-[11px]", getStatusColor(v))}>{v || "-"}</Badge>
|
||||||
|
)},
|
||||||
|
{ key: "memo", label: "비고", width: "w-[100px]" },
|
||||||
|
] as EDataTableColumn<OutboundItem>[]}
|
||||||
|
data={ts.groupData(data)}
|
||||||
|
rowKey={(row) => row.id}
|
||||||
|
loading={loading}
|
||||||
|
emptyMessage="등록된 출고 내역이 없어요"
|
||||||
|
showCheckbox
|
||||||
|
checkedIds={checkedIds}
|
||||||
|
onCheckedChange={setCheckedIds}
|
||||||
|
onRowDoubleClick={(row) => openEditModal(row)}
|
||||||
|
showPagination
|
||||||
|
draggableColumns
|
||||||
|
columnOrderKey="c16-outbound"
|
||||||
/>
|
/>
|
||||||
</TableHead>
|
|
||||||
{ts.isVisible("outbound_number") && <TableHead style={ts.thStyle("outbound_number")} className="w-[130px] text-[11px] uppercase tracking-wide">출고번호</TableHead>}
|
|
||||||
{ts.isVisible("outbound_type") && <TableHead style={ts.thStyle("outbound_type")} className="w-[90px] text-[11px] uppercase tracking-wide">출고유형</TableHead>}
|
|
||||||
{ts.isVisible("outbound_date") && <TableHead style={ts.thStyle("outbound_date")} className="w-[100px] text-[11px] uppercase tracking-wide">출고일</TableHead>}
|
|
||||||
{ts.isVisible("reference_number") && <TableHead style={ts.thStyle("reference_number")} className="w-[120px] text-[11px] uppercase tracking-wide">참조번호</TableHead>}
|
|
||||||
{ts.isVisible("source_type") && <TableHead style={ts.thStyle("source_type")} className="w-[80px] text-[11px] uppercase tracking-wide">데이터출처</TableHead>}
|
|
||||||
{ts.isVisible("customer_name") && <TableHead style={ts.thStyle("customer_name")} className="w-[120px] text-[11px] uppercase tracking-wide">거래처</TableHead>}
|
|
||||||
{ts.isVisible("item_number") && <TableHead style={ts.thStyle("item_number")} className="w-[100px] text-[11px] uppercase tracking-wide">품목코드</TableHead>}
|
|
||||||
{ts.isVisible("item_name") && <TableHead style={ts.thStyle("item_name")} className="min-w-[150px] text-[11px] uppercase tracking-wide">품목명</TableHead>}
|
|
||||||
{ts.isVisible("spec") && <TableHead style={ts.thStyle("spec")} className="w-[80px] text-[11px] uppercase tracking-wide">규격</TableHead>}
|
|
||||||
{ts.isVisible("outbound_qty") && <TableHead style={ts.thStyle("outbound_qty")} className="w-[80px] text-right text-[11px] uppercase tracking-wide">출고수량</TableHead>}
|
|
||||||
{ts.isVisible("unit_price") && <TableHead style={ts.thStyle("unit_price")} className="w-[90px] text-right text-[11px] uppercase tracking-wide">단가</TableHead>}
|
|
||||||
{ts.isVisible("total_amount") && <TableHead style={ts.thStyle("total_amount")} className="w-[100px] text-right text-[11px] uppercase tracking-wide">금액</TableHead>}
|
|
||||||
{ts.isVisible("warehouse_name") && <TableHead style={ts.thStyle("warehouse_name")} className="w-[100px] text-[11px] uppercase tracking-wide">창고</TableHead>}
|
|
||||||
{ts.isVisible("outbound_status") && <TableHead style={ts.thStyle("outbound_status")} className="w-[90px] text-center text-[11px] uppercase tracking-wide">출고상태</TableHead>}
|
|
||||||
{ts.isVisible("remark") && <TableHead style={ts.thStyle("remark")} className="w-[100px] text-[11px] uppercase tracking-wide">비고</TableHead>}
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{loading ? (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={16} className="h-40 text-center">
|
|
||||||
<Loader2 className="mx-auto h-6 w-6 animate-spin" />
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
) : data.length === 0 ? (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell
|
|
||||||
colSpan={16}
|
|
||||||
className="h-40 text-center"
|
|
||||||
>
|
|
||||||
<div className="mx-auto flex max-w-xs flex-col items-center gap-2 rounded-lg border border-dashed border-border p-8">
|
|
||||||
<Inbox className="h-10 w-10 text-muted-foreground/30" />
|
|
||||||
<p className="text-sm text-muted-foreground">등록된 출고 내역이 없어요</p>
|
|
||||||
<p className="text-xs text-muted-foreground/70">
|
|
||||||
출고 등록 버튼을 클릭하여 출고를 추가해주세요
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
) : (
|
|
||||||
data.map((row) => (
|
|
||||||
<TableRow
|
|
||||||
key={row.id}
|
|
||||||
className={cn(
|
|
||||||
"cursor-pointer transition-colors",
|
|
||||||
checkedIds.includes(row.id) && "bg-primary/5"
|
|
||||||
)}
|
|
||||||
onClick={() => toggleCheck(row.id)}
|
|
||||||
onDoubleClick={() => openEditModal(row)}
|
|
||||||
>
|
|
||||||
<TableCell
|
|
||||||
className="text-center"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<Checkbox
|
|
||||||
checked={checkedIds.includes(row.id)}
|
|
||||||
onCheckedChange={() => toggleCheck(row.id)}
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
{ts.isVisible("outbound_number") && <TableCell style={ts.thStyle("outbound_number")} className="max-w-[130px] truncate font-medium" title={row.outbound_number}>
|
|
||||||
{row.outbound_number}
|
|
||||||
</TableCell>}
|
|
||||||
{ts.isVisible("outbound_type") && <TableCell style={ts.thStyle("outbound_type")}>
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className={cn("text-[11px]", getTypeColor(row.outbound_type))}
|
|
||||||
>
|
|
||||||
{row.outbound_type || "-"}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>}
|
|
||||||
{ts.isVisible("outbound_date") && <TableCell style={ts.thStyle("outbound_date")} className="text-[13px]">
|
|
||||||
{row.outbound_date
|
|
||||||
? new Date(row.outbound_date).toLocaleDateString("ko-KR")
|
|
||||||
: "-"}
|
|
||||||
</TableCell>}
|
|
||||||
{ts.isVisible("reference_number") && <TableCell style={ts.thStyle("reference_number")} className="max-w-[120px] truncate text-[13px]" title={row.reference_number || "-"}>
|
|
||||||
{row.reference_number || "-"}
|
|
||||||
</TableCell>}
|
|
||||||
{ts.isVisible("source_type") && <TableCell style={ts.thStyle("source_type")} className="text-[13px]">
|
|
||||||
{row.source_type
|
|
||||||
? SOURCE_TYPE_LABEL[row.source_type] || row.source_type
|
|
||||||
: "-"}
|
|
||||||
</TableCell>}
|
|
||||||
{ts.isVisible("customer_name") && <TableCell style={ts.thStyle("customer_name")} className="max-w-[120px] truncate text-[13px]" title={row.customer_name || "-"}>
|
|
||||||
{row.customer_name || "-"}
|
|
||||||
</TableCell>}
|
|
||||||
{ts.isVisible("item_number") && <TableCell style={ts.thStyle("item_number")} className="max-w-[130px] truncate text-[13px]" title={row.item_code || "-"}>
|
|
||||||
{row.item_code || "-"}
|
|
||||||
</TableCell>}
|
|
||||||
{ts.isVisible("item_name") && <TableCell style={ts.thStyle("item_name")} className="max-w-[150px] truncate text-[13px]" title={row.item_name || "-"}>{row.item_name || "-"}</TableCell>}
|
|
||||||
{ts.isVisible("spec") && <TableCell style={ts.thStyle("spec")} className="max-w-[100px] truncate text-[13px]" title={row.specification || "-"}>{row.specification || "-"}</TableCell>}
|
|
||||||
{ts.isVisible("outbound_qty") && <TableCell style={ts.thStyle("outbound_qty")} className="text-right font-mono text-[13px] font-semibold">
|
|
||||||
{Number(row.outbound_qty || 0).toLocaleString()}
|
|
||||||
</TableCell>}
|
|
||||||
{ts.isVisible("unit_price") && <TableCell style={ts.thStyle("unit_price")} className="text-right font-mono text-[13px]">
|
|
||||||
{Number(row.unit_price || 0).toLocaleString()}
|
|
||||||
</TableCell>}
|
|
||||||
{ts.isVisible("total_amount") && <TableCell style={ts.thStyle("total_amount")} className="text-right font-mono text-[13px] font-semibold">
|
|
||||||
{Number(row.total_amount || 0).toLocaleString()}
|
|
||||||
</TableCell>}
|
|
||||||
{ts.isVisible("warehouse_name") && <TableCell style={ts.thStyle("warehouse_name")} className="text-[13px]">
|
|
||||||
{row.warehouse_name || row.warehouse_code || "-"}
|
|
||||||
</TableCell>}
|
|
||||||
{ts.isVisible("outbound_status") && <TableCell style={ts.thStyle("outbound_status")} className="text-center">
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className={cn(
|
|
||||||
"text-[11px]",
|
|
||||||
getStatusColor(row.outbound_status)
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{row.outbound_status || "-"}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>}
|
|
||||||
{ts.isVisible("remark") && <TableCell style={ts.thStyle("remark")} className="max-w-[120px] truncate text-[13px]" title={row.memo || "-"}>
|
|
||||||
{row.memo || "-"}
|
|
||||||
</TableCell>}
|
|
||||||
</TableRow>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 출고 등록 모달 */}
|
{/* 출고 등록 모달 */}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
import { useTableSettings } from "@/hooks/useTableSettings";
|
import { useTableSettings } from "@/hooks/useTableSettings";
|
||||||
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
||||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||||
|
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
|
||||||
|
|
||||||
const GRID_COLUMNS = [
|
const GRID_COLUMNS = [
|
||||||
{ key: "pkg_code", label: "품목코드" },
|
{ key: "pkg_code", label: "품목코드" },
|
||||||
@@ -458,58 +459,32 @@ export default function PackagingPage() {
|
|||||||
<div className="flex flex-1 flex-col overflow-hidden rounded-lg border bg-card">
|
<div className="flex flex-1 flex-col overflow-hidden rounded-lg border bg-card">
|
||||||
{/* 포장재 목록 테이블 */}
|
{/* 포장재 목록 테이블 */}
|
||||||
<div className={cn("overflow-auto", selectedPkg ? "flex-[0_0_50%] border-b" : "flex-1")}>
|
<div className={cn("overflow-auto", selectedPkg ? "flex-[0_0_50%] border-b" : "flex-1")}>
|
||||||
<Table style={{ tableLayout: "fixed" }}>
|
<EDataTable
|
||||||
<TableHeader className="sticky top-0 z-10">
|
columns={[
|
||||||
<TableRow className="bg-muted hover:bg-muted">
|
{ key: "pkg_code", label: "품목코드" },
|
||||||
{ts.isVisible("pkg_code") && <TableHead style={ts.thStyle("pkg_code")} className="p-2 text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품목코드</TableHead>}
|
{ key: "pkg_name", label: "포장명" },
|
||||||
{ts.isVisible("pkg_name") && <TableHead style={ts.thStyle("pkg_name")} className="p-2 text-[11px] font-bold uppercase tracking-wide text-muted-foreground">포장명</TableHead>}
|
{ key: "pkg_type", label: "유형", width: "w-[80px]", render: (v) => PKG_TYPE_LABEL[v] || v || "-" },
|
||||||
{ts.isVisible("pkg_type") && <TableHead style={ts.thStyle("pkg_type")} className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">유형</TableHead>}
|
{ key: "size", label: "크기(mm)", width: "w-[100px]", render: (_v, row) => fmtSize(row.width_mm, row.length_mm, row.height_mm) },
|
||||||
{ts.isVisible("size") && <TableHead style={ts.thStyle("size")} className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">크기(mm)</TableHead>}
|
{ key: "max_load_kg", label: "최대중량", width: "w-[80px]", align: "right", render: (v) => Number(v || 0) > 0 ? `${v}kg` : "-" },
|
||||||
{ts.isVisible("max_weight") && <TableHead style={ts.thStyle("max_weight")} className="w-[80px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">최대중량</TableHead>}
|
{ key: "status", label: "상태", width: "w-[60px]", align: "center", render: (v) => (
|
||||||
{ts.isVisible("status") && <TableHead style={ts.thStyle("status")} className="w-[60px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">상태</TableHead>}
|
<span className={cn("rounded px-1.5 py-0.5 text-[10px] font-medium", getStatusColor(v))}>
|
||||||
</TableRow>
|
{STATUS_LABEL[v] || v}
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{pkgLoading ? (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={6} className="h-32 text-center">
|
|
||||||
<Loader2 className="mx-auto h-5 w-5 animate-spin text-muted-foreground" />
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
) : filteredPkgUnits.length === 0 ? (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={6} className="h-40 text-center">
|
|
||||||
<div className="flex flex-col items-center justify-center gap-2">
|
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-full border-2 border-dashed border-muted-foreground/20">
|
|
||||||
<Inbox className="h-6 w-6 text-muted-foreground/40" />
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground">등록된 포장재가 없어요</p>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
) : filteredPkgUnits.map((p) => (
|
|
||||||
<TableRow
|
|
||||||
key={p.id}
|
|
||||||
className={cn(
|
|
||||||
"cursor-pointer text-xs transition-colors",
|
|
||||||
selectedPkg?.id === p.id ? "bg-primary/5" : "hover:bg-muted/50"
|
|
||||||
)}
|
|
||||||
onClick={() => selectPkg(p)}
|
|
||||||
>
|
|
||||||
{ts.isVisible("pkg_code") && <TableCell style={ts.thStyle("pkg_code")} className="p-2 font-medium">{p.pkg_code}</TableCell>}
|
|
||||||
{ts.isVisible("pkg_name") && <TableCell style={ts.thStyle("pkg_name")} className="p-2">{p.pkg_name}</TableCell>}
|
|
||||||
{ts.isVisible("pkg_type") && <TableCell style={ts.thStyle("pkg_type")} className="p-2">{PKG_TYPE_LABEL[p.pkg_type] || p.pkg_type || "-"}</TableCell>}
|
|
||||||
{ts.isVisible("size") && <TableCell style={ts.thStyle("size")} className="p-2 text-[10px] tabular-nums">{fmtSize(p.width_mm, p.length_mm, p.height_mm)}</TableCell>}
|
|
||||||
{ts.isVisible("max_weight") && <TableCell style={ts.thStyle("max_weight")} className="p-2 text-right">{Number(p.max_load_kg || 0) > 0 ? `${p.max_load_kg}kg` : "-"}</TableCell>}
|
|
||||||
{ts.isVisible("status") && <TableCell style={ts.thStyle("status")} className="p-2 text-center">
|
|
||||||
<span className={cn("rounded px-1.5 py-0.5 text-[10px] font-medium", getStatusColor(p.status))}>
|
|
||||||
{STATUS_LABEL[p.status] || p.status}
|
|
||||||
</span>
|
</span>
|
||||||
</TableCell>}
|
)},
|
||||||
</TableRow>
|
] as EDataTableColumn<PkgUnit>[]}
|
||||||
))}
|
data={ts.groupData(filteredPkgUnits)}
|
||||||
</TableBody>
|
rowKey={(row) => String(row.id)}
|
||||||
</Table>
|
loading={pkgLoading}
|
||||||
|
emptyMessage="등록된 포장재가 없어요"
|
||||||
|
selectedId={selectedPkg ? String(selectedPkg.id) : null}
|
||||||
|
onSelect={(id) => {
|
||||||
|
const pkg = filteredPkgUnits.find((p) => String(p.id) === id);
|
||||||
|
if (pkg) selectPkg(pkg);
|
||||||
|
}}
|
||||||
|
showPagination={false}
|
||||||
|
draggableColumns
|
||||||
|
columnOrderKey="c16-packaging-pkg"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 매칭 품목 서브패널 */}
|
{/* 매칭 품목 서브패널 */}
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ import { apiClient } from "@/lib/api/client";
|
|||||||
import { useTableSettings } from "@/hooks/useTableSettings";
|
import { useTableSettings } from "@/hooks/useTableSettings";
|
||||||
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
||||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||||
|
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
|
||||||
// API: /receiving/*
|
// API: /receiving/*
|
||||||
import {
|
import {
|
||||||
getReceivingList,
|
getReceivingList,
|
||||||
@@ -574,135 +575,39 @@ export default function ReceivingPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-auto">
|
<EDataTable
|
||||||
<Table style={{ tableLayout: "fixed" }}>
|
columns={[
|
||||||
<TableHeader className="sticky top-0 z-10">
|
{ key: "inbound_number", label: "입고번호", width: "w-[130px]" },
|
||||||
<TableRow className="bg-muted hover:bg-muted">
|
{ key: "inbound_type", label: "입고유형", width: "w-[90px]", render: (v) => (
|
||||||
<TableHead className="w-[40px] text-center">
|
<Badge variant={getTypeVariant(v)} className="text-[11px]">{v || "-"}</Badge>
|
||||||
<Checkbox
|
)},
|
||||||
checked={allChecked}
|
{ key: "inbound_date", label: "입고일", width: "w-[100px]", render: (v) => v ? new Date(v).toLocaleDateString("ko-KR") : "-" },
|
||||||
onCheckedChange={toggleCheckAll}
|
{ key: "reference_number", label: "참조번호", width: "w-[120px]" },
|
||||||
|
{ key: "source_table", label: "데이터출처", width: "w-[80px]", render: (v) => v ? SOURCE_TABLE_LABEL[v] || v : "-" },
|
||||||
|
{ key: "supplier_name", label: "공급처", width: "w-[120px]" },
|
||||||
|
{ key: "item_number", label: "품목코드", width: "w-[100px]" },
|
||||||
|
{ key: "item_name", label: "품목명", minWidth: "min-w-[150px]" },
|
||||||
|
{ key: "spec", label: "규격", width: "w-[80px]" },
|
||||||
|
{ key: "inbound_qty", label: "입고수량", width: "w-[80px]", align: "right", formatNumber: true },
|
||||||
|
{ key: "unit_price", label: "단가", width: "w-[90px]", align: "right", formatNumber: true },
|
||||||
|
{ key: "total_amount", label: "금액", width: "w-[100px]", align: "right", formatNumber: true },
|
||||||
|
{ key: "warehouse_name", label: "창고", width: "w-[100px]", render: (_v, row) => row.warehouse_name || row.warehouse_code || "-" },
|
||||||
|
{ key: "inbound_status", label: "입고상태", width: "w-[90px]", align: "center", render: (v) => (
|
||||||
|
<Badge variant={getStatusVariant(v)} className="text-[11px]">{v || "-"}</Badge>
|
||||||
|
)},
|
||||||
|
{ key: "memo", label: "비고", width: "w-[100px]" },
|
||||||
|
] as EDataTableColumn<InboundItem>[]}
|
||||||
|
data={ts.groupData(data)}
|
||||||
|
rowKey={(row) => row.id}
|
||||||
|
loading={loading}
|
||||||
|
emptyMessage="등록된 입고 내역이 없어요"
|
||||||
|
showCheckbox
|
||||||
|
checkedIds={checkedIds}
|
||||||
|
onCheckedChange={setCheckedIds}
|
||||||
|
showPagination
|
||||||
|
draggableColumns
|
||||||
|
columnOrderKey="c16-receiving"
|
||||||
/>
|
/>
|
||||||
</TableHead>
|
|
||||||
{ts.isVisible("inbound_number") && <TableHead style={ts.thStyle("inbound_number")} className="w-[130px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">입고번호</TableHead>}
|
|
||||||
{ts.isVisible("inbound_type") && <TableHead style={ts.thStyle("inbound_type")} className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">입고유형</TableHead>}
|
|
||||||
{ts.isVisible("inbound_date") && <TableHead style={ts.thStyle("inbound_date")} className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">입고일</TableHead>}
|
|
||||||
{ts.isVisible("reference_number") && <TableHead style={ts.thStyle("reference_number")} className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">참조번호</TableHead>}
|
|
||||||
{ts.isVisible("source_type") && <TableHead style={ts.thStyle("source_type")} className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">데이터출처</TableHead>}
|
|
||||||
{ts.isVisible("supplier_name") && <TableHead style={ts.thStyle("supplier_name")} className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">공급처</TableHead>}
|
|
||||||
{ts.isVisible("item_number") && <TableHead style={ts.thStyle("item_number")} className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품목코드</TableHead>}
|
|
||||||
{ts.isVisible("item_name") && <TableHead style={ts.thStyle("item_name")} className="min-w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품목명</TableHead>}
|
|
||||||
{ts.isVisible("spec") && <TableHead style={ts.thStyle("spec")} className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">규격</TableHead>}
|
|
||||||
{ts.isVisible("inbound_qty") && <TableHead style={ts.thStyle("inbound_qty")} className="w-[80px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">입고수량</TableHead>}
|
|
||||||
{ts.isVisible("unit_price") && <TableHead style={ts.thStyle("unit_price")} className="w-[90px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">단가</TableHead>}
|
|
||||||
{ts.isVisible("total_amount") && <TableHead style={ts.thStyle("total_amount")} className="w-[100px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">금액</TableHead>}
|
|
||||||
{ts.isVisible("warehouse_name") && <TableHead style={ts.thStyle("warehouse_name")} className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">창고</TableHead>}
|
|
||||||
{ts.isVisible("inbound_status") && <TableHead style={ts.thStyle("inbound_status")} className="w-[90px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">입고상태</TableHead>}
|
|
||||||
{ts.isVisible("remark") && <TableHead style={ts.thStyle("remark")} className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">비고</TableHead>}
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{loading ? (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={16} className="h-40 text-center">
|
|
||||||
<Loader2 className="mx-auto h-6 w-6 animate-spin" />
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
) : data.length === 0 ? (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell
|
|
||||||
colSpan={16}
|
|
||||||
className="h-40 text-center"
|
|
||||||
>
|
|
||||||
<div className="flex flex-col items-center gap-1.5 text-muted-foreground">
|
|
||||||
<Inbox className="h-10 w-10 opacity-30" />
|
|
||||||
<p className="text-sm font-medium">등록된 입고 내역이 없어요</p>
|
|
||||||
<p className="text-xs text-muted-foreground/70">
|
|
||||||
입고 등록 버튼을 클릭하여 입고를 추가해 보세요
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
) : (
|
|
||||||
data.map((row) => (
|
|
||||||
<TableRow
|
|
||||||
key={row.id}
|
|
||||||
className={cn(
|
|
||||||
"cursor-pointer transition-colors",
|
|
||||||
checkedIds.includes(row.id) && "bg-primary/5"
|
|
||||||
)}
|
|
||||||
onClick={() => toggleCheck(row.id)}
|
|
||||||
>
|
|
||||||
<TableCell
|
|
||||||
className="text-center"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<Checkbox
|
|
||||||
checked={checkedIds.includes(row.id)}
|
|
||||||
onCheckedChange={() => toggleCheck(row.id)}
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
{ts.isVisible("inbound_number") && <TableCell style={ts.thStyle("inbound_number")} className="max-w-[130px] truncate font-medium" title={row.inbound_number}>
|
|
||||||
{row.inbound_number}
|
|
||||||
</TableCell>}
|
|
||||||
{ts.isVisible("inbound_type") && <TableCell style={ts.thStyle("inbound_type")}>
|
|
||||||
<Badge
|
|
||||||
variant={getTypeVariant(row.inbound_type)}
|
|
||||||
className="text-[11px]"
|
|
||||||
>
|
|
||||||
{row.inbound_type || "-"}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>}
|
|
||||||
{ts.isVisible("inbound_date") && <TableCell style={ts.thStyle("inbound_date")} className="text-[13px]">
|
|
||||||
{row.inbound_date
|
|
||||||
? new Date(row.inbound_date).toLocaleDateString("ko-KR")
|
|
||||||
: "-"}
|
|
||||||
</TableCell>}
|
|
||||||
{ts.isVisible("reference_number") && <TableCell style={ts.thStyle("reference_number")} className="max-w-[120px] truncate text-[13px]" title={row.reference_number || "-"}>
|
|
||||||
{row.reference_number || "-"}
|
|
||||||
</TableCell>}
|
|
||||||
{ts.isVisible("source_type") && <TableCell style={ts.thStyle("source_type")} className="text-[13px]">
|
|
||||||
{row.source_table
|
|
||||||
? SOURCE_TABLE_LABEL[row.source_table] || row.source_table
|
|
||||||
: "-"}
|
|
||||||
</TableCell>}
|
|
||||||
{ts.isVisible("supplier_name") && <TableCell style={ts.thStyle("supplier_name")} className="max-w-[120px] truncate text-[13px]" title={row.supplier_name || "-"}>
|
|
||||||
{row.supplier_name || "-"}
|
|
||||||
</TableCell>}
|
|
||||||
{ts.isVisible("item_number") && <TableCell style={ts.thStyle("item_number")} className="max-w-[130px] truncate text-[13px]" title={row.item_number || "-"}>
|
|
||||||
{row.item_number || "-"}
|
|
||||||
</TableCell>}
|
|
||||||
{ts.isVisible("item_name") && <TableCell style={ts.thStyle("item_name")} className="max-w-[150px] truncate text-[13px]" title={row.item_name || "-"}>{row.item_name || "-"}</TableCell>}
|
|
||||||
{ts.isVisible("spec") && <TableCell style={ts.thStyle("spec")} className="max-w-[100px] truncate text-[13px]" title={row.spec || "-"}>{row.spec || "-"}</TableCell>}
|
|
||||||
{ts.isVisible("inbound_qty") && <TableCell style={ts.thStyle("inbound_qty")} className="text-right text-[13px] font-semibold">
|
|
||||||
{Number(row.inbound_qty || 0).toLocaleString()}
|
|
||||||
</TableCell>}
|
|
||||||
{ts.isVisible("unit_price") && <TableCell style={ts.thStyle("unit_price")} className="text-right text-[13px]">
|
|
||||||
{Number(row.unit_price || 0).toLocaleString()}
|
|
||||||
</TableCell>}
|
|
||||||
{ts.isVisible("total_amount") && <TableCell style={ts.thStyle("total_amount")} className="text-right text-[13px] font-semibold">
|
|
||||||
{Number(row.total_amount || 0).toLocaleString()}
|
|
||||||
</TableCell>}
|
|
||||||
{ts.isVisible("warehouse_name") && <TableCell style={ts.thStyle("warehouse_name")} className="text-[13px]">
|
|
||||||
{row.warehouse_name || row.warehouse_code || "-"}
|
|
||||||
</TableCell>}
|
|
||||||
{ts.isVisible("inbound_status") && <TableCell style={ts.thStyle("inbound_status")} className="text-center">
|
|
||||||
<Badge
|
|
||||||
variant={getStatusVariant(row.inbound_status)}
|
|
||||||
className="text-[11px]"
|
|
||||||
>
|
|
||||||
{row.inbound_status || "-"}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>}
|
|
||||||
{ts.isVisible("remark") && <TableCell style={ts.thStyle("remark")} className="max-w-[120px] truncate text-[13px]" title={row.memo || "-"}>
|
|
||||||
{row.memo || "-"}
|
|
||||||
</TableCell>}
|
|
||||||
</TableRow>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 입고 등록 모달 */}
|
{/* 입고 등록 모달 */}
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ import { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
|||||||
import { useTableSettings } from "@/hooks/useTableSettings";
|
import { useTableSettings } from "@/hooks/useTableSettings";
|
||||||
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
||||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||||
|
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
|
||||||
import { exportToExcel } from "@/lib/utils/excelExport";
|
import { exportToExcel } from "@/lib/utils/excelExport";
|
||||||
|
|
||||||
const WAREHOUSE_TABLE = "warehouse_info";
|
const WAREHOUSE_TABLE = "warehouse_info";
|
||||||
@@ -605,6 +606,32 @@ export default function WarehouseManagementPage() {
|
|||||||
maxLevels: rackConditions.reduce((acc, c) => Math.max(acc, c.levels || 0), 0),
|
maxLevels: rackConditions.reduce((acc, c) => Math.max(acc, c.levels || 0), 0),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// EDataTable 컬럼 정의
|
||||||
|
const warehouseColumns: EDataTableColumn[] = ts.visibleColumns.map((col) => {
|
||||||
|
const base: EDataTableColumn = { key: col.key, label: col.label };
|
||||||
|
if (col.key === "warehouse_type") {
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
render: (val: any) => (
|
||||||
|
<Badge variant={getTypeVariant(val)} className="text-[10px]">
|
||||||
|
{val}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (col.key === "status") {
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
render: (val: any) => (
|
||||||
|
<Badge variant={getStatusVariant(val)} className="text-[10px]">
|
||||||
|
{val}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return base;
|
||||||
|
});
|
||||||
|
|
||||||
// 엑셀 내보내기
|
// 엑셀 내보내기
|
||||||
const handleExcelExport = () => {
|
const handleExcelExport = () => {
|
||||||
if (warehouses.length === 0) {
|
if (warehouses.length === 0) {
|
||||||
@@ -689,70 +716,20 @@ export default function WarehouseManagementPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-auto">
|
<EDataTable
|
||||||
{warehouseLoading ? (
|
columns={warehouseColumns}
|
||||||
<div className="flex items-center justify-center h-32">
|
data={ts.groupData(warehouses)}
|
||||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
rowKey={(row) => row.id}
|
||||||
</div>
|
loading={warehouseLoading}
|
||||||
) : warehouses.length === 0 ? (
|
emptyMessage="등록된 창고가 없어요"
|
||||||
<div className="flex items-center justify-center h-32 text-sm text-muted-foreground">
|
selectedId={selectedWarehouseId}
|
||||||
등록된 창고가 없어요
|
onSelect={(id) => setSelectedWarehouseId(id)}
|
||||||
</div>
|
onRowDoubleClick={(row) => openWarehouseEditModal(row)}
|
||||||
) : (
|
showRowNumber
|
||||||
<Table style={{ tableLayout: "fixed" }}>
|
showPagination={false}
|
||||||
<TableHeader className="sticky top-0 z-10">
|
draggableColumns={false}
|
||||||
<TableRow className="bg-muted hover:bg-muted">
|
columnOrderKey="c16-warehouse"
|
||||||
<TableHead className="w-8 text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">#</TableHead>
|
/>
|
||||||
{ts.visibleColumns.map((col) => (
|
|
||||||
<TableHead key={col.key} style={ts.thStyle(col.key)} className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">{col.label}</TableHead>
|
|
||||||
))}
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{warehouses.map((w, idx) => (
|
|
||||||
<TableRow
|
|
||||||
key={w.id}
|
|
||||||
className={cn(
|
|
||||||
"cursor-pointer text-xs",
|
|
||||||
selectedWarehouseId === w.id && "bg-primary/10"
|
|
||||||
)}
|
|
||||||
onClick={() => setSelectedWarehouseId(w.id)}
|
|
||||||
onDoubleClick={() => openWarehouseEditModal(w)}
|
|
||||||
>
|
|
||||||
<TableCell className="text-center text-muted-foreground">
|
|
||||||
{idx + 1}
|
|
||||||
</TableCell>
|
|
||||||
{ts.visibleColumns.map((col) => {
|
|
||||||
if (col.key === "warehouse_type") {
|
|
||||||
return (
|
|
||||||
<TableCell key={col.key} style={ts.thStyle(col.key)}>
|
|
||||||
<Badge variant={getTypeVariant(w.warehouse_type)} className="text-[10px]">
|
|
||||||
{w.warehouse_type}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (col.key === "status") {
|
|
||||||
return (
|
|
||||||
<TableCell key={col.key} style={ts.thStyle(col.key)}>
|
|
||||||
<Badge variant={getStatusVariant(w.status)} className="text-[10px]">
|
|
||||||
{w.status}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<TableCell key={col.key} style={ts.thStyle(col.key)} className="truncate max-w-[150px]">
|
|
||||||
{w[col.key] ?? ""}
|
|
||||||
</TableCell>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import { useAuth } from "@/hooks/useAuth";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
||||||
import { formatField, validateField, validateForm } from "@/lib/utils/validation";
|
import { formatField, validateField, validateForm } from "@/lib/utils/validation";
|
||||||
|
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
|
||||||
|
|
||||||
const COMPANY_TABLE = "company_mng";
|
const COMPANY_TABLE = "company_mng";
|
||||||
const DEPT_TABLE = "dept_info";
|
const DEPT_TABLE = "dept_info";
|
||||||
@@ -376,6 +377,16 @@ export default function CompanyPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// EDataTable 컬럼 정의 (사원 목록)
|
||||||
|
const companyMemberColumns: EDataTableColumn[] = [
|
||||||
|
{ key: "sabun", label: "사번", width: "w-[80px]", render: (val: any) => <span className="text-[13px]">{val || "-"}</span> },
|
||||||
|
{ key: "user_name", label: "이름", width: "w-[90px]" },
|
||||||
|
{ key: "user_id", label: "사용자ID", width: "w-[100px]" },
|
||||||
|
{ key: "position_name", label: "직급", width: "w-[80px]", render: (val: any) => <span>{val || "-"}</span> },
|
||||||
|
{ key: "cell_phone", label: "휴대폰", width: "w-[120px]", render: (val: any) => <span>{val || "-"}</span> },
|
||||||
|
{ key: "email", label: "이메일" },
|
||||||
|
];
|
||||||
|
|
||||||
/* ── 트리 렌더 ── */
|
/* ── 트리 렌더 ── */
|
||||||
const renderTree = (nodes: DeptNode[], depth = 0) => {
|
const renderTree = (nodes: DeptNode[], depth = 0) => {
|
||||||
return nodes.map((node) => {
|
return nodes.map((node) => {
|
||||||
@@ -685,47 +696,17 @@ export default function CompanyPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{selectedDeptCode ? (
|
{selectedDeptCode ? (
|
||||||
<div className="flex-1 overflow-auto">
|
<EDataTable
|
||||||
{memberLoading ? (
|
columns={companyMemberColumns}
|
||||||
<div className="flex items-center justify-center py-10">
|
data={members}
|
||||||
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
|
rowKey={(row) => row.user_id || row.id}
|
||||||
</div>
|
loading={memberLoading}
|
||||||
) : members.length === 0 ? (
|
emptyMessage="소속 사원이 없어요"
|
||||||
<div className="flex flex-col items-center justify-center py-10 text-muted-foreground">
|
emptyIcon={<Users className="w-8 h-8 mb-2" />}
|
||||||
<Users className="w-8 h-8 mb-2" />
|
onRowDoubleClick={(row) => openUserModal(row)}
|
||||||
<span className="text-sm">소속 사원이 없어요</span>
|
showPagination={false}
|
||||||
</div>
|
draggableColumns={false}
|
||||||
) : (
|
/>
|
||||||
<Table>
|
|
||||||
<TableHeader className="sticky top-0 z-10">
|
|
||||||
<TableRow className="bg-muted hover:bg-muted">
|
|
||||||
<TableHead className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">사번</TableHead>
|
|
||||||
<TableHead className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">이름</TableHead>
|
|
||||||
<TableHead className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">사용자ID</TableHead>
|
|
||||||
<TableHead className="w-[80px] 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>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{members.map((row) => (
|
|
||||||
<TableRow
|
|
||||||
key={row.user_id || row.id}
|
|
||||||
className="cursor-pointer"
|
|
||||||
onDoubleClick={() => openUserModal(row)}
|
|
||||||
>
|
|
||||||
<TableCell className="text-[13px]">{row.sabun || "-"}</TableCell>
|
|
||||||
<TableCell className="text-sm font-medium">{row.user_name}</TableCell>
|
|
||||||
<TableCell className="text-[13px] font-mono">{row.user_id}</TableCell>
|
|
||||||
<TableCell className="text-[13px]">{row.position_name || "-"}</TableCell>
|
|
||||||
<TableCell className="text-[13px]">{row.cell_phone || "-"}</TableCell>
|
|
||||||
<TableCell className="text-[13px]">{row.email || "-"}</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground">
|
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground">
|
||||||
<Users className="w-10 h-10 mb-3" />
|
<Users className="w-10 h-10 mb-3" />
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import { formatField, validateField, validateForm } from "@/lib/utils/validation
|
|||||||
import { useTableSettings } from "@/hooks/useTableSettings";
|
import { useTableSettings } from "@/hooks/useTableSettings";
|
||||||
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
||||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||||
|
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
|
||||||
|
|
||||||
const DEPT_TABLE = "dept_info";
|
const DEPT_TABLE = "dept_info";
|
||||||
const USER_TABLE = "user_info";
|
const USER_TABLE = "user_info";
|
||||||
@@ -313,6 +314,36 @@ export default function DepartmentPage() {
|
|||||||
|
|
||||||
const isColVisible = (key: string) => ts.isVisible(key);
|
const isColVisible = (key: string) => ts.isVisible(key);
|
||||||
|
|
||||||
|
// EDataTable 컬럼 정의 (부서 목록)
|
||||||
|
const deptColumns: EDataTableColumn[] = [
|
||||||
|
{ key: "dept_code", label: "부서코드", width: "w-[120px]" },
|
||||||
|
{ key: "dept_name", label: "부서명", minWidth: "min-w-[140px]" },
|
||||||
|
...(isColVisible("parent_dept_code")
|
||||||
|
? [{
|
||||||
|
key: "parent_dept_code",
|
||||||
|
label: "상위부서",
|
||||||
|
width: "w-[110px]",
|
||||||
|
render: (val: any) => <span className="text-[13px] text-muted-foreground">{val || "\u2014"}</span>,
|
||||||
|
}]
|
||||||
|
: []),
|
||||||
|
...(isColVisible("status")
|
||||||
|
? [{
|
||||||
|
key: "status",
|
||||||
|
label: "상태",
|
||||||
|
width: "w-[70px]",
|
||||||
|
render: (val: any) =>
|
||||||
|
val ? (
|
||||||
|
<Badge
|
||||||
|
variant={val === "active" ? "default" : "outline"}
|
||||||
|
className="text-[10px] px-1.5 py-0 h-5"
|
||||||
|
>
|
||||||
|
{val === "active" ? "활성" : (val || "\u2014")}
|
||||||
|
</Badge>
|
||||||
|
) : null,
|
||||||
|
}]
|
||||||
|
: []),
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col gap-3 p-4">
|
<div className="flex h-full flex-col gap-3 p-4">
|
||||||
{/* 검색 필터 바 */}
|
{/* 검색 필터 바 */}
|
||||||
@@ -366,61 +397,20 @@ export default function DepartmentPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 부서 테이블 */}
|
{/* 부서 테이블 */}
|
||||||
<div className="flex-1 overflow-auto">
|
<EDataTable
|
||||||
<Table noWrapper style={{ tableLayout: "fixed" }}>
|
columns={deptColumns}
|
||||||
<TableHeader className="sticky top-0 z-10">
|
data={ts.groupData(depts)}
|
||||||
<TableRow className="bg-muted hover:bg-muted">
|
rowKey={(row) => row.id}
|
||||||
<TableHead className="w-[40px] text-center px-2 text-[11px] font-bold uppercase tracking-wide text-muted-foreground">No</TableHead>
|
loading={deptLoading}
|
||||||
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">부서코드</TableHead>
|
emptyMessage="등록된 부서가 없어요"
|
||||||
<TableHead className="min-w-[140px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">부서명</TableHead>
|
selectedId={selectedDeptId}
|
||||||
{isColVisible("parent_dept_code") && <TableHead style={ts.thStyle("parent_dept_code")} className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">상위부서</TableHead>}
|
onSelect={(id) => setSelectedDeptId(id)}
|
||||||
{isColVisible("status") && <TableHead style={ts.thStyle("status")} className="w-[70px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">상태</TableHead>}
|
onRowDoubleClick={() => openDeptEdit()}
|
||||||
</TableRow>
|
showRowNumber
|
||||||
</TableHeader>
|
showPagination={false}
|
||||||
<TableBody>
|
draggableColumns={false}
|
||||||
{deptLoading ? (
|
columnOrderKey="c16-department"
|
||||||
<TableRow>
|
/>
|
||||||
<TableCell colSpan={5} className="text-center py-12">
|
|
||||||
<Loader2 className="w-5 h-5 animate-spin mx-auto text-muted-foreground" />
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
) : depts.length === 0 ? (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={5} className="text-center py-12 text-muted-foreground text-sm">
|
|
||||||
등록된 부서가 없어요
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
) : depts.map((dept, idx) => (
|
|
||||||
<TableRow
|
|
||||||
key={dept.id}
|
|
||||||
className={cn(
|
|
||||||
"cursor-pointer select-none",
|
|
||||||
selectedDeptId === dept.id ? "bg-primary/10 hover:bg-primary/10" : "hover:bg-muted/50"
|
|
||||||
)}
|
|
||||||
onClick={() => setSelectedDeptId((prev) => prev === dept.id ? null : dept.id)}
|
|
||||||
onDoubleClick={openDeptEdit}
|
|
||||||
>
|
|
||||||
<TableCell className="text-center text-[13px] text-muted-foreground px-2">{idx + 1}</TableCell>
|
|
||||||
<TableCell className="text-[13px] font-mono text-muted-foreground">{dept.dept_code}</TableCell>
|
|
||||||
<TableCell className="text-sm font-medium">{dept.dept_name}</TableCell>
|
|
||||||
{isColVisible("parent_dept_code") && <TableCell style={ts.thStyle("parent_dept_code")} className="text-[13px] text-muted-foreground">{dept.parent_dept_code || "—"}</TableCell>}
|
|
||||||
{isColVisible("status") && (
|
|
||||||
<TableCell style={ts.thStyle("status")} className="text-[13px]">
|
|
||||||
{dept.status && (
|
|
||||||
<Badge
|
|
||||||
variant={dept.status === "active" ? "default" : "outline"}
|
|
||||||
className="text-[10px] px-1.5 py-0 h-5"
|
|
||||||
>
|
|
||||||
{dept.status === "active" ? "활성" : (dept.status || "—")}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
)}
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import {
|
|||||||
Pencil, Copy, Settings2,
|
Pencil, Copy, Settings2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { apiClient } from "@/lib/api/client";
|
import { apiClient } from "@/lib/api/client";
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
@@ -335,64 +336,21 @@ export default function ItemInfoPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 메인 테이블 */}
|
{/* 메인 테이블 */}
|
||||||
<div className="flex-1 overflow-auto">
|
<EDataTable
|
||||||
{loading ? (
|
columns={ts.visibleColumns.map((col): EDataTableColumn => ({
|
||||||
<div className="flex h-full items-center justify-center">
|
key: col.key,
|
||||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
label: col.label,
|
||||||
</div>
|
align: col.align as "left" | "center" | "right" | undefined,
|
||||||
) : items.length === 0 ? (
|
}))}
|
||||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
data={ts.groupData(items)}
|
||||||
등록된 품목이 없어요
|
loading={loading}
|
||||||
</div>
|
emptyMessage="등록된 품목이 없어요"
|
||||||
) : (
|
selectedId={selectedId}
|
||||||
<Table style={{ tableLayout: "fixed" }}>
|
onSelect={(id) => setSelectedId(id)}
|
||||||
<TableHeader className="sticky top-0 z-10">
|
onRowDoubleClick={(row) => openEditModal(row)}
|
||||||
<TableRow className="bg-muted hover:bg-muted">
|
showRowNumber
|
||||||
<TableHead className="w-10 text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">#</TableHead>
|
draggableColumns={false}
|
||||||
{ts.visibleColumns.map((col) => (
|
/>
|
||||||
<TableHead
|
|
||||||
key={col.key}
|
|
||||||
style={ts.thStyle(col.key)}
|
|
||||||
className={cn(
|
|
||||||
"whitespace-nowrap text-xs",
|
|
||||||
col.align === "right" && "text-right"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{col.label}
|
|
||||||
</TableHead>
|
|
||||||
))}
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{items.map((item, idx) => (
|
|
||||||
<TableRow
|
|
||||||
key={item.id ?? idx}
|
|
||||||
className={cn(
|
|
||||||
"cursor-pointer text-sm",
|
|
||||||
selectedId === item.id && "bg-primary/10"
|
|
||||||
)}
|
|
||||||
onClick={() => setSelectedId(item.id)}
|
|
||||||
onDoubleClick={() => openEditModal(item)}
|
|
||||||
>
|
|
||||||
<TableCell className="text-center text-[13px] text-muted-foreground">{idx + 1}</TableCell>
|
|
||||||
{ts.visibleColumns.map((col) => (
|
|
||||||
<TableCell
|
|
||||||
key={col.key}
|
|
||||||
style={ts.thStyle(col.key)}
|
|
||||||
className={cn(
|
|
||||||
"whitespace-nowrap max-w-[160px] truncate",
|
|
||||||
col.align === "right" && "text-right tabular-nums"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{item[col.key] ?? ""}
|
|
||||||
</TableCell>
|
|
||||||
))}
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 등록/수정 모달 */}
|
{/* 등록/수정 모달 */}
|
||||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
* 외주업체관리와 양방향 연동 (같은 subcontractor_item_mapping 테이블)
|
* 외주업체관리와 양방향 연동 (같은 subcontractor_item_mapping 테이블)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback } from "react";
|
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
@@ -31,6 +31,7 @@ import { exportToExcel } from "@/lib/utils/excelExport";
|
|||||||
import { useTableSettings } from "@/hooks/useTableSettings";
|
import { useTableSettings } from "@/hooks/useTableSettings";
|
||||||
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
||||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||||
|
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
|
||||||
|
|
||||||
const ITEM_TABLE = "item_info";
|
const ITEM_TABLE = "item_info";
|
||||||
const MAPPING_TABLE = "subcontractor_item_mapping";
|
const MAPPING_TABLE = "subcontractor_item_mapping";
|
||||||
@@ -113,6 +114,19 @@ export default function SubcontractorItemPage() {
|
|||||||
return categoryOptions[col]?.find((o) => o.code === code)?.label || code;
|
return categoryOptions[col]?.find((o) => o.code === code)?.label || code;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const mainTableColumns = useMemo<EDataTableColumn[]>(() => {
|
||||||
|
const cols: EDataTableColumn[] = [];
|
||||||
|
if (ts.isVisible("item_number")) cols.push({ key: "item_number", label: "품번", width: "w-[110px]" });
|
||||||
|
if (ts.isVisible("item_name")) cols.push({ key: "item_name", label: "품명", minWidth: "min-w-[130px]", render: (v) => v || "-" });
|
||||||
|
if (ts.isVisible("size")) cols.push({ key: "size", label: "규격", width: "w-[90px]", render: (v) => v || "-" });
|
||||||
|
if (ts.isVisible("unit")) cols.push({ key: "unit", label: "단위", width: "w-[60px]", render: (v) => v || "-" });
|
||||||
|
if (ts.isVisible("standard_price")) cols.push({ key: "standard_price", label: "기준단가", width: "w-[90px]", align: "right", formatNumber: true });
|
||||||
|
if (ts.isVisible("selling_price")) cols.push({ key: "selling_price", label: "판매가격", width: "w-[90px]", align: "right", formatNumber: true });
|
||||||
|
if (ts.isVisible("currency_code")) cols.push({ key: "currency_code", label: "통화", width: "w-[50px]", render: (v) => v || "-" });
|
||||||
|
if (ts.isVisible("status")) cols.push({ key: "status", label: "상태", width: "w-[60px]", render: (v) => v || "-" });
|
||||||
|
return cols;
|
||||||
|
}, [ts.visibleColumns]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
// 좌측: 품목 조회 (division이 "외주관리"인 품목만 필터링)
|
// 좌측: 품목 조회 (division이 "외주관리"인 품목만 필터링)
|
||||||
const outsourcingDivisionCode = categoryOptions["division"]?.find(
|
const outsourcingDivisionCode = categoryOptions["division"]?.find(
|
||||||
(o) => o.label === "외주관리" || o.label === "외주" || o.label.includes("외주")
|
(o) => o.label === "외주관리" || o.label === "외주" || o.label.includes("외주")
|
||||||
@@ -337,52 +351,18 @@ export default function SubcontractorItemPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-auto">
|
<EDataTable
|
||||||
{itemLoading ? (
|
columns={mainTableColumns}
|
||||||
<div className="flex items-center justify-center py-12">
|
data={ts.groupData(items)}
|
||||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
loading={itemLoading}
|
||||||
</div>
|
emptyMessage="등록된 외주품목이 없어요"
|
||||||
) : items.length === 0 ? (
|
selectedId={selectedItemId}
|
||||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
onSelect={(id) => setSelectedItemId(id)}
|
||||||
<Inbox className="w-8 h-8 mb-2 opacity-40" />
|
onRowDoubleClick={() => openEditItem()}
|
||||||
<p className="text-sm">등록된 외주품목이 없어요</p>
|
showPagination={true}
|
||||||
</div>
|
draggableColumns={false}
|
||||||
) : (
|
columnOrderKey="c16-subcontractor-item-main"
|
||||||
<Table noWrapper style={{ tableLayout: "fixed" }}>
|
/>
|
||||||
<thead className="sticky top-0 z-10 bg-card">
|
|
||||||
<TableRow>
|
|
||||||
{ts.isVisible("item_number") && <TableHead style={ts.thStyle("item_number")} className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품번</TableHead>}
|
|
||||||
{ts.isVisible("item_name") && <TableHead style={ts.thStyle("item_name")} className="min-w-[130px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품명</TableHead>}
|
|
||||||
{ts.isVisible("size") && <TableHead style={ts.thStyle("size")} className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">규격</TableHead>}
|
|
||||||
{ts.isVisible("unit") && <TableHead style={ts.thStyle("unit")} className="w-[60px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">단위</TableHead>}
|
|
||||||
{ts.isVisible("standard_price") && <TableHead style={ts.thStyle("standard_price")} className="w-[90px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">기준단가</TableHead>}
|
|
||||||
{ts.isVisible("selling_price") && <TableHead style={ts.thStyle("selling_price")} className="w-[90px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">판매가격</TableHead>}
|
|
||||||
{ts.isVisible("currency_code") && <TableHead style={ts.thStyle("currency_code")} className="w-[50px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">통화</TableHead>}
|
|
||||||
{ts.isVisible("status") && <TableHead style={ts.thStyle("status")} className="w-[60px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">상태</TableHead>}
|
|
||||||
</TableRow>
|
|
||||||
</thead>
|
|
||||||
<TableBody>
|
|
||||||
{items.map((item) => (
|
|
||||||
<TableRow
|
|
||||||
key={item.id}
|
|
||||||
className={cn("cursor-pointer", selectedItemId === item.id && "border-l-2 border-l-primary bg-primary/[0.08]")}
|
|
||||||
onClick={() => setSelectedItemId(item.id)}
|
|
||||||
onDoubleClick={openEditItem}
|
|
||||||
>
|
|
||||||
{ts.isVisible("item_number") && <TableCell style={ts.thStyle("item_number")} className="text-[13px] font-mono">{item.item_number}</TableCell>}
|
|
||||||
{ts.isVisible("item_name") && <TableCell style={ts.thStyle("item_name")} className="text-sm max-w-[150px] truncate" title={item.item_name}>{item.item_name || "-"}</TableCell>}
|
|
||||||
{ts.isVisible("size") && <TableCell style={ts.thStyle("size")} className="text-[13px]">{item.size || "-"}</TableCell>}
|
|
||||||
{ts.isVisible("unit") && <TableCell style={ts.thStyle("unit")} className="text-[13px]">{item.unit || "-"}</TableCell>}
|
|
||||||
{ts.isVisible("standard_price") && <TableCell style={ts.thStyle("standard_price")} className="text-[13px] text-right font-mono">{formatNum(item.standard_price)}</TableCell>}
|
|
||||||
{ts.isVisible("selling_price") && <TableCell style={ts.thStyle("selling_price")} className="text-[13px] text-right font-mono">{formatNum(item.selling_price)}</TableCell>}
|
|
||||||
{ts.isVisible("currency_code") && <TableCell style={ts.thStyle("currency_code")} className="text-[13px]">{item.currency_code || "-"}</TableCell>}
|
|
||||||
{ts.isVisible("status") && <TableCell style={ts.thStyle("status")} className="text-[13px]">{item.status || "-"}</TableCell>}
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback } from "react";
|
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
@@ -31,6 +31,7 @@ import { validateField, validateForm, formatField } from "@/lib/utils/validation
|
|||||||
import { useTableSettings } from "@/hooks/useTableSettings";
|
import { useTableSettings } from "@/hooks/useTableSettings";
|
||||||
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
||||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||||
|
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
|
||||||
|
|
||||||
const SUBCONTRACTOR_TABLE = "subcontractor_mng";
|
const SUBCONTRACTOR_TABLE = "subcontractor_mng";
|
||||||
const MAPPING_TABLE = "subcontractor_item_mapping";
|
const MAPPING_TABLE = "subcontractor_item_mapping";
|
||||||
@@ -167,6 +168,14 @@ export default function SubcontractorManagementPage() {
|
|||||||
return val;
|
return val;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const mainTableColumns = useMemo<EDataTableColumn[]>(() => {
|
||||||
|
return ts.visibleColumns.map((col) => ({
|
||||||
|
key: col.key,
|
||||||
|
label: col.label,
|
||||||
|
render: (value: any, row: any) => renderCellValue(row, col.key),
|
||||||
|
}));
|
||||||
|
}, [ts.visibleColumns, categoryOptions]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
// 외주업체 목록 조회
|
// 외주업체 목록 조회
|
||||||
const fetchSubcontractors = useCallback(async () => {
|
const fetchSubcontractors = useCallback(async () => {
|
||||||
setSubcontractorLoading(true);
|
setSubcontractorLoading(true);
|
||||||
@@ -831,49 +840,18 @@ export default function SubcontractorManagementPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-auto">
|
<EDataTable
|
||||||
{subcontractorLoading ? (
|
columns={mainTableColumns}
|
||||||
<div className="flex items-center justify-center py-16">
|
data={ts.groupData(subcontractors)}
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
loading={subcontractorLoading}
|
||||||
</div>
|
emptyMessage="등록된 외주업체가 없어요"
|
||||||
) : subcontractors.length === 0 ? (
|
selectedId={selectedSubcontractorId}
|
||||||
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
|
onSelect={(id) => setSelectedSubcontractorId(id)}
|
||||||
<Wrench className="h-12 w-12 mb-3 opacity-30" />
|
onRowDoubleClick={() => openSubcontractorEdit()}
|
||||||
<p className="text-sm font-medium">등록된 외주업체가 없어요</p>
|
showPagination={true}
|
||||||
</div>
|
draggableColumns={false}
|
||||||
) : (
|
columnOrderKey="c16-subcontractor-main"
|
||||||
<Table>
|
/>
|
||||||
<TableHeader className="sticky top-0 z-10">
|
|
||||||
<TableRow className="bg-muted hover:bg-muted">
|
|
||||||
{ts.visibleColumns.map((col) => (
|
|
||||||
<TableHead key={col.key} className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">{col.label}</TableHead>
|
|
||||||
))}
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{subcontractors.map((sub) => (
|
|
||||||
<TableRow
|
|
||||||
key={sub.id}
|
|
||||||
className={cn(
|
|
||||||
"cursor-pointer",
|
|
||||||
selectedSubcontractorId === sub.id
|
|
||||||
? "bg-primary/10 border-l-2 border-l-primary"
|
|
||||||
: "hover:bg-muted/50"
|
|
||||||
)}
|
|
||||||
onClick={() => setSelectedSubcontractorId(sub.id)}
|
|
||||||
onDoubleClick={openSubcontractorEdit}
|
|
||||||
>
|
|
||||||
{ts.visibleColumns.map((col) => (
|
|
||||||
<TableCell key={col.key} className="text-[13px]">
|
|
||||||
{renderCellValue(sub, col.key)}
|
|
||||||
</TableCell>
|
|
||||||
))}
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ import { useTableSettings } from "@/hooks/useTableSettings";
|
|||||||
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
||||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||||
import { exportToExcel } from "@/lib/utils/excelExport";
|
import { exportToExcel } from "@/lib/utils/excelExport";
|
||||||
|
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
|
||||||
|
|
||||||
// ─── 상수 ───────────────────────────────────────
|
// ─── 상수 ───────────────────────────────────────
|
||||||
const BOM_TABLE = "bom";
|
const BOM_TABLE = "bom";
|
||||||
@@ -945,71 +946,31 @@ export default function BomManagementPage() {
|
|||||||
|
|
||||||
{/* BOM 목록 테이블 */}
|
{/* BOM 목록 테이블 */}
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
{loading ? (
|
<EDataTable
|
||||||
<div className="flex items-center justify-center py-12">
|
columns={ts.visibleColumns.map((col): EDataTableColumn => ({
|
||||||
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
|
key: col.key,
|
||||||
</div>
|
label: col.label,
|
||||||
) : bomList.length === 0 ? (
|
render: col.key === "item_code"
|
||||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground gap-2">
|
? (_val: any, row: any) => <span className="font-mono text-[13px]">{row.item_code || row.item_number || "-"}</span>
|
||||||
<Inbox className="w-8 h-8 text-muted-foreground/40" />
|
: col.key === "bom_type"
|
||||||
<p className="text-xs">등록된 BOM이 없어요</p>
|
? (_val: any, row: any) => <span className="text-[13px]">{BOM_TYPE_OPTIONS.find((o) => o.code === row.bom_type)?.label || row.bom_type || "-"}</span>
|
||||||
</div>
|
: col.key === "status"
|
||||||
) : (
|
? (_val: any, row: any) => renderStatusBadge(row.status)
|
||||||
<Table style={{ tableLayout: "fixed" }}>
|
: undefined,
|
||||||
<TableHeader className="sticky top-0 z-10">
|
}))}
|
||||||
<TableRow className="bg-muted hover:bg-muted">
|
data={ts.groupData(bomList)}
|
||||||
<TableHead className="w-[36px] text-center">
|
loading={loading}
|
||||||
<Checkbox
|
emptyMessage="등록된 BOM이 없어요"
|
||||||
checked={checkedIds.length === bomList.length && bomList.length > 0}
|
showCheckbox
|
||||||
onCheckedChange={(checked) =>
|
checkedIds={checkedIds}
|
||||||
setCheckedIds(checked ? bomList.map((r) => r.id) : [])
|
onCheckedChange={setCheckedIds}
|
||||||
}
|
selectedId={selectedBomId}
|
||||||
|
onSelect={(id) => setSelectedBomId(id)}
|
||||||
|
onRowClick={(row) => setSelectedBomId(row.id)}
|
||||||
|
showPagination
|
||||||
|
draggableColumns={false}
|
||||||
|
columnOrderKey="c16-bom"
|
||||||
/>
|
/>
|
||||||
</TableHead>
|
|
||||||
{ts.visibleColumns.map((col) => (
|
|
||||||
<TableHead key={col.key} style={ts.thStyle(col.key)} className="text-xs text-[11px] font-bold uppercase tracking-wide text-muted-foreground">{col.label}</TableHead>
|
|
||||||
))}
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{bomList.map((row) => (
|
|
||||||
<TableRow
|
|
||||||
key={row.id}
|
|
||||||
className={cn(
|
|
||||||
"cursor-pointer transition-colors",
|
|
||||||
selectedBomId === row.id
|
|
||||||
? "bg-primary/10 border-l-2 border-l-primary"
|
|
||||||
: "hover:bg-muted/50"
|
|
||||||
)}
|
|
||||||
onClick={() => setSelectedBomId(row.id)}
|
|
||||||
>
|
|
||||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
|
||||||
<Checkbox
|
|
||||||
checked={checkedIds.includes(row.id)}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
setCheckedIds((prev) =>
|
|
||||||
checked ? [...prev, row.id] : prev.filter((id) => id !== row.id)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
{ts.visibleColumns.map((col) => {
|
|
||||||
if (col.key === "item_code") {
|
|
||||||
return <TableCell key={col.key} style={ts.thStyle(col.key)} className="font-mono text-[13px]">{row.item_code || row.item_number || "-"}</TableCell>;
|
|
||||||
}
|
|
||||||
if (col.key === "bom_type") {
|
|
||||||
return <TableCell key={col.key} style={ts.thStyle(col.key)} className="text-[13px]">{BOM_TYPE_OPTIONS.find((o) => o.code === row.bom_type)?.label || row.bom_type || "-"}</TableCell>;
|
|
||||||
}
|
|
||||||
if (col.key === "status") {
|
|
||||||
return <TableCell key={col.key} style={ts.thStyle(col.key)}>{renderStatusBadge(row.status)}</TableCell>;
|
|
||||||
}
|
|
||||||
return <TableCell key={col.key} style={ts.thStyle(col.key)} className="text-[13px] max-w-[160px] truncate">{row[col.key] || "-"}</TableCell>;
|
|
||||||
})}
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
|
|||||||
@@ -1040,8 +1040,25 @@ export default function ProductionPlanManagementPage() {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{orderItems.map((item) => (
|
{ts.groupData(orderItems).map((item, rowIdx) => {
|
||||||
<React.Fragment key={item.item_code}>
|
if (item._isGroupSummary) {
|
||||||
|
return (
|
||||||
|
<TableRow key={`summary-${rowIdx}`} className="bg-muted/60 font-semibold border-t border-primary/20">
|
||||||
|
<TableCell />
|
||||||
|
<TableCell />
|
||||||
|
{ts.visibleColumns.map((col) => {
|
||||||
|
const v = (item as any)[col.key];
|
||||||
|
return (
|
||||||
|
<TableCell key={col.key} style={ts.thStyle(col.key)} className={typeof v === "number" ? "text-right font-mono text-[13px]" : "text-[13px] text-primary"}>
|
||||||
|
{typeof v === "number" ? Number(v).toLocaleString() : (v || "")}
|
||||||
|
</TableCell>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<React.Fragment key={item.item_code || rowIdx}>
|
||||||
<TableRow className={cn("cursor-pointer border-t-2 border-t-primary/30 bg-primary/5 font-semibold hover:bg-primary/10", selectedItemGroups.has(item.item_code) && "bg-primary/10")}>
|
<TableRow className={cn("cursor-pointer border-t-2 border-t-primary/30 bg-primary/5 font-semibold hover:bg-primary/10", selectedItemGroups.has(item.item_code) && "bg-primary/10")}>
|
||||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||||
<Checkbox checked={selectedItemGroups.has(item.item_code)} onCheckedChange={() => toggleItemGroupSelect(item.item_code)} className="h-4 w-4" />
|
<Checkbox checked={selectedItemGroups.has(item.item_code)} onCheckedChange={() => toggleItemGroupSelect(item.item_code)} className="h-4 w-4" />
|
||||||
@@ -1093,7 +1110,8 @@ export default function ProductionPlanManagementPage() {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { Label } from "@/components/ui/label";
|
|||||||
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
|
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -684,62 +685,27 @@ export function ItemRoutingTab() {
|
|||||||
<div className="min-h-0 flex-1 overflow-auto">
|
<div className="min-h-0 flex-1 overflow-auto">
|
||||||
{!selectedVersionId ? (
|
{!selectedVersionId ? (
|
||||||
<p className="py-8 text-center text-sm text-muted-foreground">버전을 선택해주세요</p>
|
<p className="py-8 text-center text-sm text-muted-foreground">버전을 선택해주세요</p>
|
||||||
) : detailsLoading ? (
|
|
||||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
|
||||||
<Loader2 className="h-7 w-7 animate-spin" />
|
|
||||||
<p className="mt-2 text-sm">불러오는 중...</p>
|
|
||||||
</div>
|
|
||||||
) : detailsGridData.length === 0 ? (
|
|
||||||
<p className="py-8 text-center text-sm text-muted-foreground">등록된 공정이 없어요</p>
|
|
||||||
) : (
|
) : (
|
||||||
<Table>
|
<EDataTable
|
||||||
<TableHeader className="sticky top-0 bg-muted/90 z-10">
|
columns={[
|
||||||
<TableRow>
|
{ key: "seq_no", label: "순번", width: "w-[80px]", align: "center" as const },
|
||||||
<TableHead className="w-10 text-muted-foreground">
|
{ key: "process_display", label: "공정명" },
|
||||||
<Checkbox
|
{ key: "is_required", label: "필수", width: "w-[80px]", align: "center" as const },
|
||||||
checked={selectedDetailIds.size === detailsGridData.length && detailsGridData.length > 0}
|
{ key: "is_fixed_order", label: "순서고정", width: "w-[90px]", align: "center" as const },
|
||||||
onCheckedChange={(checked) => {
|
{ key: "work_type", label: "작업구분", width: "w-[100px]" },
|
||||||
if (checked) setSelectedDetailIds(new Set(detailsGridData.map((r) => r.id)));
|
{ key: "standard_time", label: "표준시간", width: "w-[90px]", align: "right" as const },
|
||||||
else setSelectedDetailIds(new Set());
|
{ key: "outsource_display", label: "외주업체" },
|
||||||
}}
|
] as EDataTableColumn[]}
|
||||||
|
data={detailsGridData}
|
||||||
|
rowKey={(row) => row.id}
|
||||||
|
loading={detailsLoading}
|
||||||
|
emptyMessage="등록된 공정이 없어요"
|
||||||
|
showCheckbox
|
||||||
|
checkedIds={Array.from(selectedDetailIds)}
|
||||||
|
onCheckedChange={(ids) => setSelectedDetailIds(new Set(ids))}
|
||||||
|
showPagination={false}
|
||||||
|
draggableColumns={false}
|
||||||
/>
|
/>
|
||||||
</TableHead>
|
|
||||||
<TableHead className="w-[80px] text-center text-muted-foreground">순번</TableHead>
|
|
||||||
<TableHead className="text-muted-foreground">공정명</TableHead>
|
|
||||||
<TableHead className="w-[80px] text-center text-muted-foreground">필수</TableHead>
|
|
||||||
<TableHead className="w-[90px] text-center text-muted-foreground">순서고정</TableHead>
|
|
||||||
<TableHead className="w-[100px] text-muted-foreground">작업구분</TableHead>
|
|
||||||
<TableHead className="w-[90px] text-right text-muted-foreground">표준시간</TableHead>
|
|
||||||
<TableHead className="text-muted-foreground">외주업체</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{detailsGridData.map((row) => (
|
|
||||||
<TableRow key={row.id} className="hover:bg-muted/30">
|
|
||||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
|
||||||
<Checkbox
|
|
||||||
checked={selectedDetailIds.has(row.id)}
|
|
||||||
onCheckedChange={(checked) => {
|
|
||||||
setSelectedDetailIds((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
if (checked) next.add(row.id);
|
|
||||||
else next.delete(row.id);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-center">{row.seq_no}</TableCell>
|
|
||||||
<TableCell>{row.process_display}</TableCell>
|
|
||||||
<TableCell className="text-center">{row.is_required}</TableCell>
|
|
||||||
<TableCell className="text-center">{row.is_fixed_order}</TableCell>
|
|
||||||
<TableCell>{row.work_type}</TableCell>
|
|
||||||
<TableCell className="text-right">{row.standard_time}</TableCell>
|
|
||||||
<TableCell>{row.outsource_display}</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
|
||||||
import {
|
import {
|
||||||
getProcessList,
|
getProcessList,
|
||||||
createProcess,
|
createProcess,
|
||||||
@@ -435,69 +436,34 @@ export function ProcessMasterTab() {
|
|||||||
|
|
||||||
{/* 공정 목록 테이블 */}
|
{/* 공정 목록 테이블 */}
|
||||||
<div className="min-h-0 flex-1 overflow-auto">
|
<div className="min-h-0 flex-1 overflow-auto">
|
||||||
{listBusy ? (
|
<EDataTable
|
||||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
columns={[
|
||||||
<Loader2 className="h-7 w-7 animate-spin" />
|
{ key: "process_code", label: "공정코드", width: "w-[130px]", render: (val: any) => <span className="font-mono text-xs">{val}</span> },
|
||||||
<p className="mt-2 text-sm">불러오는 중...</p>
|
{ key: "process_name", label: "공정명" },
|
||||||
</div>
|
{ key: "process_type_display", label: "공정유형", width: "w-[120px]" },
|
||||||
) : processGridData.length === 0 ? (
|
{ key: "standard_time", label: "표준시간(분)", width: "w-[110px]", align: "right" as const },
|
||||||
<p className="py-8 text-center text-sm text-muted-foreground">조회된 공정이 없어요</p>
|
{ key: "worker_count", label: "작업인원", width: "w-[90px]", align: "right" as const },
|
||||||
) : (
|
{ key: "use_yn_display", label: "사용여부", width: "w-[90px]", align: "center" as const },
|
||||||
<Table>
|
] as EDataTableColumn[]}
|
||||||
<TableHeader className="sticky top-0 bg-muted/90 z-10">
|
data={processGridData}
|
||||||
<TableRow>
|
rowKey={(row) => row.id}
|
||||||
<TableHead className="w-10 text-muted-foreground">
|
loading={listBusy}
|
||||||
<Checkbox
|
emptyMessage="조회된 공정이 없어요"
|
||||||
checked={selectedIds.size === processGridData.length && processGridData.length > 0}
|
selectedId={selectedProcess?.id ?? null}
|
||||||
onCheckedChange={(checked) => {
|
onSelect={(id) => {
|
||||||
if (checked) setSelectedIds(new Set(processGridData.map((r) => r.id)));
|
const proc = processes.find((p) => p.id === id);
|
||||||
else setSelectedIds(new Set());
|
setSelectedProcess(proc || null);
|
||||||
}}
|
}}
|
||||||
/>
|
onRowClick={(row) => {
|
||||||
</TableHead>
|
|
||||||
<TableHead className="w-[130px] text-muted-foreground">공정코드</TableHead>
|
|
||||||
<TableHead className="text-muted-foreground">공정명</TableHead>
|
|
||||||
<TableHead className="w-[120px] text-muted-foreground">공정유형</TableHead>
|
|
||||||
<TableHead className="w-[110px] text-right text-muted-foreground">표준시간(분)</TableHead>
|
|
||||||
<TableHead className="w-[90px] text-right text-muted-foreground">작업인원</TableHead>
|
|
||||||
<TableHead className="w-[90px] text-center text-muted-foreground">사용여부</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{processGridData.map((row) => (
|
|
||||||
<TableRow
|
|
||||||
key={row.id}
|
|
||||||
data-state={selectedProcess?.id === row.id ? "selected" : undefined}
|
|
||||||
className="cursor-pointer hover:bg-muted/30"
|
|
||||||
onClick={() => {
|
|
||||||
const proc = processes.find((p) => p.id === row.id);
|
const proc = processes.find((p) => p.id === row.id);
|
||||||
setSelectedProcess(proc || null);
|
setSelectedProcess(proc || null);
|
||||||
}}
|
}}
|
||||||
>
|
showCheckbox
|
||||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
checkedIds={Array.from(selectedIds)}
|
||||||
<Checkbox
|
onCheckedChange={(ids) => setSelectedIds(new Set(ids))}
|
||||||
checked={selectedIds.has(row.id)}
|
showPagination={false}
|
||||||
onCheckedChange={(checked) => {
|
draggableColumns={false}
|
||||||
setSelectedIds((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
if (checked) next.add(row.id);
|
|
||||||
else next.delete(row.id);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
|
||||||
<TableCell className="font-mono text-xs">{row.process_code}</TableCell>
|
|
||||||
<TableCell>{row.process_name}</TableCell>
|
|
||||||
<TableCell>{row.process_type_display}</TableCell>
|
|
||||||
<TableCell className="text-right">{row.standard_time}</TableCell>
|
|
||||||
<TableCell className="text-right">{row.worker_count}</TableCell>
|
|
||||||
<TableCell className="text-center">{row.use_yn_display}</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { WorkStandardEditModal } from "./WorkStandardEditModal";
|
|||||||
import { useTableSettings } from "@/hooks/useTableSettings";
|
import { useTableSettings } from "@/hooks/useTableSettings";
|
||||||
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
||||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||||
|
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
|
||||||
|
|
||||||
const GRID_COLUMNS = [
|
const GRID_COLUMNS = [
|
||||||
{ key: "work_instruction_no", label: "작업지시번호" },
|
{ key: "work_instruction_no", label: "작업지시번호" },
|
||||||
@@ -445,52 +446,20 @@ export default function WorkInstructionPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 테이블 */}
|
{/* 테이블 */}
|
||||||
<div className="flex-1 overflow-auto">
|
<EDataTable
|
||||||
<Table style={{ tableLayout: "fixed" }}>
|
columns={[
|
||||||
<TableHeader className="sticky top-0 z-10">
|
{ key: "work_instruction_no", label: "작업지시번호", width: "w-[150px]", render: (_v, row) => <span className="font-mono text-[13px] font-medium">{getDisplayNo(row)}</span> },
|
||||||
<TableRow className="bg-muted hover:bg-muted">
|
{ key: "status", label: "상태", width: "w-[70px]", align: "center", render: (v) => {
|
||||||
{ts.isVisible("work_instruction_no") && <TableHead style={ts.thStyle("work_instruction_no")} className="w-[150px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">작업지시번호</TableHead>}
|
const sBadge = STATUS_BADGE[v] || STATUS_BADGE["일반"];
|
||||||
{ts.isVisible("status") && <TableHead style={ts.thStyle("status")} className="w-[70px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">상태</TableHead>}
|
return <Badge variant="outline" className={cn("text-[10px]", sBadge.cls)}>{sBadge.label}</Badge>;
|
||||||
{ts.isVisible("progress") && <TableHead style={ts.thStyle("progress")} className="w-[100px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">진행현황</TableHead>}
|
}},
|
||||||
{ts.isVisible("item_name") && <TableHead style={ts.thStyle("item_name")} className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품목명</TableHead>}
|
{ key: "progress", label: "진행현황", width: "w-[100px]", align: "center", sortable: false, filterable: false, render: (_v, row) => {
|
||||||
{ts.isVisible("spec") && <TableHead style={ts.thStyle("spec")} className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">규격</TableHead>}
|
const isFirstOfGroup = Number(row.detail_seq) === 1;
|
||||||
{ts.isVisible("qty") && <TableHead style={ts.thStyle("qty")} className="w-[80px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">수량</TableHead>}
|
if (!isFirstOfGroup) return <span className="text-[10px] text-muted-foreground">↑</span>;
|
||||||
{ts.isVisible("equipment") && <TableHead style={ts.thStyle("equipment")} className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">설비</TableHead>}
|
const pct = getProgress(row);
|
||||||
{ts.isVisible("routing") && <TableHead style={ts.thStyle("routing")} className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">라우팅</TableHead>}
|
const pLabel = getProgressLabel(row);
|
||||||
{ts.isVisible("work_team") && <TableHead style={ts.thStyle("work_team")} className="w-[80px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">작업조</TableHead>}
|
|
||||||
{ts.isVisible("worker") && <TableHead style={ts.thStyle("worker")} className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">작업자</TableHead>}
|
|
||||||
{ts.isVisible("start_date") && <TableHead style={ts.thStyle("start_date")} className="w-[100px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">시작일</TableHead>}
|
|
||||||
{ts.isVisible("end_date") && <TableHead style={ts.thStyle("end_date")} className="w-[100px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">완료일</TableHead>}
|
|
||||||
{ts.isVisible("actions") && <TableHead style={ts.thStyle("actions")} className="w-[150px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">작업</TableHead>}
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{loading ? (
|
|
||||||
<TableRow><TableCell colSpan={13} className="text-center py-16"><Loader2 className="w-6 h-6 animate-spin mx-auto text-muted-foreground" /></TableCell></TableRow>
|
|
||||||
) : orders.length === 0 ? (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={13} className="py-16">
|
|
||||||
<div className="flex flex-col items-center justify-center text-center">
|
|
||||||
<div className="rounded-full border-2 border-dashed border-muted-foreground/20 w-12 h-12 flex items-center justify-center mb-4">
|
|
||||||
<Inbox className="w-6 h-6 text-muted-foreground/40" />
|
|
||||||
</div>
|
|
||||||
<p className="text-sm font-medium text-muted-foreground mb-1">등록된 작업지시가 없어요</p>
|
|
||||||
<p className="text-xs text-muted-foreground/60">새로운 작업지시를 등록해주세요</p>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
) : orders.map((o, rowIdx) => {
|
|
||||||
const pct = getProgress(o);
|
|
||||||
const pLabel = getProgressLabel(o);
|
|
||||||
const pBadge = PROGRESS_BADGE[pLabel] || PROGRESS_BADGE["대기"];
|
const pBadge = PROGRESS_BADGE[pLabel] || PROGRESS_BADGE["대기"];
|
||||||
const sBadge = STATUS_BADGE[o.status] || STATUS_BADGE["일반"];
|
|
||||||
const isFirstOfGroup = Number(o.detail_seq) === 1;
|
|
||||||
return (
|
return (
|
||||||
<TableRow key={`${o.wi_id}-${o.detail_id}`} className="hover:bg-muted/30">
|
|
||||||
{ts.isVisible("work_instruction_no") && <TableCell style={ts.thStyle("work_instruction_no")} className="font-mono text-[13px] font-medium">{getDisplayNo(o)}</TableCell>}
|
|
||||||
{ts.isVisible("status") && <TableCell style={ts.thStyle("status")} className="text-center"><Badge variant="outline" className={cn("text-[10px]", sBadge.cls)}>{sBadge.label}</Badge></TableCell>}
|
|
||||||
{ts.isVisible("progress") && <TableCell style={ts.thStyle("progress")} className="text-center">
|
|
||||||
{isFirstOfGroup ? (
|
|
||||||
<div className="flex flex-col items-center gap-1">
|
<div className="flex flex-col items-center gap-1">
|
||||||
<Badge variant="secondary" className={cn("text-[10px]", pBadge.cls)}>{pBadge.label}</Badge>
|
<Badge variant="secondary" className={cn("text-[10px]", pBadge.cls)}>{pBadge.label}</Badge>
|
||||||
<div className="w-full h-1.5 bg-muted rounded-full overflow-hidden">
|
<div className="w-full h-1.5 bg-muted rounded-full overflow-hidden">
|
||||||
@@ -498,51 +467,53 @@ export default function WorkInstructionPage() {
|
|||||||
</div>
|
</div>
|
||||||
<span className="text-[10px] text-muted-foreground">{pct}%</span>
|
<span className="text-[10px] text-muted-foreground">{pct}%</span>
|
||||||
</div>
|
</div>
|
||||||
) : <span className="text-[10px] text-muted-foreground">↑</span>}
|
);
|
||||||
</TableCell>}
|
}},
|
||||||
{ts.isVisible("item_name") && <TableCell style={ts.thStyle("item_name")} className="text-sm">{o.item_name || o.item_number || "-"}</TableCell>}
|
{ key: "item_name", label: "품목명", render: (_v, row) => row.item_name || row.item_number || "-" },
|
||||||
{ts.isVisible("spec") && <TableCell style={ts.thStyle("spec")} className="text-[13px] text-muted-foreground">{o.item_spec || "-"}</TableCell>}
|
{ key: "item_spec", label: "규격", width: "w-[100px]" },
|
||||||
{ts.isVisible("qty") && <TableCell style={ts.thStyle("qty")} className="text-right text-[13px] font-mono font-medium">{Number(o.detail_qty || 0).toLocaleString()}</TableCell>}
|
{ key: "detail_qty", label: "수량", width: "w-[80px]", align: "right", formatNumber: true },
|
||||||
{ts.isVisible("equipment") && <TableCell style={ts.thStyle("equipment")} className="text-[13px]">{isFirstOfGroup ? (o.equipment_name || "-") : ""}</TableCell>}
|
{ key: "equipment_name", label: "설비", width: "w-[120px]", render: (v, row) => Number(row.detail_seq) === 1 ? (v || "-") : "" },
|
||||||
{ts.isVisible("routing") && <TableCell style={ts.thStyle("routing")} className="text-[13px]">
|
{ key: "routing", label: "라우팅", width: "w-[120px]", sortable: false, filterable: false, render: (_v, row) => {
|
||||||
{isFirstOfGroup ? (
|
const isFirstOfGroup = Number(row.detail_seq) === 1;
|
||||||
o.routing_version_id ? (
|
if (!isFirstOfGroup) return "";
|
||||||
|
if (row.routing_version_id) {
|
||||||
|
return (
|
||||||
<button
|
<button
|
||||||
className="text-primary underline underline-offset-2 hover:text-primary/80 cursor-pointer text-xs text-left"
|
className="text-primary underline underline-offset-2 hover:text-primary/80 cursor-pointer text-xs text-left"
|
||||||
onClick={e => {
|
onClick={e => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
openWorkStandardModal(
|
openWorkStandardModal(row.work_instruction_no, row.routing_version_id, row.routing_name || "", row.item_name || row.item_number || "", row.item_number || "");
|
||||||
o.work_instruction_no,
|
|
||||||
o.routing_version_id,
|
|
||||||
o.routing_name || "",
|
|
||||||
o.item_name || o.item_number || "",
|
|
||||||
o.item_number || ""
|
|
||||||
);
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{o.routing_name || "라우팅"} <ClipboardCheck className="w-3 h-3 inline ml-0.5" />
|
{row.routing_name || "라우팅"} <ClipboardCheck className="w-3 h-3 inline ml-0.5" />
|
||||||
</button>
|
</button>
|
||||||
) : <span className="text-muted-foreground">-</span>
|
|
||||||
) : ""}
|
|
||||||
</TableCell>}
|
|
||||||
{ts.isVisible("work_team") && <TableCell style={ts.thStyle("work_team")} className="text-center text-[13px]">{isFirstOfGroup ? (o.work_team || "-") : ""}</TableCell>}
|
|
||||||
{ts.isVisible("worker") && <TableCell style={ts.thStyle("worker")} className="text-[13px]">{isFirstOfGroup ? getWorkerName(o.worker) : ""}</TableCell>}
|
|
||||||
{ts.isVisible("start_date") && <TableCell style={ts.thStyle("start_date")} className="text-center text-[13px] font-mono">{isFirstOfGroup ? (o.start_date || "-") : ""}</TableCell>}
|
|
||||||
{ts.isVisible("end_date") && <TableCell style={ts.thStyle("end_date")} className="text-center text-[13px] font-mono">{isFirstOfGroup ? (o.end_date || "-") : ""}</TableCell>}
|
|
||||||
{ts.isVisible("actions") && <TableCell style={ts.thStyle("actions")} className="text-center">
|
|
||||||
{isFirstOfGroup && (
|
|
||||||
<div className="flex items-center justify-center gap-1">
|
|
||||||
<Button variant="ghost" size="sm" className="h-7 text-xs px-2" onClick={() => openEditModal(o)}><Pencil className="w-3 h-3" /> 수정</Button>
|
|
||||||
<Button variant="ghost" size="sm" className="h-7 text-xs px-2 text-destructive hover:text-destructive" onClick={() => handleDelete(o.wi_id)}><Trash2 className="w-3 h-3" /></Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</TableCell>}
|
|
||||||
</TableRow>
|
|
||||||
);
|
);
|
||||||
})}
|
}
|
||||||
</TableBody>
|
return <span className="text-muted-foreground">-</span>;
|
||||||
</Table>
|
}},
|
||||||
|
{ key: "work_team", label: "작업조", width: "w-[80px]", align: "center", render: (v, row) => Number(row.detail_seq) === 1 ? (v || "-") : "" },
|
||||||
|
{ key: "worker", label: "작업자", width: "w-[100px]", render: (v, row) => Number(row.detail_seq) === 1 ? getWorkerName(v) : "" },
|
||||||
|
{ key: "start_date", label: "시작일", width: "w-[100px]", align: "center", render: (v, row) => Number(row.detail_seq) === 1 ? (v || "-") : "" },
|
||||||
|
{ key: "end_date", label: "완료일", width: "w-[100px]", align: "center", render: (v, row) => Number(row.detail_seq) === 1 ? (v || "-") : "" },
|
||||||
|
{ key: "actions", label: "작업", width: "w-[150px]", align: "center", sortable: false, filterable: false, render: (_v, row) => {
|
||||||
|
const isFirstOfGroup = Number(row.detail_seq) === 1;
|
||||||
|
if (!isFirstOfGroup) return null;
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center gap-1">
|
||||||
|
<Button variant="ghost" size="sm" className="h-7 text-xs px-2" onClick={() => openEditModal(row)}><Pencil className="w-3 h-3" /> 수정</Button>
|
||||||
|
<Button variant="ghost" size="sm" className="h-7 text-xs px-2 text-destructive hover:text-destructive" onClick={() => handleDelete(row.wi_id)}><Trash2 className="w-3 h-3" /></Button>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
}},
|
||||||
|
] as EDataTableColumn[]}
|
||||||
|
data={ts.groupData(orders)}
|
||||||
|
rowKey={(row) => `${row.wi_id}-${row.detail_id}`}
|
||||||
|
loading={loading}
|
||||||
|
emptyMessage="등록된 작업지시가 없어요"
|
||||||
|
showPagination
|
||||||
|
draggableColumns
|
||||||
|
columnOrderKey="c16-work-instruction"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { toast } from "sonner";
|
|||||||
import { useTableSettings } from "@/hooks/useTableSettings";
|
import { useTableSettings } from "@/hooks/useTableSettings";
|
||||||
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
||||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||||
|
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
|
||||||
|
|
||||||
const MASTER_TABLE = "purchase_order_mng";
|
const MASTER_TABLE = "purchase_order_mng";
|
||||||
const DETAIL_TABLE = "purchase_detail";
|
const DETAIL_TABLE = "purchase_detail";
|
||||||
@@ -588,8 +589,6 @@ export default function PurchaseOrderPage() {
|
|||||||
toast.success("다운로드 완료");
|
toast.success("다운로드 완료");
|
||||||
};
|
};
|
||||||
|
|
||||||
const allChecked = orders.length > 0 && checkedIds.length === orders.length;
|
|
||||||
const someChecked = checkedIds.length > 0 && checkedIds.length < orders.length;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col gap-3 p-3">
|
<div className="flex h-full flex-col gap-3 p-3">
|
||||||
@@ -638,90 +637,32 @@ export default function PurchaseOrderPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 데이터 테이블 */}
|
{/* 데이터 테이블 */}
|
||||||
<div className="flex-1 overflow-auto border rounded-lg bg-card">
|
<div className="flex-1 overflow-hidden border rounded-lg bg-card">
|
||||||
<Table style={{ tableLayout: "fixed" }}>
|
<EDataTable
|
||||||
<TableHeader className="sticky top-0 z-10">
|
columns={ts.visibleColumns.map((col): EDataTableColumn => ({
|
||||||
<TableRow className="bg-muted hover:bg-muted">
|
key: col.key,
|
||||||
<TableHead className="w-[44px] text-center">
|
label: col.label,
|
||||||
<Checkbox
|
align: ["order_qty", "received_qty", "remain_qty", "unit_price", "amount"].includes(col.key) ? "right" : undefined,
|
||||||
checked={allChecked}
|
formatNumber: ["order_qty", "received_qty", "remain_qty", "unit_price", "amount"].includes(col.key),
|
||||||
data-state={someChecked ? "indeterminate" : undefined}
|
render: col.key === "status"
|
||||||
onCheckedChange={(checked) => {
|
? (val: any, row: any) => row.status ? (
|
||||||
setCheckedIds(checked ? orders.map((o) => o.id) : []);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</TableHead>
|
|
||||||
{ts.isVisible("purchase_no") && <TableHead style={ts.thStyle("purchase_no")} className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">발주번호</TableHead>}
|
|
||||||
{ts.isVisible("order_date") && <TableHead style={ts.thStyle("order_date")} className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">발주일</TableHead>}
|
|
||||||
{ts.isVisible("supplier_name") && <TableHead style={ts.thStyle("supplier_name")} className="w-[140px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">공급업체</TableHead>}
|
|
||||||
{ts.isVisible("item_code") && <TableHead style={ts.thStyle("item_code")} className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품번</TableHead>}
|
|
||||||
{ts.isVisible("item_name") && <TableHead style={ts.thStyle("item_name")} className="w-[140px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품명</TableHead>}
|
|
||||||
{ts.isVisible("spec") && <TableHead style={ts.thStyle("spec")} className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">규격</TableHead>}
|
|
||||||
{ts.isVisible("order_qty") && <TableHead style={ts.thStyle("order_qty")} className="w-[80px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">발주수량</TableHead>}
|
|
||||||
{ts.isVisible("received_qty") && <TableHead style={ts.thStyle("received_qty")} className="w-[80px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">입고수량</TableHead>}
|
|
||||||
{ts.isVisible("remain_qty") && <TableHead style={ts.thStyle("remain_qty")} className="w-[70px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">잔량</TableHead>}
|
|
||||||
{ts.isVisible("unit_price") && <TableHead style={ts.thStyle("unit_price")} className="w-[100px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">단가</TableHead>}
|
|
||||||
{ts.isVisible("amount") && <TableHead style={ts.thStyle("amount")} className="w-[110px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">금액</TableHead>}
|
|
||||||
{ts.isVisible("due_date") && <TableHead style={ts.thStyle("due_date")} className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">납기일</TableHead>}
|
|
||||||
{ts.isVisible("status") && <TableHead style={ts.thStyle("status")} className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">상태</TableHead>}
|
|
||||||
{ts.isVisible("memo") && <TableHead style={ts.thStyle("memo")} className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">메모</TableHead>}
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{loading ? (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={ts.visibleColumns.length + 1} className="text-center py-12">
|
|
||||||
<Loader2 className="w-6 h-6 animate-spin mx-auto text-muted-foreground" />
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
) : orders.length === 0 ? (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={ts.visibleColumns.length + 1} className="text-center py-12 text-muted-foreground text-sm">
|
|
||||||
등록된 발주가 없어요
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
) : orders.map((row) => (
|
|
||||||
<TableRow
|
|
||||||
key={row.id}
|
|
||||||
className={cn("cursor-pointer", checkedIds.includes(row.id) && "bg-primary/5")}
|
|
||||||
onDoubleClick={() => openEditModal(row.purchase_no)}
|
|
||||||
>
|
|
||||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
|
||||||
<Checkbox
|
|
||||||
checked={checkedIds.includes(row.id)}
|
|
||||||
onCheckedChange={(checked) => {
|
|
||||||
setCheckedIds((prev) =>
|
|
||||||
checked ? [...prev, row.id] : prev.filter((id) => id !== row.id)
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
{ts.isVisible("purchase_no") && <TableCell style={ts.thStyle("purchase_no")} className="text-[13px] font-mono">{row.purchase_no}</TableCell>}
|
|
||||||
{ts.isVisible("order_date") && <TableCell style={ts.thStyle("order_date")} className="text-[13px]">{row.order_date}</TableCell>}
|
|
||||||
{ts.isVisible("supplier_name") && <TableCell style={ts.thStyle("supplier_name")} className="text-[13px] max-w-[140px]"><span className="block truncate" title={row.supplier_name}>{row.supplier_name}</span></TableCell>}
|
|
||||||
{ts.isVisible("item_code") && <TableCell style={ts.thStyle("item_code")} className="text-[13px] font-mono max-w-[120px]"><span className="block truncate" title={row.item_code}>{row.item_code}</span></TableCell>}
|
|
||||||
{ts.isVisible("item_name") && <TableCell style={ts.thStyle("item_name")} className="text-[13px] max-w-[140px]"><span className="block truncate" title={row.item_name}>{row.item_name}</span></TableCell>}
|
|
||||||
{ts.isVisible("spec") && <TableCell style={ts.thStyle("spec")} className="text-[13px] text-muted-foreground">{row.spec}</TableCell>}
|
|
||||||
{ts.isVisible("order_qty") && <TableCell style={ts.thStyle("order_qty")} className="text-[13px] text-right font-mono">{row.order_qty ? Number(row.order_qty).toLocaleString() : ""}</TableCell>}
|
|
||||||
{ts.isVisible("received_qty") && <TableCell style={ts.thStyle("received_qty")} className="text-[13px] text-right font-mono">{row.received_qty ? Number(row.received_qty).toLocaleString() : ""}</TableCell>}
|
|
||||||
{ts.isVisible("remain_qty") && <TableCell style={ts.thStyle("remain_qty")} className="text-[13px] text-right font-mono">{row.remain_qty ? Number(row.remain_qty).toLocaleString() : ""}</TableCell>}
|
|
||||||
{ts.isVisible("unit_price") && <TableCell style={ts.thStyle("unit_price")} className="text-[13px] text-right font-mono">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</TableCell>}
|
|
||||||
{ts.isVisible("amount") && <TableCell style={ts.thStyle("amount")} className="text-[13px] text-right font-mono font-semibold">{row.amount ? Number(row.amount).toLocaleString() : ""}</TableCell>}
|
|
||||||
{ts.isVisible("due_date") && <TableCell style={ts.thStyle("due_date")} className="text-[13px]">{row.due_date}</TableCell>}
|
|
||||||
{ts.isVisible("status") && (
|
|
||||||
<TableCell style={ts.thStyle("status")}>
|
|
||||||
{row.status && (
|
|
||||||
<span className={cn("inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-semibold", STATUS_BADGE_CLASS[row.status] || "")}>
|
<span className={cn("inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-semibold", STATUS_BADGE_CLASS[row.status] || "")}>
|
||||||
{row.status}
|
{row.status}
|
||||||
</span>
|
</span>
|
||||||
)}
|
) : null
|
||||||
</TableCell>
|
: undefined,
|
||||||
)}
|
}))}
|
||||||
{ts.isVisible("memo") && <TableCell style={ts.thStyle("memo")} className="text-[13px] text-muted-foreground max-w-[120px]"><span className="block truncate">{row.memo}</span></TableCell>}
|
data={ts.groupData(orders)}
|
||||||
</TableRow>
|
loading={loading}
|
||||||
))}
|
emptyMessage="등록된 발주가 없어요"
|
||||||
</TableBody>
|
showCheckbox
|
||||||
</Table>
|
checkedIds={checkedIds}
|
||||||
|
onCheckedChange={setCheckedIds}
|
||||||
|
onRowDoubleClick={(row) => openEditModal(row.purchase_no)}
|
||||||
|
showPagination
|
||||||
|
draggableColumns={false}
|
||||||
|
columnOrderKey="c16-purchase-order"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 발주 등록/수정 모달 */}
|
{/* 발주 등록/수정 모달 */}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback } from "react";
|
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
@@ -20,6 +20,7 @@ import { exportToExcel } from "@/lib/utils/excelExport";
|
|||||||
import { useTableSettings } from "@/hooks/useTableSettings";
|
import { useTableSettings } from "@/hooks/useTableSettings";
|
||||||
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
||||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||||
|
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
|
||||||
|
|
||||||
const ITEM_TABLE = "item_info";
|
const ITEM_TABLE = "item_info";
|
||||||
const MAPPING_TABLE = "supplier_item_mapping";
|
const MAPPING_TABLE = "supplier_item_mapping";
|
||||||
@@ -128,6 +129,25 @@ export default function PurchaseItemPage() {
|
|||||||
const isColVisible = (key: string) => ts.isVisible(key);
|
const isColVisible = (key: string) => ts.isVisible(key);
|
||||||
const itemColSpan = 2 + ITEM_COLUMNS.filter((c) => isColVisible(c.key)).length;
|
const itemColSpan = 2 + ITEM_COLUMNS.filter((c) => isColVisible(c.key)).length;
|
||||||
|
|
||||||
|
const mainTableColumns = useMemo<EDataTableColumn[]>(() => {
|
||||||
|
const cols: EDataTableColumn[] = [
|
||||||
|
{ key: "item_number", label: "품번", width: "w-[110px]" },
|
||||||
|
{ key: "item_name", label: "품명" },
|
||||||
|
];
|
||||||
|
if (isColVisible("size")) cols.push({ key: "size", label: "규격", width: "w-[90px]", render: (v) => v || "-" });
|
||||||
|
if (isColVisible("unit")) cols.push({ key: "unit", label: "단위", width: "w-[60px]", render: (v) => v || "-" });
|
||||||
|
if (isColVisible("standard_price")) cols.push({ key: "standard_price", label: "기준단가", width: "w-[90px]", align: "right", formatNumber: true });
|
||||||
|
if (isColVisible("status")) cols.push({
|
||||||
|
key: "status", label: "상태", width: "w-[60px]", align: "center",
|
||||||
|
render: (v) => (
|
||||||
|
<span className={cn("text-[10px] font-medium px-1.5 py-0.5 rounded",
|
||||||
|
v === "ACTIVE" || v === "사용" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
|
||||||
|
)}>{v || "-"}</span>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
return cols;
|
||||||
|
}, [ts.visibleColumns]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
// 우측: 공급업체 매핑 조회
|
// 우측: 공급업체 매핑 조회
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedItem?.item_number) { setSupplierItems([]); setSupplierCheckedIds([]); return; }
|
if (!selectedItem?.item_number) { setSupplierItems([]); setSupplierCheckedIds([]); return; }
|
||||||
@@ -380,50 +400,18 @@ export default function PurchaseItemPage() {
|
|||||||
<Settings2 className="h-3.5 w-3.5" />
|
<Settings2 className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-auto">
|
<EDataTable
|
||||||
<Table style={{ tableLayout: "fixed" }}>
|
columns={mainTableColumns}
|
||||||
<TableHeader className="sticky top-0 z-10">
|
data={ts.groupData(items)}
|
||||||
<TableRow className="bg-muted hover:bg-muted">
|
loading={itemLoading}
|
||||||
<TableHead className="w-[110px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품번</TableHead>
|
emptyMessage="등록된 구매품목이 없어요"
|
||||||
<TableHead className="p-2 text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품명</TableHead>
|
selectedId={selectedItemId}
|
||||||
{isColVisible("size") && <TableHead style={ts.thStyle("size")} className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">규격</TableHead>}
|
onSelect={(id) => setSelectedItemId(id)}
|
||||||
{isColVisible("unit") && <TableHead style={ts.thStyle("unit")} className="w-[60px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">단위</TableHead>}
|
onRowDoubleClick={() => openEditItem()}
|
||||||
{isColVisible("standard_price") && <TableHead style={ts.thStyle("standard_price")} className="w-[90px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">기준단가</TableHead>}
|
showPagination={true}
|
||||||
{isColVisible("status") && <TableHead style={ts.thStyle("status")} className="w-[60px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">상태</TableHead>}
|
draggableColumns={false}
|
||||||
</TableRow>
|
columnOrderKey="c16-purchase-item-main"
|
||||||
</TableHeader>
|
/>
|
||||||
<TableBody>
|
|
||||||
{itemLoading ? (
|
|
||||||
<TableRow><TableCell colSpan={itemColSpan} className="h-32 text-center"><Loader2 className="mx-auto h-5 w-5 animate-spin" /></TableCell></TableRow>
|
|
||||||
) : items.length === 0 ? (
|
|
||||||
<TableRow><TableCell colSpan={itemColSpan} className="h-32 text-center text-muted-foreground text-sm">등록된 구매품목이 없어요</TableCell></TableRow>
|
|
||||||
) : items.map((item) => (
|
|
||||||
<TableRow
|
|
||||||
key={item.id}
|
|
||||||
className={cn(
|
|
||||||
"cursor-pointer text-xs border-l-2",
|
|
||||||
selectedItemId === item.id ? "border-l-primary bg-primary/5" : "border-l-transparent"
|
|
||||||
)}
|
|
||||||
onClick={() => setSelectedItemId(item.id)}
|
|
||||||
onDoubleClick={openEditItem}
|
|
||||||
>
|
|
||||||
<TableCell className="p-2 font-medium truncate max-w-[110px]">{item.item_number}</TableCell>
|
|
||||||
<TableCell className="p-2 truncate max-w-[160px]">{item.item_name}</TableCell>
|
|
||||||
{isColVisible("size") && <TableCell style={ts.thStyle("size")} className="p-2 truncate">{item.size || "-"}</TableCell>}
|
|
||||||
{isColVisible("unit") && <TableCell style={ts.thStyle("unit")} className="p-2">{item.unit || "-"}</TableCell>}
|
|
||||||
{isColVisible("standard_price") && <TableCell style={ts.thStyle("standard_price")} className="p-2 text-right">{item.standard_price ? Number(item.standard_price).toLocaleString() : "-"}</TableCell>}
|
|
||||||
{isColVisible("status") && (
|
|
||||||
<TableCell style={ts.thStyle("status")} className="p-2 text-center">
|
|
||||||
<span className={cn("text-[10px] font-medium px-1.5 py-0.5 rounded",
|
|
||||||
item.status === "ACTIVE" || item.status === "사용" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
|
|
||||||
)}>{item.status || "-"}</span>
|
|
||||||
</TableCell>
|
|
||||||
)}
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback } from "react";
|
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
@@ -20,6 +20,7 @@ import { exportToExcel } from "@/lib/utils/excelExport";
|
|||||||
import { useTableSettings } from "@/hooks/useTableSettings";
|
import { useTableSettings } from "@/hooks/useTableSettings";
|
||||||
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
||||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||||
|
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
|
||||||
|
|
||||||
const SUPPLIER_TABLE = "supplier_mng";
|
const SUPPLIER_TABLE = "supplier_mng";
|
||||||
const MAPPING_TABLE = "supplier_item_mapping";
|
const MAPPING_TABLE = "supplier_item_mapping";
|
||||||
@@ -101,6 +102,24 @@ export default function SupplierManagementPage() {
|
|||||||
const isColVisible = (key: string) => ts.isVisible(key);
|
const isColVisible = (key: string) => ts.isVisible(key);
|
||||||
const supplierColSpan = 2 + SUPPLIER_COLUMNS.filter((c) => isColVisible(c.key)).length;
|
const supplierColSpan = 2 + SUPPLIER_COLUMNS.filter((c) => isColVisible(c.key)).length;
|
||||||
|
|
||||||
|
const mainTableColumns = useMemo<EDataTableColumn[]>(() => {
|
||||||
|
const cols: EDataTableColumn[] = [
|
||||||
|
{ key: "supplier_code", label: "공급업체코드", width: "w-[120px]" },
|
||||||
|
{ key: "supplier_name", label: "공급업체명" },
|
||||||
|
];
|
||||||
|
if (isColVisible("contact_person")) cols.push({ key: "contact_person", label: "담당자", width: "w-[90px]", render: (v) => v || "-" });
|
||||||
|
if (isColVisible("contact_phone")) cols.push({ key: "contact_phone", label: "연락처", width: "w-[120px]", render: (v) => v || "-" });
|
||||||
|
if (isColVisible("status")) cols.push({
|
||||||
|
key: "status", label: "상태", width: "w-[70px]", align: "center",
|
||||||
|
render: (v) => (
|
||||||
|
<span className={cn("text-[10px] font-medium px-1.5 py-0.5 rounded",
|
||||||
|
v === "ACTIVE" || v === "사용" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
|
||||||
|
)}>{v || "-"}</span>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
return cols;
|
||||||
|
}, [ts.visibleColumns]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
// 우측: 품목 매핑 조회
|
// 우측: 품목 매핑 조회
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedSupplier?.supplier_code) { setMappingItems([]); setMappingCheckedIds([]); return; }
|
if (!selectedSupplier?.supplier_code) { setMappingItems([]); setMappingCheckedIds([]); return; }
|
||||||
@@ -369,48 +388,18 @@ export default function SupplierManagementPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-auto">
|
<EDataTable
|
||||||
<Table style={{ tableLayout: "fixed" }}>
|
columns={mainTableColumns}
|
||||||
<TableHeader className="sticky top-0 z-10">
|
data={ts.groupData(suppliers)}
|
||||||
<TableRow className="bg-muted hover:bg-muted">
|
loading={supplierLoading}
|
||||||
<TableHead className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">공급업체코드</TableHead>
|
emptyMessage="등록된 공급업체가 없어요"
|
||||||
<TableHead className="p-2 text-[11px] font-bold uppercase tracking-wide text-muted-foreground">공급업체명</TableHead>
|
selectedId={selectedSupplierId}
|
||||||
{isColVisible("contact_person") && <TableHead style={ts.thStyle("contact_person")} className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">담당자</TableHead>}
|
onSelect={(id) => setSelectedSupplierId(id)}
|
||||||
{isColVisible("contact_phone") && <TableHead style={ts.thStyle("contact_phone")} className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">연락처</TableHead>}
|
onRowDoubleClick={() => openSupplierEdit()}
|
||||||
{isColVisible("status") && <TableHead style={ts.thStyle("status")} className="w-[70px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">상태</TableHead>}
|
showPagination={true}
|
||||||
</TableRow>
|
draggableColumns={false}
|
||||||
</TableHeader>
|
columnOrderKey="c16-supplier-main"
|
||||||
<TableBody>
|
/>
|
||||||
{supplierLoading ? (
|
|
||||||
<TableRow><TableCell colSpan={supplierColSpan} className="h-32 text-center"><Loader2 className="mx-auto h-5 w-5 animate-spin" /></TableCell></TableRow>
|
|
||||||
) : suppliers.length === 0 ? (
|
|
||||||
<TableRow><TableCell colSpan={supplierColSpan} className="h-32 text-center text-muted-foreground text-sm">등록된 공급업체가 없어요</TableCell></TableRow>
|
|
||||||
) : suppliers.map((s) => (
|
|
||||||
<TableRow
|
|
||||||
key={s.id}
|
|
||||||
className={cn(
|
|
||||||
"cursor-pointer text-xs border-l-2",
|
|
||||||
selectedSupplierId === s.id ? "border-l-primary bg-primary/5" : "border-l-transparent"
|
|
||||||
)}
|
|
||||||
onClick={() => setSelectedSupplierId(s.id)}
|
|
||||||
onDoubleClick={openSupplierEdit}
|
|
||||||
>
|
|
||||||
<TableCell className="p-2 font-medium truncate max-w-[120px]">{s.supplier_code}</TableCell>
|
|
||||||
<TableCell className="p-2 truncate max-w-[160px]">{s.supplier_name}</TableCell>
|
|
||||||
{isColVisible("contact_person") && <TableCell style={ts.thStyle("contact_person")} className="p-2 truncate">{s.contact_person || "-"}</TableCell>}
|
|
||||||
{isColVisible("contact_phone") && <TableCell style={ts.thStyle("contact_phone")} className="p-2 truncate">{s.contact_phone || "-"}</TableCell>}
|
|
||||||
{isColVisible("status") && (
|
|
||||||
<TableCell style={ts.thStyle("status")} className="p-2 text-center">
|
|
||||||
<span className={cn("text-[10px] font-medium px-1.5 py-0.5 rounded",
|
|
||||||
s.status === "ACTIVE" || s.status === "사용" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
|
|
||||||
)}>{s.status || "-"}</span>
|
|
||||||
</TableCell>
|
|
||||||
)}
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback } from "react";
|
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
@@ -38,6 +38,7 @@ import { toast } from "sonner";
|
|||||||
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
||||||
import { useTableSettings } from "@/hooks/useTableSettings";
|
import { useTableSettings } from "@/hooks/useTableSettings";
|
||||||
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
||||||
|
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
|
||||||
|
|
||||||
/* ───── 테이블명 ───── */
|
/* ───── 테이블명 ───── */
|
||||||
const INSPECTION_TABLE = "inspection_standard";
|
const INSPECTION_TABLE = "inspection_standard";
|
||||||
@@ -176,6 +177,16 @@ export default function InspectionManagementPage() {
|
|||||||
return opts.find((o) => o.code === code)?.label || code;
|
return opts.find((o) => o.code === code)?.label || code;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const inspTableColumns = useMemo<EDataTableColumn[]>(() => {
|
||||||
|
return ts.visibleColumns.map((col) => {
|
||||||
|
const base: EDataTableColumn = { key: col.key, label: col.label };
|
||||||
|
if (["inspection_type", "inspection_method", "judgment_criteria", "unit", "apply_type"].includes(col.key)) {
|
||||||
|
base.render = (v: any, row: any) => getCatLabel(INSPECTION_TABLE, col.key, row[col.key]);
|
||||||
|
}
|
||||||
|
return base;
|
||||||
|
});
|
||||||
|
}, [ts.visibleColumns, catOptions]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
/* ═══════════════════ 데이터 조회 ═══════════════════ */
|
/* ═══════════════════ 데이터 조회 ═══════════════════ */
|
||||||
// 다중값 컬럼 (쉼표 구분 저장) — 서버 equals 대신 contains 사용
|
// 다중값 컬럼 (쉼표 구분 저장) — 서버 equals 대신 contains 사용
|
||||||
const MULTI_VALUE_COLUMNS = ["inspection_type"];
|
const MULTI_VALUE_COLUMNS = ["inspection_type"];
|
||||||
@@ -574,99 +585,19 @@ export default function InspectionManagementPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-hidden rounded-lg border">
|
<div className="overflow-hidden rounded-lg border">
|
||||||
<Table style={{ tableLayout: "fixed" }}>
|
<EDataTable
|
||||||
<TableHeader className="sticky top-0 z-10">
|
columns={inspTableColumns}
|
||||||
<TableRow className="bg-muted hover:bg-muted">
|
data={ts.groupData(inspections)}
|
||||||
<TableHead className="w-10">
|
loading={inspLoading}
|
||||||
<Checkbox
|
emptyMessage="등록된 검사기준이 없어요"
|
||||||
checked={inspections.length > 0 && inspChecked.length === inspections.length}
|
showCheckbox={true}
|
||||||
onCheckedChange={(v) => setInspChecked(v ? inspections.map((r) => r.id) : [])}
|
checkedIds={inspChecked}
|
||||||
|
onCheckedChange={setInspChecked}
|
||||||
|
onRowDoubleClick={(row) => openInspEdit(row)}
|
||||||
|
showPagination={true}
|
||||||
|
draggableColumns={false}
|
||||||
|
columnOrderKey="c16-inspection-main"
|
||||||
/>
|
/>
|
||||||
</TableHead>
|
|
||||||
{ts.visibleColumns.map((col) => (
|
|
||||||
<TableHead
|
|
||||||
key={col.key}
|
|
||||||
style={ts.thStyle(col.key)}
|
|
||||||
className="text-muted-foreground text-[11px] font-bold tracking-wide uppercase"
|
|
||||||
>
|
|
||||||
{col.label}
|
|
||||||
</TableHead>
|
|
||||||
))}
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{inspLoading ? (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={ts.visibleColumns.length + 1} className="py-8 text-center">
|
|
||||||
<Loader2 className="text-muted-foreground mx-auto h-5 w-5 animate-spin" />
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
) : inspections.length === 0 ? (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell
|
|
||||||
colSpan={ts.visibleColumns.length + 1}
|
|
||||||
className="text-muted-foreground py-10 text-center"
|
|
||||||
>
|
|
||||||
<Inbox className="mx-auto mb-2 h-8 w-8 opacity-40" />
|
|
||||||
<p className="text-sm">등록된 검사기준이 없어요</p>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
) : (
|
|
||||||
inspections.map((row) => (
|
|
||||||
<TableRow
|
|
||||||
key={row.id}
|
|
||||||
className={cn("cursor-pointer", inspChecked.includes(row.id) && "bg-primary/5")}
|
|
||||||
onClick={() =>
|
|
||||||
setInspChecked((prev) =>
|
|
||||||
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id],
|
|
||||||
)
|
|
||||||
}
|
|
||||||
onDoubleClick={() => openInspEdit(row)}
|
|
||||||
>
|
|
||||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
|
||||||
<Checkbox
|
|
||||||
checked={inspChecked.includes(row.id)}
|
|
||||||
onCheckedChange={(v) =>
|
|
||||||
setInspChecked((prev) => (v ? [...prev, row.id] : prev.filter((id) => id !== row.id)))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
{ts.visibleColumns.map((col) => {
|
|
||||||
if (col.key === "inspection_type")
|
|
||||||
return (
|
|
||||||
<TableCell key={col.key}>
|
|
||||||
{getCatLabel(INSPECTION_TABLE, "inspection_type", row.inspection_type)}
|
|
||||||
</TableCell>
|
|
||||||
);
|
|
||||||
if (col.key === "inspection_method")
|
|
||||||
return (
|
|
||||||
<TableCell key={col.key}>
|
|
||||||
{getCatLabel(INSPECTION_TABLE, "inspection_method", row.inspection_method)}
|
|
||||||
</TableCell>
|
|
||||||
);
|
|
||||||
if (col.key === "judgment_criteria")
|
|
||||||
return (
|
|
||||||
<TableCell key={col.key}>
|
|
||||||
{getCatLabel(INSPECTION_TABLE, "judgment_criteria", row.judgment_criteria)}
|
|
||||||
</TableCell>
|
|
||||||
);
|
|
||||||
if (col.key === "unit")
|
|
||||||
return (
|
|
||||||
<TableCell key={col.key}>{getCatLabel(INSPECTION_TABLE, "unit", row.unit)}</TableCell>
|
|
||||||
);
|
|
||||||
if (col.key === "apply_type")
|
|
||||||
return (
|
|
||||||
<TableCell key={col.key}>
|
|
||||||
{getCatLabel(INSPECTION_TABLE, "apply_type", row.apply_type)}
|
|
||||||
</TableCell>
|
|
||||||
);
|
|
||||||
return <TableCell key={col.key}>{row[col.key] ?? ""}</TableCell>;
|
|
||||||
})}
|
|
||||||
</TableRow>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { useTableSettings } from "@/hooks/useTableSettings";
|
|||||||
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
||||||
|
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
|
||||||
|
|
||||||
const TABLE_NAME = "item_inspection_info";
|
const TABLE_NAME = "item_inspection_info";
|
||||||
|
|
||||||
@@ -302,48 +303,29 @@ export default function ItemInspectionInfoPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3">
|
<div className="p-3">
|
||||||
<div className="border rounded-lg overflow-hidden">
|
<EDataTable
|
||||||
<Table style={{ tableLayout: "fixed" }}>
|
columns={ts.visibleColumns.map((col): EDataTableColumn => ({
|
||||||
<TableHeader className="sticky top-0 z-10">
|
key: col.key,
|
||||||
<TableRow className="bg-muted hover:bg-muted">
|
label: col.label,
|
||||||
<TableHead className="w-10">
|
render: col.key === "is_active"
|
||||||
<Checkbox
|
? (val: any, row: any) => (
|
||||||
checked={data.length > 0 && checkedIds.length === data.length}
|
<Badge variant={row.is_active ? "default" : "secondary"} className="text-xs">
|
||||||
onCheckedChange={(v) => setCheckedIds(v ? data.map(r => r.id) : [])}
|
{row.is_active ? "사용" : "미사용"}
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
: undefined,
|
||||||
|
}))}
|
||||||
|
data={ts.groupData(data)}
|
||||||
|
loading={loading}
|
||||||
|
emptyMessage="등록된 품목검사정보가 없어요"
|
||||||
|
showCheckbox
|
||||||
|
checkedIds={checkedIds}
|
||||||
|
onCheckedChange={setCheckedIds}
|
||||||
|
onRowDoubleClick={(row) => openEdit(row)}
|
||||||
|
showPagination
|
||||||
|
draggableColumns={false}
|
||||||
|
columnOrderKey="c16-item-inspection"
|
||||||
/>
|
/>
|
||||||
</TableHead>
|
|
||||||
{ts.visibleColumns.map((col) => (
|
|
||||||
<TableHead key={col.key} style={ts.thStyle(col.key)} className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">{col.label}</TableHead>
|
|
||||||
))}
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{loading ? (
|
|
||||||
<TableRow><TableCell colSpan={ts.visibleColumns.length + 1} className="text-center py-8"><Loader2 className="w-5 h-5 animate-spin mx-auto text-muted-foreground" /></TableCell></TableRow>
|
|
||||||
) : data.length === 0 ? (
|
|
||||||
<TableRow><TableCell colSpan={ts.visibleColumns.length + 1} className="text-center py-10 text-muted-foreground"><Inbox className="w-8 h-8 mx-auto mb-2 opacity-40" /><p className="text-sm">등록된 품목검사정보가 없어요</p></TableCell></TableRow>
|
|
||||||
) : data.map((row) => (
|
|
||||||
<TableRow
|
|
||||||
key={row.id}
|
|
||||||
className={cn("cursor-pointer", checkedIds.includes(row.id) && "bg-primary/5")}
|
|
||||||
onClick={() => setCheckedIds(prev => prev.includes(row.id) ? prev.filter(id => id !== row.id) : [...prev, row.id])}
|
|
||||||
onDoubleClick={() => openEdit(row)}
|
|
||||||
>
|
|
||||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
|
||||||
<Checkbox checked={checkedIds.includes(row.id)} onCheckedChange={(v) => setCheckedIds(prev => v ? [...prev, row.id] : prev.filter(id => id !== row.id))} />
|
|
||||||
</TableCell>
|
|
||||||
{ts.visibleColumns.map((col) => (
|
|
||||||
<TableCell key={col.key} style={ts.thStyle(col.key)}>
|
|
||||||
{col.key === "is_active"
|
|
||||||
? <Badge variant={row.is_active ? "default" : "secondary"} className="text-xs">{row.is_active ? "사용" : "미사용"}</Badge>
|
|
||||||
: row[col.key] ?? ""}
|
|
||||||
</TableCell>
|
|
||||||
))}
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ import {
|
|||||||
Wrench,
|
Wrench,
|
||||||
Settings2,
|
Settings2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { apiClient } from "@/lib/api/client";
|
import { apiClient } from "@/lib/api/client";
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
@@ -463,91 +464,38 @@ export default function ClaimManagementPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 테이블 */}
|
{/* 테이블 */}
|
||||||
<div className="flex-1 overflow-auto">
|
<EDataTable
|
||||||
<Table style={{ tableLayout: "fixed" }}>
|
columns={ts.visibleColumns.map((col): EDataTableColumn<ClaimRow> => ({
|
||||||
<TableHeader className="sticky top-0 z-10">
|
key: col.key,
|
||||||
<TableRow className="bg-muted hover:bg-muted">
|
label: col.label,
|
||||||
<TableHead className="w-[40px] text-center text-[11px] font-bold uppercase tracking-wide">#</TableHead>
|
align: col.key === "claim_type" || col.key === "claim_status" ? "center" : undefined,
|
||||||
{ts.visibleColumns.map((col) => (
|
render: col.key === "claim_type"
|
||||||
<TableHead key={col.key} style={ts.thStyle(col.key)} className="text-[11px] font-bold uppercase tracking-wide">
|
? (val: any) => (
|
||||||
{col.label}
|
<Badge variant={getClaimTypeVariant(val)} className="text-[10px] px-1.5 py-0">
|
||||||
</TableHead>
|
{val}
|
||||||
))}
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{loading && data.length === 0 ? (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={ts.visibleColumns.length + 1} className="h-32 text-center">
|
|
||||||
<div className="flex items-center justify-center gap-2 text-muted-foreground">
|
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
|
||||||
<span className="text-sm">불러오는 중...</span>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
) : data.length === 0 ? (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={ts.visibleColumns.length + 1} className="h-32 text-center">
|
|
||||||
<div className="flex flex-col items-center gap-2 text-muted-foreground">
|
|
||||||
<Inbox className="w-8 h-8 opacity-30" />
|
|
||||||
<span className="text-sm">등록된 클레임이 없어요</span>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
) : (
|
|
||||||
data.map((claim, idx) => (
|
|
||||||
<TableRow
|
|
||||||
key={claim.id}
|
|
||||||
className={cn(
|
|
||||||
"cursor-pointer transition-colors",
|
|
||||||
selectedClaimNo === claim.claim_no
|
|
||||||
? "bg-primary/8 border-l-2 border-l-primary"
|
|
||||||
: "hover:bg-muted/50"
|
|
||||||
)}
|
|
||||||
onClick={() => handleRowClick(claim.claim_no)}
|
|
||||||
onDoubleClick={() => openEditModal(claim.claim_no)}
|
|
||||||
>
|
|
||||||
<TableCell className="text-center text-[11px] text-muted-foreground py-2">
|
|
||||||
{idx + 1}
|
|
||||||
</TableCell>
|
|
||||||
{ts.visibleColumns.map((col) => {
|
|
||||||
if (col.key === "claim_type") {
|
|
||||||
return (
|
|
||||||
<TableCell key={col.key} style={ts.thStyle(col.key)} className="text-center py-2">
|
|
||||||
<Badge variant={getClaimTypeVariant(claim.claim_type)} className="text-[10px] px-1.5 py-0">
|
|
||||||
{claim.claim_type}
|
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
)
|
||||||
);
|
: col.key === "claim_status"
|
||||||
}
|
? (val: any) => (
|
||||||
if (col.key === "claim_status") {
|
<Badge variant={getClaimStatusVariant(val)} className="text-[10px] px-1.5 py-0">
|
||||||
return (
|
{val}
|
||||||
<TableCell key={col.key} style={ts.thStyle(col.key)} className="text-center py-2">
|
|
||||||
<Badge variant={getClaimStatusVariant(claim.claim_status)} className="text-[10px] px-1.5 py-0">
|
|
||||||
{claim.claim_status}
|
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
)
|
||||||
);
|
: undefined,
|
||||||
}
|
}))}
|
||||||
if (col.key === "claim_content") {
|
data={ts.groupData(data)}
|
||||||
return (
|
loading={loading}
|
||||||
<TableCell key={col.key} style={ts.thStyle(col.key)} className="text-sm text-muted-foreground py-2 max-w-[200px] truncate">
|
emptyMessage="등록된 클레임이 없어요"
|
||||||
{claim.claim_content}
|
rowKey={(row) => String(row.id)}
|
||||||
</TableCell>
|
selectedId={selectedClaimNo ? String(data.find(c => c.claim_no === selectedClaimNo)?.id ?? "") : null}
|
||||||
);
|
onSelect={(id) => {
|
||||||
}
|
const claim = data.find(c => String(c.id) === id);
|
||||||
return (
|
handleRowClick(claim?.claim_no ?? "");
|
||||||
<TableCell key={col.key} style={ts.thStyle(col.key)} className="text-sm py-2">
|
}}
|
||||||
{claim[col.key] ?? "-"}
|
onRowDoubleClick={(row) => openEditModal(row.claim_no)}
|
||||||
</TableCell>
|
showRowNumber
|
||||||
);
|
draggableColumns={false}
|
||||||
})}
|
/>
|
||||||
</TableRow>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import { validateField, validateForm, formatField } from "@/lib/utils/validation
|
|||||||
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
||||||
import { useTableSettings } from "@/hooks/useTableSettings";
|
import { useTableSettings } from "@/hooks/useTableSettings";
|
||||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||||
|
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
|
||||||
|
|
||||||
const CUSTOMER_TABLE = "customer_mng";
|
const CUSTOMER_TABLE = "customer_mng";
|
||||||
const MAPPING_TABLE = "customer_item_mapping";
|
const MAPPING_TABLE = "customer_item_mapping";
|
||||||
@@ -777,6 +778,39 @@ export default function CustomerManagementPage() {
|
|||||||
const customerColSpan = 1 + ["customer_code", "customer_name", "contact_person", "contact_phone", "division", "status"]
|
const customerColSpan = 1 + ["customer_code", "customer_name", "contact_person", "contact_phone", "division", "status"]
|
||||||
.filter((k) => isColumnVisible(k)).length;
|
.filter((k) => isColumnVisible(k)).length;
|
||||||
|
|
||||||
|
// EDataTable 컬럼 정의 (거래처 목록)
|
||||||
|
const customerColumns: EDataTableColumn[] = [
|
||||||
|
...(isColumnVisible("customer_code") ? [{ key: "customer_code", label: "거래처코드", width: "w-[120px]" }] : []),
|
||||||
|
...(isColumnVisible("customer_name") ? [{ key: "customer_name", label: "거래처명", minWidth: "min-w-[160px]" }] : []),
|
||||||
|
...(isColumnVisible("contact_person") ? [{ key: "contact_person", label: "대표자", width: "w-[90px]" }] : []),
|
||||||
|
...(isColumnVisible("contact_phone") ? [{ key: "contact_phone", label: "연락처", width: "w-[120px]" }] : []),
|
||||||
|
...(isColumnVisible("division") ? [{
|
||||||
|
key: "division",
|
||||||
|
label: "유형",
|
||||||
|
width: "w-[80px]",
|
||||||
|
render: (val: any) =>
|
||||||
|
val ? (
|
||||||
|
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 h-5 font-normal">
|
||||||
|
{val}
|
||||||
|
</Badge>
|
||||||
|
) : null,
|
||||||
|
}] : []),
|
||||||
|
...(isColumnVisible("status") ? [{
|
||||||
|
key: "status",
|
||||||
|
label: "상태",
|
||||||
|
width: "w-[70px]",
|
||||||
|
render: (val: any) =>
|
||||||
|
val ? (
|
||||||
|
<Badge
|
||||||
|
variant={val === "활성" || val === "거래중" ? "default" as const : "outline" as const}
|
||||||
|
className="text-[10px] px-1.5 py-0 h-5"
|
||||||
|
>
|
||||||
|
{val}
|
||||||
|
</Badge>
|
||||||
|
) : null,
|
||||||
|
}] : []),
|
||||||
|
];
|
||||||
|
|
||||||
// 엑셀 다운로드
|
// 엑셀 다운로드
|
||||||
const handleExcelDownload = async () => {
|
const handleExcelDownload = async () => {
|
||||||
if (customers.length === 0) return;
|
if (customers.length === 0) return;
|
||||||
@@ -914,73 +948,20 @@ export default function CustomerManagementPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 거래처 테이블 */}
|
{/* 거래처 테이블 */}
|
||||||
<div className="flex-1 overflow-auto">
|
<EDataTable
|
||||||
<Table noWrapper style={{ tableLayout: "fixed" }}>
|
columns={customerColumns}
|
||||||
<TableHeader className="sticky top-0 z-10">
|
data={ts.groupData(customers)}
|
||||||
<TableRow className="bg-muted hover:bg-muted">
|
rowKey={(row) => row.id}
|
||||||
<TableHead className="w-[40px] text-center px-2 text-[11px] font-bold uppercase tracking-wide text-muted-foreground">No</TableHead>
|
loading={customerLoading}
|
||||||
{isColumnVisible("customer_code") && <TableHead style={ts.thStyle("customer_code")} className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">거래처코드</TableHead>}
|
emptyMessage="등록된 거래처가 없어요"
|
||||||
{isColumnVisible("customer_name") && <TableHead style={ts.thStyle("customer_name")} className="min-w-[160px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">거래처명</TableHead>}
|
selectedId={selectedCustomerId}
|
||||||
{isColumnVisible("contact_person") && <TableHead style={ts.thStyle("contact_person")} className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">대표자</TableHead>}
|
onSelect={(id) => setSelectedCustomerId(id)}
|
||||||
{isColumnVisible("contact_phone") && <TableHead style={ts.thStyle("contact_phone")} className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">연락처</TableHead>}
|
onRowDoubleClick={(row) => { setSelectedCustomerId(row.id); openCustomerEdit(); }}
|
||||||
{isColumnVisible("division") && <TableHead style={ts.thStyle("division")} className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">유형</TableHead>}
|
showRowNumber
|
||||||
{isColumnVisible("status") && <TableHead style={ts.thStyle("status")} className="w-[70px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">상태</TableHead>}
|
showPagination={false}
|
||||||
</TableRow>
|
draggableColumns={false}
|
||||||
</TableHeader>
|
columnOrderKey="c16-customer"
|
||||||
<TableBody>
|
/>
|
||||||
{customerLoading ? (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={customerColSpan} className="text-center py-12">
|
|
||||||
<Loader2 className="w-5 h-5 animate-spin mx-auto text-muted-foreground" />
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
) : customers.length === 0 ? (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={customerColSpan} className="text-center py-12 text-muted-foreground text-sm">
|
|
||||||
등록된 거래처가 없어요
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
) : customers.map((c, idx) => (
|
|
||||||
<TableRow
|
|
||||||
key={c.id}
|
|
||||||
className={cn(
|
|
||||||
"cursor-pointer select-none",
|
|
||||||
selectedCustomerId === c.id ? "bg-primary/10 hover:bg-primary/10" : "hover:bg-muted/50"
|
|
||||||
)}
|
|
||||||
onClick={() => setSelectedCustomerId(c.id)}
|
|
||||||
onDoubleClick={() => { setSelectedCustomerId(c.id); openCustomerEdit(); }}
|
|
||||||
>
|
|
||||||
<TableCell className="text-center text-[13px] text-muted-foreground px-2">{idx + 1}</TableCell>
|
|
||||||
{isColumnVisible("customer_code") && <TableCell style={ts.thStyle("customer_code")} className="text-[13px] font-mono text-muted-foreground">{c.customer_code}</TableCell>}
|
|
||||||
{isColumnVisible("customer_name") && <TableCell style={ts.thStyle("customer_name")} className="text-sm font-medium">{c.customer_name}</TableCell>}
|
|
||||||
{isColumnVisible("contact_person") && <TableCell style={ts.thStyle("contact_person")} className="text-[13px] text-muted-foreground">{c.contact_person}</TableCell>}
|
|
||||||
{isColumnVisible("contact_phone") && <TableCell style={ts.thStyle("contact_phone")} className="text-[13px] text-muted-foreground">{c.contact_phone}</TableCell>}
|
|
||||||
{isColumnVisible("division") && (
|
|
||||||
<TableCell style={ts.thStyle("division")} className="text-[13px]">
|
|
||||||
{c.division && (
|
|
||||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 h-5 font-normal">
|
|
||||||
{c.division}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
)}
|
|
||||||
{isColumnVisible("status") && (
|
|
||||||
<TableCell style={ts.thStyle("status")} className="text-[13px]">
|
|
||||||
{c.status && (
|
|
||||||
<Badge
|
|
||||||
variant={c.status === "활성" || c.status === "거래중" ? "default" : "outline"}
|
|
||||||
className="text-[10px] px-1.5 py-0 h-5"
|
|
||||||
>
|
|
||||||
{c.status}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
)}
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { ShippingPlanBatchModal } from "@/components/common/ShippingPlanBatchMod
|
|||||||
import { useTableSettings } from "@/hooks/useTableSettings";
|
import { useTableSettings } from "@/hooks/useTableSettings";
|
||||||
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
||||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||||
|
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
|
||||||
|
|
||||||
const DETAIL_TABLE = "sales_order_detail";
|
const DETAIL_TABLE = "sales_order_detail";
|
||||||
const MASTER_TABLE = "sales_order_mng";
|
const MASTER_TABLE = "sales_order_mng";
|
||||||
@@ -560,13 +561,6 @@ export default function SalesOrderPage() {
|
|||||||
toast.success("다운로드 완료");
|
toast.success("다운로드 완료");
|
||||||
};
|
};
|
||||||
|
|
||||||
// 전체 선택/해제
|
|
||||||
const isAllChecked = orders.length > 0 && orders.every((o) => checkedIds.includes(o.id));
|
|
||||||
const toggleAllChecked = () => {
|
|
||||||
if (isAllChecked) setCheckedIds([]);
|
|
||||||
else setCheckedIds(orders.map((o) => o.id).filter(Boolean));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col gap-3 p-3">
|
<div className="flex h-full flex-col gap-3 p-3">
|
||||||
{/* 브레드크럼 */}
|
{/* 브레드크럼 */}
|
||||||
@@ -634,99 +628,25 @@ export default function SalesOrderPage() {
|
|||||||
|
|
||||||
{/* 데이터 테이블 */}
|
{/* 데이터 테이블 */}
|
||||||
<div className="flex-1 overflow-hidden rounded-lg border border-border bg-card">
|
<div className="flex-1 overflow-hidden rounded-lg border border-border bg-card">
|
||||||
<div className="overflow-auto" style={{ maxHeight: "calc(100vh - 290px)" }}>
|
<EDataTable
|
||||||
<Table noWrapper className="w-full" style={{ tableLayout: "fixed" }}>
|
columns={ts.visibleColumns.map((col): EDataTableColumn => ({
|
||||||
<TableHeader className="sticky top-0 z-10">
|
key: col.key,
|
||||||
<TableRow className="bg-muted hover:bg-muted">
|
label: col.label,
|
||||||
<TableHead className="w-10 pl-4">
|
align: ["qty", "ship_qty", "balance_qty", "unit_price", "amount"].includes(col.key) ? "right" : undefined,
|
||||||
<input
|
formatNumber: ["qty", "ship_qty", "balance_qty", "unit_price", "amount"].includes(col.key),
|
||||||
type="checkbox"
|
}))}
|
||||||
checked={isAllChecked}
|
data={ts.groupData(orders)}
|
||||||
onChange={toggleAllChecked}
|
loading={loading}
|
||||||
className="h-4 w-4 cursor-pointer rounded border-border accent-primary"
|
emptyMessage="등록된 수주가 없어요"
|
||||||
|
emptyIcon={<ClipboardList className="h-8 w-8 opacity-30" />}
|
||||||
|
showCheckbox
|
||||||
|
checkedIds={checkedIds}
|
||||||
|
onCheckedChange={setCheckedIds}
|
||||||
|
onRowDoubleClick={(row) => openEditModal(row.order_no)}
|
||||||
|
showPagination
|
||||||
|
draggableColumns={false}
|
||||||
|
columnOrderKey="c16-sales-order"
|
||||||
/>
|
/>
|
||||||
</TableHead>
|
|
||||||
{ts.isVisible("order_no") && <TableHead style={ts.thStyle("order_no")} className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">수주번호</TableHead>}
|
|
||||||
{ts.isVisible("part_code") && <TableHead style={ts.thStyle("part_code")} className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품번</TableHead>}
|
|
||||||
{ts.isVisible("part_name") && <TableHead style={ts.thStyle("part_name")} className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품명</TableHead>}
|
|
||||||
{ts.isVisible("spec") && <TableHead style={ts.thStyle("spec")} className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">규격</TableHead>}
|
|
||||||
{ts.isVisible("unit") && <TableHead style={ts.thStyle("unit")} className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">단위</TableHead>}
|
|
||||||
{ts.isVisible("qty") && <TableHead style={ts.thStyle("qty")} className="text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">수량</TableHead>}
|
|
||||||
{ts.isVisible("ship_qty") && <TableHead style={ts.thStyle("ship_qty")} className="text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">출하수량</TableHead>}
|
|
||||||
{ts.isVisible("balance_qty") && <TableHead style={ts.thStyle("balance_qty")} className="text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">잔량</TableHead>}
|
|
||||||
{ts.isVisible("unit_price") && <TableHead style={ts.thStyle("unit_price")} className="text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">단가</TableHead>}
|
|
||||||
{ts.isVisible("amount") && <TableHead style={ts.thStyle("amount")} className="text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">금액</TableHead>}
|
|
||||||
{ts.isVisible("currency_code") && <TableHead style={ts.thStyle("currency_code")} className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">통화</TableHead>}
|
|
||||||
{ts.isVisible("due_date") && <TableHead style={ts.thStyle("due_date")} className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">납기일</TableHead>}
|
|
||||||
{ts.isVisible("memo") && <TableHead style={ts.thStyle("memo")} className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">메모</TableHead>}
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{loading ? (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={ts.visibleColumns.length + 1} className="py-16 text-center">
|
|
||||||
<Loader2 className="mx-auto h-6 w-6 animate-spin text-primary" />
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
) : orders.length === 0 ? (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={ts.visibleColumns.length + 1} className="py-16 text-center">
|
|
||||||
<div className="flex flex-col items-center gap-2 text-muted-foreground">
|
|
||||||
<ClipboardList className="h-8 w-8 opacity-30" />
|
|
||||||
<span className="text-sm">등록된 수주가 없어요</span>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
) : (
|
|
||||||
orders.map((row) => {
|
|
||||||
const isChecked = checkedIds.includes(row.id);
|
|
||||||
return (
|
|
||||||
<TableRow
|
|
||||||
key={row.id}
|
|
||||||
className={cn(
|
|
||||||
"cursor-pointer border-l-[3px] border-l-transparent transition-all",
|
|
||||||
isChecked ? "border-l-primary bg-primary/5" : "hover:bg-accent"
|
|
||||||
)}
|
|
||||||
onClick={() => setCheckedIds((prev) =>
|
|
||||||
prev.includes(row.id) ? prev.filter((id) => id !== row.id) : [...prev, row.id]
|
|
||||||
)}
|
|
||||||
onDoubleClick={() => openEditModal(row.order_no)}
|
|
||||||
>
|
|
||||||
<TableCell className="pl-4">
|
|
||||||
<input type="checkbox" checked={isChecked} readOnly className="h-4 w-4 cursor-pointer rounded accent-primary" />
|
|
||||||
</TableCell>
|
|
||||||
{ts.isVisible("order_no") && <TableCell style={ts.thStyle("order_no")} className="font-mono text-[13px]">{row.order_no}</TableCell>}
|
|
||||||
{ts.isVisible("part_code") && (
|
|
||||||
<TableCell style={ts.thStyle("part_code")} className="max-w-[120px]">
|
|
||||||
<span className="block truncate font-mono text-[13px]" title={row.part_code}>{row.part_code}</span>
|
|
||||||
</TableCell>
|
|
||||||
)}
|
|
||||||
{ts.isVisible("part_name") && (
|
|
||||||
<TableCell style={ts.thStyle("part_name")} className="max-w-[150px]">
|
|
||||||
<span className="block truncate text-sm" title={row.part_name}>{row.part_name}</span>
|
|
||||||
</TableCell>
|
|
||||||
)}
|
|
||||||
{ts.isVisible("spec") && <TableCell style={ts.thStyle("spec")} className="text-[13px] text-muted-foreground">{row.spec}</TableCell>}
|
|
||||||
{ts.isVisible("unit") && <TableCell style={ts.thStyle("unit")} className="text-[13px]">{row.unit}</TableCell>}
|
|
||||||
{ts.isVisible("qty") && <TableCell style={ts.thStyle("qty")} className="text-right font-mono text-[13px]">{row.qty ? Number(row.qty).toLocaleString() : ""}</TableCell>}
|
|
||||||
{ts.isVisible("ship_qty") && <TableCell style={ts.thStyle("ship_qty")} className="text-right font-mono text-[13px] text-muted-foreground">{row.ship_qty ? Number(row.ship_qty).toLocaleString() : ""}</TableCell>}
|
|
||||||
{ts.isVisible("balance_qty") && <TableCell style={ts.thStyle("balance_qty")} className="text-right font-mono text-[13px]">{row.balance_qty ? Number(row.balance_qty).toLocaleString() : ""}</TableCell>}
|
|
||||||
{ts.isVisible("unit_price") && <TableCell style={ts.thStyle("unit_price")} className="text-right font-mono text-[13px]">{row.unit_price ? Number(row.unit_price).toLocaleString() : ""}</TableCell>}
|
|
||||||
{ts.isVisible("amount") && <TableCell style={ts.thStyle("amount")} className="text-right font-mono text-[13px] font-semibold">{row.amount ? Number(row.amount).toLocaleString() : ""}</TableCell>}
|
|
||||||
{ts.isVisible("currency_code") && <TableCell style={ts.thStyle("currency_code")} className="text-[13px]">{row.currency_code}</TableCell>}
|
|
||||||
{ts.isVisible("due_date") && <TableCell style={ts.thStyle("due_date")} className="text-[13px]">{row.due_date}</TableCell>}
|
|
||||||
{ts.isVisible("memo") && (
|
|
||||||
<TableCell style={ts.thStyle("memo")} className="max-w-[100px]">
|
|
||||||
<span className="block truncate text-[13px] text-muted-foreground" title={row.memo}>{row.memo}</span>
|
|
||||||
</TableCell>
|
|
||||||
)}
|
|
||||||
</TableRow>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 수주 등록/수정 모달 */}
|
{/* 수주 등록/수정 모달 */}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import { exportToExcel } from "@/lib/utils/excelExport";
|
|||||||
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
||||||
import { useTableSettings } from "@/hooks/useTableSettings";
|
import { useTableSettings } from "@/hooks/useTableSettings";
|
||||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||||
|
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
|
||||||
|
|
||||||
const ITEM_TABLE = "item_info";
|
const ITEM_TABLE = "item_info";
|
||||||
const MAPPING_TABLE = "customer_item_mapping";
|
const MAPPING_TABLE = "customer_item_mapping";
|
||||||
@@ -605,6 +606,18 @@ export default function SalesItemPage() {
|
|||||||
toast.success("다운로드 완료");
|
toast.success("다운로드 완료");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// EDataTable 컬럼 정의 (판매품목)
|
||||||
|
const itemColumns: EDataTableColumn[] = [
|
||||||
|
{ key: "item_number", label: "품번", width: "w-[110px]" },
|
||||||
|
{ key: "item_name", label: "품명", minWidth: "min-w-[130px]" },
|
||||||
|
{ key: "size", label: "규격", width: "w-[80px]" },
|
||||||
|
{ key: "unit", label: "단위", width: "w-[60px]" },
|
||||||
|
{ key: "standard_price", label: "기준단가", width: "w-[90px]", align: "right", formatNumber: true },
|
||||||
|
{ key: "selling_price", label: "판매가격", width: "w-[90px]", align: "right", formatNumber: true },
|
||||||
|
{ key: "currency_code", label: "통화", width: "w-[50px]" },
|
||||||
|
{ key: "status", label: "상태", width: "w-[60px]" },
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col gap-3 p-3">
|
<div className="flex h-full flex-col gap-3 p-3">
|
||||||
|
|
||||||
@@ -649,58 +662,20 @@ export default function SalesItemPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{/* 테이블 영역 */}
|
{/* 테이블 영역 */}
|
||||||
<div className="flex-1 overflow-auto">
|
<EDataTable
|
||||||
{itemLoading ? (
|
columns={itemColumns}
|
||||||
<div className="flex items-center justify-center h-full">
|
data={ts.groupData(items)}
|
||||||
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
|
rowKey={(row) => row.id}
|
||||||
</div>
|
loading={itemLoading}
|
||||||
) : items.length === 0 ? (
|
emptyMessage="등록된 판매품목이 없어요"
|
||||||
<div className="flex items-center justify-center h-full text-sm text-muted-foreground">
|
selectedId={selectedItemId}
|
||||||
등록된 판매품목이 없어요
|
onSelect={(id) => setSelectedItemId(id)}
|
||||||
</div>
|
onRowDoubleClick={() => openEditItem()}
|
||||||
) : (
|
showRowNumber
|
||||||
<Table noWrapper>
|
showPagination={false}
|
||||||
<TableHeader className="sticky top-0 z-10">
|
draggableColumns={false}
|
||||||
<TableRow className="bg-muted hover:bg-muted">
|
columnOrderKey="c16-sales-item"
|
||||||
<TableHead className="sticky top-0 z-10 bg-card text-[11px] font-bold uppercase tracking-wide text-muted-foreground w-[32px] text-center">#</TableHead>
|
/>
|
||||||
<TableHead className="sticky top-0 z-10 bg-card text-[11px] font-bold uppercase tracking-wide text-muted-foreground w-[110px]">품번</TableHead>
|
|
||||||
<TableHead className="sticky top-0 z-10 bg-card text-[11px] font-bold uppercase tracking-wide text-muted-foreground min-w-[130px]">품명</TableHead>
|
|
||||||
<TableHead className="sticky top-0 z-10 bg-card text-[11px] font-bold uppercase tracking-wide text-muted-foreground w-[80px]">규격</TableHead>
|
|
||||||
<TableHead className="sticky top-0 z-10 bg-card text-[11px] font-bold uppercase tracking-wide text-muted-foreground w-[60px]">단위</TableHead>
|
|
||||||
<TableHead className="sticky top-0 z-10 bg-card text-[11px] font-bold uppercase tracking-wide text-muted-foreground w-[90px] text-right">기준단가</TableHead>
|
|
||||||
<TableHead className="sticky top-0 z-10 bg-card text-[11px] font-bold uppercase tracking-wide text-muted-foreground w-[90px] text-right">판매가격</TableHead>
|
|
||||||
<TableHead className="sticky top-0 z-10 bg-card text-[11px] font-bold uppercase tracking-wide text-muted-foreground w-[50px]">통화</TableHead>
|
|
||||||
<TableHead className="sticky top-0 z-10 bg-card text-[11px] font-bold uppercase tracking-wide text-muted-foreground w-[60px]">상태</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{items.map((item, idx) => (
|
|
||||||
<TableRow
|
|
||||||
key={item.id}
|
|
||||||
className={cn(
|
|
||||||
"cursor-pointer border-l-2 transition-all",
|
|
||||||
selectedItemId === item.id
|
|
||||||
? "bg-primary/[0.08] border-l-primary"
|
|
||||||
: "border-l-transparent hover:bg-accent"
|
|
||||||
)}
|
|
||||||
onClick={() => setSelectedItemId(item.id)}
|
|
||||||
onDoubleClick={() => openEditItem()}
|
|
||||||
>
|
|
||||||
<TableCell className="text-center text-[13px] text-muted-foreground">{idx + 1}</TableCell>
|
|
||||||
<TableCell className="text-[13px] font-mono text-muted-foreground">{item.item_number}</TableCell>
|
|
||||||
<TableCell className={cn("text-sm", selectedItemId === item.id ? "font-semibold text-foreground" : "text-foreground")}>{item.item_name}</TableCell>
|
|
||||||
<TableCell className="text-[13px] text-muted-foreground">{item.size}</TableCell>
|
|
||||||
<TableCell className="text-[13px] text-muted-foreground">{item.unit}</TableCell>
|
|
||||||
<TableCell className="text-[13px] text-right font-mono text-muted-foreground">{formatNum(item.standard_price)}</TableCell>
|
|
||||||
<TableCell className="text-[13px] text-right font-mono text-muted-foreground">{formatNum(item.selling_price)}</TableCell>
|
|
||||||
<TableCell className="text-[13px] text-muted-foreground">{item.currency_code}</TableCell>
|
|
||||||
<TableCell className="text-[13px] text-muted-foreground">{item.status}</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { ExcelUploadModal } from "@/components/common/ExcelUploadModal";
|
|||||||
import { useTableSettings } from "@/hooks/useTableSettings";
|
import { useTableSettings } from "@/hooks/useTableSettings";
|
||||||
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
||||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||||
|
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
|
||||||
|
|
||||||
const GRID_COLUMNS = [
|
const GRID_COLUMNS = [
|
||||||
{ key: "instruction_no", label: "출하지시번호" },
|
{ key: "instruction_no", label: "출하지시번호" },
|
||||||
@@ -201,10 +202,6 @@ export default function ShippingOrderPage() {
|
|||||||
}
|
}
|
||||||
}, [isModalOpen, dataSource]);
|
}, [isModalOpen, dataSource]);
|
||||||
|
|
||||||
const handleCheckAll = (checked: boolean) => {
|
|
||||||
setCheckedIds(checked ? orders.map((o: any) => o.id) : []);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteSelected = async () => {
|
const handleDeleteSelected = async () => {
|
||||||
if (checkedIds.length === 0) return;
|
if (checkedIds.length === 0) return;
|
||||||
if (!confirm(`선택한 ${checkedIds.length}개의 출하지시를 삭제하시겠습니까?`)) return;
|
if (!confirm(`선택한 ${checkedIds.length}개의 출하지시를 삭제하시겠습니까?`)) return;
|
||||||
@@ -392,6 +389,70 @@ export default function ShippingOrderPage() {
|
|||||||
|
|
||||||
const formatDate = (d: string) => d ? d.split("T")[0] : "-";
|
const formatDate = (d: string) => d ? d.split("T")[0] : "-";
|
||||||
|
|
||||||
|
// 출하지시 데이터를 플랫한 행 목록으로 변환 (EDataTable용)
|
||||||
|
const flattenedOrders = useMemo(() => {
|
||||||
|
const rows: any[] = [];
|
||||||
|
for (const order of orders) {
|
||||||
|
const items = Array.isArray(order.items) ? order.items.filter((it: any) => it.id) : [];
|
||||||
|
if (items.length === 0) {
|
||||||
|
rows.push({
|
||||||
|
_rowId: String(order.id),
|
||||||
|
_orderId: order.id,
|
||||||
|
_order: order,
|
||||||
|
instruction_no: order.instruction_no,
|
||||||
|
ship_date: formatDate(order.instruction_date),
|
||||||
|
customer_name: order.customer_name || "-",
|
||||||
|
transport_company: order.carrier_name || "-",
|
||||||
|
vehicle_no: order.vehicle_no || "-",
|
||||||
|
driver_name: order.driver_name || "-",
|
||||||
|
status: order.status,
|
||||||
|
item_code: "-",
|
||||||
|
item_name: "-",
|
||||||
|
qty: 0,
|
||||||
|
source_type: "-",
|
||||||
|
remark: order.memo || "-",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
items.forEach((item: any, idx: number) => {
|
||||||
|
rows.push({
|
||||||
|
_rowId: `${order.id}-${item.id}`,
|
||||||
|
_orderId: order.id,
|
||||||
|
_order: order,
|
||||||
|
instruction_no: idx === 0 ? order.instruction_no : "",
|
||||||
|
ship_date: idx === 0 ? formatDate(order.instruction_date) : "",
|
||||||
|
customer_name: idx === 0 ? (order.customer_name || "-") : "",
|
||||||
|
transport_company: idx === 0 ? (order.carrier_name || "-") : "",
|
||||||
|
vehicle_no: idx === 0 ? (order.vehicle_no || "-") : "",
|
||||||
|
driver_name: idx === 0 ? (order.driver_name || "-") : "",
|
||||||
|
status: idx === 0 ? order.status : "",
|
||||||
|
item_code: item.item_code || "",
|
||||||
|
item_name: item.item_name || "",
|
||||||
|
qty: Number(item.order_qty || 0),
|
||||||
|
source_type: item.source_type || "",
|
||||||
|
remark: idx === 0 ? (order.memo || "-") : "",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}, [orders]);
|
||||||
|
|
||||||
|
// checkedIds를 order.id 기준으로 관리하므로 _orderId로 매핑
|
||||||
|
const flatCheckedRowIds = useMemo(() => {
|
||||||
|
return flattenedOrders
|
||||||
|
.filter((r) => checkedIds.includes(r._orderId))
|
||||||
|
.map((r) => r._rowId);
|
||||||
|
}, [flattenedOrders, checkedIds]);
|
||||||
|
|
||||||
|
const handleFlatCheckedChange = useCallback((rowIds: string[]) => {
|
||||||
|
const orderIds = new Set<number>();
|
||||||
|
for (const rowId of rowIds) {
|
||||||
|
const row = flattenedOrders.find((r) => r._rowId === rowId);
|
||||||
|
if (row) orderIds.add(row._orderId);
|
||||||
|
}
|
||||||
|
setCheckedIds(Array.from(orderIds));
|
||||||
|
}, [flattenedOrders]);
|
||||||
|
|
||||||
const dataSourceTitle: Record<DataSourceType, string> = {
|
const dataSourceTitle: Record<DataSourceType, string> = {
|
||||||
shipmentPlan: "출하계획 목록",
|
shipmentPlan: "출하계획 목록",
|
||||||
salesOrder: "수주정보 목록",
|
salesOrder: "수주정보 목록",
|
||||||
@@ -454,138 +515,42 @@ export default function ShippingOrderPage() {
|
|||||||
|
|
||||||
{/* 메인 테이블 */}
|
{/* 메인 테이블 */}
|
||||||
<div className="flex-1 overflow-hidden rounded-lg border bg-card flex flex-col">
|
<div className="flex-1 overflow-hidden rounded-lg border bg-card flex flex-col">
|
||||||
<div className="flex-1 overflow-auto">
|
<EDataTable
|
||||||
{loading ? (
|
columns={ts.visibleColumns.map((col): EDataTableColumn => ({
|
||||||
<div className="flex items-center justify-center h-32">
|
key: col.key,
|
||||||
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
|
label: col.label,
|
||||||
</div>
|
align: col.key === "qty" ? "right" : col.key === "status" || col.key === "source_type" || col.key === "ship_date" ? "center" : undefined,
|
||||||
) : (
|
formatNumber: col.key === "qty",
|
||||||
<Table style={{ tableLayout: "fixed" }}>
|
sortable: false,
|
||||||
<TableHeader className="sticky top-0 z-10">
|
filterable: false,
|
||||||
<TableRow className="bg-muted hover:bg-muted">
|
render: col.key === "status"
|
||||||
<TableHead className="w-[40px] text-center">
|
? (val: any) => val ? (
|
||||||
<Checkbox
|
<span className={cn("px-2 py-0.5 rounded-full text-[11px] font-medium", getStatusColor(val))}>
|
||||||
checked={orders.length > 0 && checkedIds.length === orders.length}
|
{getStatusLabel(val)}
|
||||||
onCheckedChange={handleCheckAll}
|
|
||||||
/>
|
|
||||||
</TableHead>
|
|
||||||
{ts.isVisible("instruction_no") && <TableHead style={ts.thStyle("instruction_no")} className="w-[140px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">출하지시번호</TableHead>}
|
|
||||||
{ts.isVisible("ship_date") && <TableHead style={ts.thStyle("ship_date")} className="w-[100px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">출하일자</TableHead>}
|
|
||||||
{ts.isVisible("customer_name") && <TableHead style={ts.thStyle("customer_name")} className="w-[120px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">거래처명</TableHead>}
|
|
||||||
{ts.isVisible("transport_company") && <TableHead style={ts.thStyle("transport_company")} className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">운송업체</TableHead>}
|
|
||||||
{ts.isVisible("vehicle_no") && <TableHead style={ts.thStyle("vehicle_no")} className="w-[90px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">차량번호</TableHead>}
|
|
||||||
{ts.isVisible("driver_name") && <TableHead style={ts.thStyle("driver_name")} className="w-[80px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">기사명</TableHead>}
|
|
||||||
{ts.isVisible("status") && <TableHead style={ts.thStyle("status")} className="w-[80px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">상태</TableHead>}
|
|
||||||
{ts.isVisible("item_code") && <TableHead style={ts.thStyle("item_code")} className="w-[100px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품번</TableHead>}
|
|
||||||
{ts.isVisible("item_name") && <TableHead style={ts.thStyle("item_name")} className="w-[130px] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품명</TableHead>}
|
|
||||||
{ts.isVisible("qty") && <TableHead style={ts.thStyle("qty")} className="w-[70px] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">수량</TableHead>}
|
|
||||||
{ts.isVisible("source_type") && <TableHead style={ts.thStyle("source_type")} className="w-[80px] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">소스</TableHead>}
|
|
||||||
{ts.isVisible("remark") && <TableHead style={ts.thStyle("remark")} className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">비고</TableHead>}
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{orders.length === 0 ? (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={13} className="h-40 text-center">
|
|
||||||
<div className="flex flex-col items-center gap-2 text-muted-foreground">
|
|
||||||
<div className="w-12 h-12 rounded-full border-2 border-dashed border-muted-foreground/20 flex items-center justify-center">
|
|
||||||
<Inbox className="w-5 h-5 text-muted-foreground/30" />
|
|
||||||
</div>
|
|
||||||
<p className="text-sm font-medium">등록된 출하지시가 없어요</p>
|
|
||||||
<p className="text-xs text-muted-foreground/60">출하지시 등록 버튼으로 등록해주세요</p>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
) : (
|
|
||||||
orders.map((order: any) => {
|
|
||||||
const items = Array.isArray(order.items) ? order.items.filter((it: any) => it.id) : [];
|
|
||||||
if (items.length === 0) {
|
|
||||||
return (
|
|
||||||
<TableRow
|
|
||||||
key={order.id}
|
|
||||||
className={cn("cursor-pointer transition-colors", selectedOrderId === order.id && "bg-primary/5")}
|
|
||||||
onClick={() => setSelectedOrderId(order.id)}
|
|
||||||
onDoubleClick={() => openModal(order)}
|
|
||||||
>
|
|
||||||
<TableCell className="text-center" onClick={e => e.stopPropagation()}>
|
|
||||||
<Checkbox
|
|
||||||
checked={checkedIds.includes(order.id)}
|
|
||||||
onCheckedChange={(c) => {
|
|
||||||
if (c) setCheckedIds(p => [...p, order.id]);
|
|
||||||
else setCheckedIds(p => p.filter(i => i !== order.id));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
{ts.isVisible("instruction_no") && <TableCell style={ts.thStyle("instruction_no")} className="font-medium text-sm">{order.instruction_no}</TableCell>}
|
|
||||||
{ts.isVisible("ship_date") && <TableCell style={ts.thStyle("ship_date")} className="text-center text-sm">{formatDate(order.instruction_date)}</TableCell>}
|
|
||||||
{ts.isVisible("customer_name") && <TableCell style={ts.thStyle("customer_name")} className="text-sm">{order.customer_name || "-"}</TableCell>}
|
|
||||||
{ts.isVisible("transport_company") && <TableCell style={ts.thStyle("transport_company")} className="text-sm">{order.carrier_name || "-"}</TableCell>}
|
|
||||||
{ts.isVisible("vehicle_no") && <TableCell style={ts.thStyle("vehicle_no")} className="text-sm">{order.vehicle_no || "-"}</TableCell>}
|
|
||||||
{ts.isVisible("driver_name") && <TableCell style={ts.thStyle("driver_name")} className="text-sm">{order.driver_name || "-"}</TableCell>}
|
|
||||||
{ts.isVisible("status") && <TableCell style={ts.thStyle("status")} className="text-center">
|
|
||||||
<span className={cn("px-2 py-0.5 rounded-full text-[11px] font-medium", getStatusColor(order.status))}>
|
|
||||||
{getStatusLabel(order.status)}
|
|
||||||
</span>
|
</span>
|
||||||
</TableCell>}
|
) : null
|
||||||
{ts.isVisible("item_code") && <TableCell style={ts.thStyle("item_code")} className="text-sm">-</TableCell>}
|
: col.key === "source_type"
|
||||||
{ts.isVisible("item_name") && <TableCell style={ts.thStyle("item_name")} className="text-sm">-</TableCell>}
|
? (val: any) => {
|
||||||
{ts.isVisible("qty") && <TableCell style={ts.thStyle("qty")} className="text-right text-sm">0</TableCell>}
|
if (!val || val === "-") return <span>-</span>;
|
||||||
{ts.isVisible("source_type") && <TableCell style={ts.thStyle("source_type")} className="text-center text-sm">-</TableCell>}
|
const b = getSourceBadge(val);
|
||||||
{ts.isVisible("remark") && <TableCell style={ts.thStyle("remark")} className="text-[13px] text-muted-foreground truncate max-w-[100px]">{order.memo || "-"}</TableCell>}
|
|
||||||
</TableRow>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return items.map((item: any, itemIdx: number) => (
|
|
||||||
<TableRow
|
|
||||||
key={`${order.id}-${item.id}`}
|
|
||||||
className={cn("cursor-pointer transition-colors", selectedOrderId === order.id && "bg-primary/5")}
|
|
||||||
onClick={() => setSelectedOrderId(order.id)}
|
|
||||||
onDoubleClick={() => openModal(order)}
|
|
||||||
>
|
|
||||||
<TableCell className="text-center" onClick={e => e.stopPropagation()}>
|
|
||||||
{itemIdx === 0 && (
|
|
||||||
<Checkbox
|
|
||||||
checked={checkedIds.includes(order.id)}
|
|
||||||
onCheckedChange={(c) => {
|
|
||||||
if (c) setCheckedIds(p => [...p, order.id]);
|
|
||||||
else setCheckedIds(p => p.filter(i => i !== order.id));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
{ts.isVisible("instruction_no") && <TableCell style={ts.thStyle("instruction_no")} className="font-medium text-sm">{itemIdx === 0 ? order.instruction_no : ""}</TableCell>}
|
|
||||||
{ts.isVisible("ship_date") && <TableCell style={ts.thStyle("ship_date")} className="text-center text-sm">{itemIdx === 0 ? formatDate(order.instruction_date) : ""}</TableCell>}
|
|
||||||
{ts.isVisible("customer_name") && <TableCell style={ts.thStyle("customer_name")} className="text-sm">{itemIdx === 0 ? (order.customer_name || "-") : ""}</TableCell>}
|
|
||||||
{ts.isVisible("transport_company") && <TableCell style={ts.thStyle("transport_company")} className="text-sm">{itemIdx === 0 ? (order.carrier_name || "-") : ""}</TableCell>}
|
|
||||||
{ts.isVisible("vehicle_no") && <TableCell style={ts.thStyle("vehicle_no")} className="text-sm">{itemIdx === 0 ? (order.vehicle_no || "-") : ""}</TableCell>}
|
|
||||||
{ts.isVisible("driver_name") && <TableCell style={ts.thStyle("driver_name")} className="text-sm">{itemIdx === 0 ? (order.driver_name || "-") : ""}</TableCell>}
|
|
||||||
{ts.isVisible("status") && <TableCell style={ts.thStyle("status")} className="text-center">
|
|
||||||
{itemIdx === 0 && (
|
|
||||||
<span className={cn("px-2 py-0.5 rounded-full text-[11px] font-medium", getStatusColor(order.status))}>
|
|
||||||
{getStatusLabel(order.status)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</TableCell>}
|
|
||||||
{ts.isVisible("item_code") && <TableCell style={ts.thStyle("item_code")} className="text-[13px] text-muted-foreground">{item.item_code}</TableCell>}
|
|
||||||
{ts.isVisible("item_name") && <TableCell style={ts.thStyle("item_name")} className="font-medium text-sm">{item.item_name}</TableCell>}
|
|
||||||
{ts.isVisible("qty") && <TableCell style={ts.thStyle("qty")} className="text-right text-sm">{Number(item.order_qty || 0).toLocaleString()}</TableCell>}
|
|
||||||
{ts.isVisible("source_type") && <TableCell style={ts.thStyle("source_type")} className="text-center">
|
|
||||||
{(() => {
|
|
||||||
const b = getSourceBadge(item.source_type || "");
|
|
||||||
return <span className={cn("px-2 py-0.5 rounded-full text-[10px] font-medium", b.cls)}>{b.label}</span>;
|
return <span className={cn("px-2 py-0.5 rounded-full text-[10px] font-medium", b.cls)}>{b.label}</span>;
|
||||||
})()}
|
}
|
||||||
</TableCell>}
|
: undefined,
|
||||||
{ts.isVisible("remark") && <TableCell style={ts.thStyle("remark")} className="text-[13px] text-muted-foreground truncate max-w-[100px]">
|
}))}
|
||||||
{itemIdx === 0 ? (order.memo || "-") : ""}
|
data={ts.groupData(flattenedOrders)}
|
||||||
</TableCell>}
|
rowKey={(row) => row._rowId}
|
||||||
</TableRow>
|
loading={loading}
|
||||||
));
|
emptyMessage="등록된 출하지시가 없어요"
|
||||||
})
|
showCheckbox
|
||||||
)}
|
checkedIds={flatCheckedRowIds}
|
||||||
</TableBody>
|
onCheckedChange={handleFlatCheckedChange}
|
||||||
</Table>
|
selectedId={selectedOrderId != null ? String(selectedOrderId) : null}
|
||||||
)}
|
onRowClick={(row) => setSelectedOrderId(row._orderId)}
|
||||||
</div>
|
onRowDoubleClick={(row) => openModal(row._order)}
|
||||||
|
showPagination
|
||||||
|
draggableColumns={false}
|
||||||
|
columnOrderKey="c16-shipping-order"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 등록/수정 모달 */}
|
{/* 등록/수정 모달 */}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
import { useTableSettings } from "@/hooks/useTableSettings";
|
import { useTableSettings } from "@/hooks/useTableSettings";
|
||||||
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
import { TableSettingsModal } from "@/components/common/TableSettingsModal";
|
||||||
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter";
|
||||||
|
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
|
||||||
|
|
||||||
const GRID_COLUMNS = [
|
const GRID_COLUMNS = [
|
||||||
{ key: "order_no", label: "수주번호" },
|
{ key: "order_no", label: "수주번호" },
|
||||||
@@ -114,10 +115,11 @@ export default function ShippingPlanPage() {
|
|||||||
const selectedPlan = useMemo(() => data.find(p => p.id === selectedId), [data, selectedId]);
|
const selectedPlan = useMemo(() => data.find(p => p.id === selectedId), [data, selectedId]);
|
||||||
|
|
||||||
const groupedData = useMemo(() => {
|
const groupedData = useMemo(() => {
|
||||||
const orderMap = new Map<string, ShipmentPlanListItem[]>();
|
const grouped = ts.groupData(data);
|
||||||
|
const orderMap = new Map<string, any[]>();
|
||||||
const orderKeys: string[] = [];
|
const orderKeys: string[] = [];
|
||||||
data.forEach(plan => {
|
grouped.forEach(plan => {
|
||||||
const key = plan.order_no || `_no_order_${plan.id}`;
|
const key = (plan as any)._isGroupSummary ? `_summary_${orderKeys.length}` : (plan.order_no || `_no_order_${plan.id}`);
|
||||||
if (!orderMap.has(key)) {
|
if (!orderMap.has(key)) {
|
||||||
orderMap.set(key, []);
|
orderMap.set(key, []);
|
||||||
orderKeys.push(key);
|
orderKeys.push(key);
|
||||||
@@ -128,7 +130,7 @@ export default function ShippingPlanPage() {
|
|||||||
orderNo: key,
|
orderNo: key,
|
||||||
plans: orderMap.get(key)!,
|
plans: orderMap.get(key)!,
|
||||||
}));
|
}));
|
||||||
}, [data]);
|
}, [data, ts.groupData]);
|
||||||
|
|
||||||
const handleRowClick = (plan: ShipmentPlanListItem) => {
|
const handleRowClick = (plan: ShipmentPlanListItem) => {
|
||||||
if (isDetailChanged && selectedId !== plan.id) {
|
if (isDetailChanged && selectedId !== plan.id) {
|
||||||
@@ -233,91 +235,36 @@ export default function ShippingPlanPage() {
|
|||||||
|
|
||||||
{/* 테이블 */}
|
{/* 테이블 */}
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
<Table style={{ tableLayout: "fixed" }}>
|
<EDataTable
|
||||||
<TableHeader className="sticky top-0 z-10">
|
columns={[
|
||||||
<TableRow className="bg-muted hover:bg-muted">
|
{ key: "order_no", label: "수주번호", render: (val: any) => <span className="font-medium text-sm">{val || "-"}</span> },
|
||||||
<TableHead className="w-[40px] text-center">
|
{ key: "due_date", label: "납기일", align: "center" as const, render: (val: any) => <span className="text-sm">{formatDate(val)}</span> },
|
||||||
<Checkbox
|
{ key: "customer_name", label: "거래처", render: (val: any) => <span className="text-sm">{val || "-"}</span> },
|
||||||
checked={data.length > 0 && checkedIds.length === data.filter(p => p.status !== "CANCELLED").length}
|
{ key: "part_code", label: "품목코드", render: (val: any) => <span className="text-muted-foreground text-[13px]">{val || "-"}</span> },
|
||||||
onCheckedChange={handleCheckAll}
|
{ key: "part_name", label: "품목명", render: (val: any) => <span className="font-medium text-sm">{val || "-"}</span> },
|
||||||
/>
|
{ key: "order_qty", label: "수주수량", align: "right" as const, formatNumber: true },
|
||||||
</TableHead>
|
{ key: "plan_qty", label: "계획수량", align: "right" as const, render: (val: any) => <span className="font-semibold text-primary text-sm">{formatNumber(val)}</span> },
|
||||||
{ts.isVisible("order_no") && <TableHead style={ts.thStyle("order_no")} className="w-[10%] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">수주번호</TableHead>}
|
{ key: "plan_date", label: "계획일", align: "center" as const, render: (val: any) => <span className="text-sm">{formatDate(val)}</span> },
|
||||||
{ts.isVisible("due_date") && <TableHead style={ts.thStyle("due_date")} className="w-[8%] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">납기일</TableHead>}
|
{ key: "status", label: "상태", align: "center" as const, render: (val: any) => <span className={cn("px-2 py-0.5 rounded-full text-[11px] font-medium", getStatusColor(val))}>{getStatusLabel(val)}</span> },
|
||||||
{ts.isVisible("customer_name") && <TableHead style={ts.thStyle("customer_name")} className="w-[12%] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">거래처</TableHead>}
|
] as EDataTableColumn<ShipmentPlanListItem>[]}
|
||||||
{ts.isVisible("part_code") && <TableHead style={ts.thStyle("part_code")} className="w-[18%] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품목코드</TableHead>}
|
data={data}
|
||||||
{ts.isVisible("part_name") && <TableHead style={ts.thStyle("part_name")} className="w-[18%] text-[11px] font-bold uppercase tracking-wide text-muted-foreground">품목명</TableHead>}
|
rowKey={(row) => String(row.id)}
|
||||||
{ts.isVisible("order_qty") && <TableHead style={ts.thStyle("order_qty")} className="w-[7%] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">수주수량</TableHead>}
|
loading={loading}
|
||||||
{ts.isVisible("plan_qty") && <TableHead style={ts.thStyle("plan_qty")} className="w-[7%] text-right text-[11px] font-bold uppercase tracking-wide text-muted-foreground">계획수량</TableHead>}
|
emptyMessage="출하계획이 없어요"
|
||||||
{ts.isVisible("plan_date") && <TableHead style={ts.thStyle("plan_date")} className="w-[8%] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">계획일</TableHead>}
|
selectedId={selectedId !== null ? String(selectedId) : null}
|
||||||
{ts.isVisible("status") && <TableHead style={ts.thStyle("status")} className="w-[6%] text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">상태</TableHead>}
|
onSelect={(id) => {
|
||||||
</TableRow>
|
if (id) {
|
||||||
</TableHeader>
|
const plan = data.find(p => String(p.id) === id);
|
||||||
<TableBody>
|
if (plan) handleRowClick(plan);
|
||||||
{groupedData.length === 0 ? (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={10} className="h-40 text-center">
|
|
||||||
<div className="flex flex-col items-center gap-2 text-muted-foreground">
|
|
||||||
<div className="w-12 h-12 rounded-full border-2 border-dashed border-muted-foreground/20 flex items-center justify-center">
|
|
||||||
<Inbox className="w-5 h-5 text-muted-foreground/30" />
|
|
||||||
</div>
|
|
||||||
<p className="text-sm font-medium">출하계획이 없어요</p>
|
|
||||||
<p className="text-xs text-muted-foreground/60">조건을 변경해서 다시 조회해주세요</p>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
) : (
|
|
||||||
groupedData.map(group =>
|
|
||||||
group.plans.map((plan, planIdx) => (
|
|
||||||
<TableRow
|
|
||||||
key={plan.id}
|
|
||||||
className={cn(
|
|
||||||
"cursor-pointer transition-colors",
|
|
||||||
selectedId === plan.id && "bg-primary/5",
|
|
||||||
plan.status === "CANCELLED" && "opacity-50",
|
|
||||||
planIdx === 0 && "border-t-2 border-t-border"
|
|
||||||
)}
|
|
||||||
onClick={() => handleRowClick(plan)}
|
|
||||||
>
|
|
||||||
<TableCell className="text-center" onClick={e => e.stopPropagation()}>
|
|
||||||
{planIdx === 0 && (
|
|
||||||
<Checkbox
|
|
||||||
checked={group.plans.every(p => checkedIds.includes(p.id))}
|
|
||||||
onCheckedChange={(c) => {
|
|
||||||
if (c) {
|
|
||||||
setCheckedIds(prev => [...new Set([...prev, ...group.plans.filter(p => p.status !== "CANCELLED").map(p => p.id)])]);
|
|
||||||
} else {
|
|
||||||
setCheckedIds(prev => prev.filter(id => !group.plans.some(p => p.id === id)));
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
onRowClick={(row) => handleRowClick(row)}
|
||||||
|
showCheckbox
|
||||||
|
checkedIds={checkedIds.map(String)}
|
||||||
|
onCheckedChange={(ids) => setCheckedIds(ids.map(Number))}
|
||||||
|
showPagination={false}
|
||||||
|
draggableColumns={false}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
{ts.isVisible("order_no") && <TableCell style={ts.thStyle("order_no")} className="font-medium text-sm">
|
|
||||||
{planIdx === 0 ? (plan.order_no || "-") : ""}
|
|
||||||
</TableCell>}
|
|
||||||
{ts.isVisible("due_date") && <TableCell style={ts.thStyle("due_date")} className="text-center text-sm">
|
|
||||||
{planIdx === 0 ? formatDate(plan.due_date) : ""}
|
|
||||||
</TableCell>}
|
|
||||||
{ts.isVisible("customer_name") && <TableCell style={ts.thStyle("customer_name")} className="text-sm">
|
|
||||||
{planIdx === 0 ? (plan.customer_name || "-") : ""}
|
|
||||||
</TableCell>}
|
|
||||||
{ts.isVisible("part_code") && <TableCell style={ts.thStyle("part_code")} className="text-muted-foreground text-[13px]">{plan.part_code || "-"}</TableCell>}
|
|
||||||
{ts.isVisible("part_name") && <TableCell style={ts.thStyle("part_name")} className="font-medium text-sm">{plan.part_name || "-"}</TableCell>}
|
|
||||||
{ts.isVisible("order_qty") && <TableCell style={ts.thStyle("order_qty")} className="text-right text-sm">{formatNumber(plan.order_qty)}</TableCell>}
|
|
||||||
{ts.isVisible("plan_qty") && <TableCell style={ts.thStyle("plan_qty")} className="text-right font-semibold text-primary text-sm">{formatNumber(plan.plan_qty)}</TableCell>}
|
|
||||||
{ts.isVisible("plan_date") && <TableCell style={ts.thStyle("plan_date")} className="text-center text-sm">{formatDate(plan.plan_date)}</TableCell>}
|
|
||||||
{ts.isVisible("status") && <TableCell style={ts.thStyle("status")} className="text-center">
|
|
||||||
<span className={cn("px-2 py-0.5 rounded-full text-[11px] font-medium", getStatusColor(plan.status))}>
|
|
||||||
{getStatusLabel(plan.status)}
|
|
||||||
</span>
|
|
||||||
</TableCell>}
|
|
||||||
</TableRow>
|
|
||||||
))
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
|
|||||||
@@ -147,7 +147,10 @@ export function DynamicSearchFilter({
|
|||||||
}
|
}
|
||||||
|
|
||||||
setAllColumns(merged);
|
setAllColumns(merged);
|
||||||
|
// externalFilterConfig가 있으면 외부 설정이 activeFilters를 관리하므로 건드리지 않음
|
||||||
|
if (!externalFilterConfig) {
|
||||||
setActiveFilters(merged.filter((c) => c.enabled));
|
setActiveFilters(merged.filter((c) => c.enabled));
|
||||||
|
}
|
||||||
|
|
||||||
// 저장된 필터 값 복원
|
// 저장된 필터 값 복원
|
||||||
const savedValues = localStorage.getItem(STORAGE_KEY_VALUES);
|
const savedValues = localStorage.getItem(STORAGE_KEY_VALUES);
|
||||||
|
|||||||
@@ -0,0 +1,795 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EDataTable — 직접 구현 페이지용 공통 데이터 테이블 컴포넌트
|
||||||
|
*
|
||||||
|
* 프리셋 디자인 규격(Type A~F) 기반, shadcn/ui 위에 구축.
|
||||||
|
* 기능: 정렬, 헤더 필터, 컬럼 드래그 이동, 인라인 편집, 체크박스, 페이지네이션
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
||||||
|
import {
|
||||||
|
DndContext, closestCenter, PointerSensor, useSensor, useSensors, DragEndEvent,
|
||||||
|
} from "@dnd-kit/core";
|
||||||
|
import { SortableContext, horizontalListSortingStrategy, useSortable, arrayMove } from "@dnd-kit/sortable";
|
||||||
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Filter, Check, Search, X, Loader2, Inbox, GripVertical,
|
||||||
|
ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, ArrowUp, ArrowDown,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
|
||||||
|
// ─── 타입 ───
|
||||||
|
|
||||||
|
export interface EDataTableColumn<T = any> {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
width?: string;
|
||||||
|
minWidth?: string;
|
||||||
|
align?: "left" | "center" | "right";
|
||||||
|
sortable?: boolean;
|
||||||
|
filterable?: boolean;
|
||||||
|
editable?: boolean;
|
||||||
|
inputType?: "text" | "number" | "date" | "select";
|
||||||
|
selectOptions?: { value: string; label: string }[];
|
||||||
|
formatNumber?: boolean;
|
||||||
|
truncate?: boolean;
|
||||||
|
render?: (value: any, row: T, rowIndex: number) => React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SortState {
|
||||||
|
key: string;
|
||||||
|
direction: "asc" | "desc";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EDataTableProps<T extends Record<string, any> = any> {
|
||||||
|
columns: EDataTableColumn<T>[];
|
||||||
|
data: T[];
|
||||||
|
rowKey?: (row: T) => string;
|
||||||
|
|
||||||
|
loading?: boolean;
|
||||||
|
emptyMessage?: string;
|
||||||
|
emptyIcon?: React.ReactNode;
|
||||||
|
|
||||||
|
selectedId?: string | null;
|
||||||
|
onSelect?: (id: string | null) => void;
|
||||||
|
|
||||||
|
showCheckbox?: boolean;
|
||||||
|
checkedIds?: string[];
|
||||||
|
onCheckedChange?: (ids: string[]) => void;
|
||||||
|
|
||||||
|
onRowClick?: (row: T, index: number) => void;
|
||||||
|
onRowDoubleClick?: (row: T, index: number) => void;
|
||||||
|
|
||||||
|
onCellEdit?: (rowId: string, columnKey: string, newValue: any, row: T) => void;
|
||||||
|
tableName?: string;
|
||||||
|
|
||||||
|
sort?: SortState | null;
|
||||||
|
onSortChange?: (sort: SortState | null) => void;
|
||||||
|
|
||||||
|
draggableColumns?: boolean;
|
||||||
|
onColumnOrderChange?: (columns: EDataTableColumn<T>[]) => void;
|
||||||
|
columnOrderKey?: string;
|
||||||
|
|
||||||
|
showRowNumber?: boolean;
|
||||||
|
showPagination?: boolean;
|
||||||
|
defaultPageSize?: number;
|
||||||
|
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 유틸 ───
|
||||||
|
|
||||||
|
const fmtNum = (val: any) => {
|
||||||
|
if (val == null || val === "") return "";
|
||||||
|
const n = Number(String(val).replace(/,/g, ""));
|
||||||
|
if (isNaN(n)) return String(val);
|
||||||
|
return n.toLocaleString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRowId = (row: any, rowKey?: (row: any) => string) => {
|
||||||
|
if (rowKey) return rowKey(row);
|
||||||
|
return row.id ?? row._id ?? "";
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── SortableHeaderCell ───
|
||||||
|
|
||||||
|
function SortableHeaderCell({
|
||||||
|
col, sortKey, sortDir, onSort,
|
||||||
|
headerFilterValues, uniqueValues, onToggleFilter, onClearFilter,
|
||||||
|
draggable,
|
||||||
|
}: {
|
||||||
|
col: EDataTableColumn;
|
||||||
|
sortKey: string | null;
|
||||||
|
sortDir: "asc" | "desc";
|
||||||
|
onSort: (key: string) => void;
|
||||||
|
headerFilterValues: Set<string>;
|
||||||
|
uniqueValues: string[];
|
||||||
|
onToggleFilter: (colKey: string, value: string) => void;
|
||||||
|
onClearFilter: (colKey: string) => void;
|
||||||
|
draggable: boolean;
|
||||||
|
}) {
|
||||||
|
const [filterSearch, setFilterSearch] = useState("");
|
||||||
|
const {
|
||||||
|
attributes, listeners, setNodeRef, transform, transition, isDragging,
|
||||||
|
} = useSortable({ id: col.key, disabled: !draggable });
|
||||||
|
|
||||||
|
const style: React.CSSProperties = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
opacity: isDragging ? 0.5 : 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const isSorted = sortKey === col.key;
|
||||||
|
const hasFilter = headerFilterValues.size > 0;
|
||||||
|
const filteredUniqueValues = uniqueValues.filter(
|
||||||
|
(v) => !filterSearch || v.toLowerCase().includes(filterSearch.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableHead
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
className={cn(
|
||||||
|
col.width, col.minWidth,
|
||||||
|
"text-[11px] font-bold uppercase tracking-wide text-muted-foreground select-none relative",
|
||||||
|
col.align === "right" && "text-right",
|
||||||
|
col.align === "center" && "text-center",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className={cn(
|
||||||
|
"inline-flex items-center gap-1",
|
||||||
|
col.align === "right" && "justify-end w-full",
|
||||||
|
col.align === "center" && "justify-center w-full",
|
||||||
|
)}>
|
||||||
|
{/* 드래그 핸들 */}
|
||||||
|
{draggable && (
|
||||||
|
<div
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
className="cursor-grab text-muted-foreground/40 hover:text-muted-foreground shrink-0"
|
||||||
|
>
|
||||||
|
<GripVertical className="h-3 w-3" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 컬럼 라벨 + 정렬 */}
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-1 cursor-pointer min-w-0"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (col.sortable !== false) onSort(col.key);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="truncate">{col.label}</span>
|
||||||
|
{isSorted && (
|
||||||
|
sortDir === "asc"
|
||||||
|
? <ArrowUp className="h-3 w-3 text-primary shrink-0" />
|
||||||
|
: <ArrowDown className="h-3 w-3 text-primary shrink-0" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 필터 아이콘 + Popover */}
|
||||||
|
{col.filterable !== false && uniqueValues.length > 0 && (
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<button
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className={cn(
|
||||||
|
"hover:bg-primary/20 rounded p-0.5 transition-colors shrink-0",
|
||||||
|
hasFilter && "text-primary bg-primary/10",
|
||||||
|
)}
|
||||||
|
title="필터"
|
||||||
|
>
|
||||||
|
<Filter className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-56 p-2" align="start" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between border-b pb-2">
|
||||||
|
<span className="text-xs font-medium">필터: {col.label}</span>
|
||||||
|
{hasFilter && (
|
||||||
|
<button onClick={() => onClearFilter(col.key)} className="text-primary text-xs hover:underline">
|
||||||
|
초기화
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3 w-3 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
value={filterSearch}
|
||||||
|
onChange={(e) => setFilterSearch(e.target.value)}
|
||||||
|
placeholder="검색..."
|
||||||
|
className="h-7 text-xs pl-7"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-52 space-y-0.5 overflow-y-auto">
|
||||||
|
{filteredUniqueValues.slice(0, 100).map((val) => {
|
||||||
|
const isSelected = headerFilterValues.has(val);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={val}
|
||||||
|
className={cn(
|
||||||
|
"hover:bg-muted flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-xs",
|
||||||
|
isSelected && "bg-primary/10",
|
||||||
|
)}
|
||||||
|
onClick={() => onToggleFilter(col.key, val)}
|
||||||
|
>
|
||||||
|
<div className={cn(
|
||||||
|
"flex h-4 w-4 items-center justify-center rounded border shrink-0",
|
||||||
|
isSelected ? "bg-primary border-primary" : "border-input",
|
||||||
|
)}>
|
||||||
|
{isSelected && <Check className="text-primary-foreground h-3 w-3" />}
|
||||||
|
</div>
|
||||||
|
<span className="truncate">{val || "(빈 값)"}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{filteredUniqueValues.length > 100 && (
|
||||||
|
<div className="text-muted-foreground px-2 py-1 text-xs">
|
||||||
|
...외 {filteredUniqueValues.length - 100}개
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableHead>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── EDataTable ───
|
||||||
|
|
||||||
|
export function EDataTable<T extends Record<string, any> = any>({
|
||||||
|
columns: initialColumns,
|
||||||
|
data,
|
||||||
|
rowKey,
|
||||||
|
loading = false,
|
||||||
|
emptyMessage = "데이터가 없어요",
|
||||||
|
emptyIcon,
|
||||||
|
selectedId,
|
||||||
|
onSelect,
|
||||||
|
showCheckbox = false,
|
||||||
|
checkedIds = [],
|
||||||
|
onCheckedChange,
|
||||||
|
onRowClick,
|
||||||
|
onRowDoubleClick,
|
||||||
|
onCellEdit,
|
||||||
|
tableName,
|
||||||
|
sort: externalSort,
|
||||||
|
onSortChange,
|
||||||
|
draggableColumns = true,
|
||||||
|
onColumnOrderChange,
|
||||||
|
columnOrderKey,
|
||||||
|
showRowNumber = false,
|
||||||
|
showPagination = true,
|
||||||
|
defaultPageSize = 50,
|
||||||
|
className,
|
||||||
|
}: EDataTableProps<T>) {
|
||||||
|
const [columns, setColumns] = useState(initialColumns);
|
||||||
|
useEffect(() => { setColumns(initialColumns); }, [initialColumns]);
|
||||||
|
|
||||||
|
// 정렬
|
||||||
|
const [internalSort, setInternalSort] = useState<SortState | null>(null);
|
||||||
|
const sortState = externalSort !== undefined ? externalSort : internalSort;
|
||||||
|
|
||||||
|
// 헤더 필터
|
||||||
|
const [headerFilters, setHeaderFilters] = useState<Record<string, Set<string>>>({});
|
||||||
|
|
||||||
|
// 페이지네이션
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(defaultPageSize);
|
||||||
|
const [pageSizeInput, setPageSizeInput] = useState(String(defaultPageSize));
|
||||||
|
|
||||||
|
// 인라인 편집
|
||||||
|
const [editingCell, setEditingCell] = useState<{ rowId: string; colKey: string } | null>(null);
|
||||||
|
const [editValue, setEditValue] = useState("");
|
||||||
|
const editRef = useRef<HTMLInputElement | HTMLSelectElement>(null);
|
||||||
|
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, { activationConstraint: { distance: 8 } })
|
||||||
|
);
|
||||||
|
|
||||||
|
// localStorage에서 컬럼 순서 복원
|
||||||
|
useEffect(() => {
|
||||||
|
if (!columnOrderKey) return;
|
||||||
|
const saved = localStorage.getItem(`edatatable_col_order_${columnOrderKey}`);
|
||||||
|
if (saved) {
|
||||||
|
try {
|
||||||
|
const order = JSON.parse(saved) as string[];
|
||||||
|
const reordered = order
|
||||||
|
.map((key) => initialColumns.find((c) => c.key === key))
|
||||||
|
.filter(Boolean) as EDataTableColumn<T>[];
|
||||||
|
const remaining = initialColumns.filter((c) => !order.includes(c.key));
|
||||||
|
setColumns([...reordered, ...remaining]);
|
||||||
|
} catch { /* skip */ }
|
||||||
|
}
|
||||||
|
}, [columnOrderKey]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
// 컬럼별 고유값
|
||||||
|
const columnUniqueValues = useMemo(() => {
|
||||||
|
const result: Record<string, string[]> = {};
|
||||||
|
for (const col of columns) {
|
||||||
|
if (col.filterable === false) continue;
|
||||||
|
const values = new Set<string>();
|
||||||
|
data.forEach((row) => {
|
||||||
|
const val = row[col.key];
|
||||||
|
if (val !== null && val !== undefined && val !== "") {
|
||||||
|
values.add(String(val));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
result[col.key] = Array.from(values).sort();
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}, [data, columns]);
|
||||||
|
|
||||||
|
// 드래그 완료
|
||||||
|
const handleDragEnd = (event: DragEndEvent) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
if (!over || active.id === over.id) return;
|
||||||
|
setColumns((prev) => {
|
||||||
|
const oldIndex = prev.findIndex((c) => c.key === active.id);
|
||||||
|
const newIndex = prev.findIndex((c) => c.key === over.id);
|
||||||
|
const next = arrayMove(prev, oldIndex, newIndex);
|
||||||
|
if (columnOrderKey) {
|
||||||
|
localStorage.setItem(`edatatable_col_order_${columnOrderKey}`, JSON.stringify(next.map((c) => c.key)));
|
||||||
|
}
|
||||||
|
onColumnOrderChange?.(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 정렬
|
||||||
|
const handleSort = (key: string) => {
|
||||||
|
const newSort: SortState | null = sortState?.key === key
|
||||||
|
? sortState.direction === "asc"
|
||||||
|
? { key, direction: "desc" }
|
||||||
|
: null
|
||||||
|
: { key, direction: "asc" };
|
||||||
|
|
||||||
|
if (onSortChange) {
|
||||||
|
onSortChange(newSort);
|
||||||
|
} else {
|
||||||
|
setInternalSort(newSort);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 헤더 필터
|
||||||
|
const toggleHeaderFilter = (colKey: string, value: string) => {
|
||||||
|
setHeaderFilters((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
const set = new Set(next[colKey] || []);
|
||||||
|
if (set.has(value)) set.delete(value); else set.add(value);
|
||||||
|
if (set.size === 0) delete next[colKey]; else next[colKey] = set;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearHeaderFilter = (colKey: string) => {
|
||||||
|
setHeaderFilters((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[colKey];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 필터 + 정렬
|
||||||
|
const processedData = useMemo(() => {
|
||||||
|
let result = [...data];
|
||||||
|
|
||||||
|
// 헤더 필터
|
||||||
|
if (Object.keys(headerFilters).length > 0) {
|
||||||
|
result = result.filter((row) =>
|
||||||
|
Object.entries(headerFilters).every(([colKey, values]) => {
|
||||||
|
if (values.size === 0) return true;
|
||||||
|
const cellVal = row[colKey] != null ? String(row[colKey]) : "";
|
||||||
|
return values.has(cellVal);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 정렬 (외부 정렬이 아닌 경우만)
|
||||||
|
if (sortState && !onSortChange) {
|
||||||
|
const { key, direction } = sortState;
|
||||||
|
result.sort((a, b) => {
|
||||||
|
const av = a[key] ?? "";
|
||||||
|
const bv = b[key] ?? "";
|
||||||
|
const na = Number(av);
|
||||||
|
const nb = Number(bv);
|
||||||
|
if (!isNaN(na) && !isNaN(nb)) return direction === "asc" ? na - nb : nb - na;
|
||||||
|
return direction === "asc"
|
||||||
|
? String(av).localeCompare(String(bv))
|
||||||
|
: String(bv).localeCompare(String(av));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}, [data, headerFilters, sortState, onSortChange]);
|
||||||
|
|
||||||
|
// 필터/데이터 변경 시 1페이지 리셋
|
||||||
|
useEffect(() => { setCurrentPage(1); }, [data, headerFilters]);
|
||||||
|
|
||||||
|
// 페이지네이션
|
||||||
|
const totalItems = processedData.length;
|
||||||
|
const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));
|
||||||
|
const safePage = Math.min(currentPage, totalPages);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentPage > totalPages) setCurrentPage(totalPages);
|
||||||
|
}, [currentPage, totalPages]);
|
||||||
|
|
||||||
|
const pageOffset = (safePage - 1) * pageSize;
|
||||||
|
const paginatedData = showPagination
|
||||||
|
? processedData.slice(pageOffset, pageOffset + pageSize)
|
||||||
|
: processedData;
|
||||||
|
|
||||||
|
const applyPageSize = () => {
|
||||||
|
const n = parseInt(pageSizeInput, 10);
|
||||||
|
if (!isNaN(n) && n >= 1) {
|
||||||
|
setPageSize(n);
|
||||||
|
setCurrentPage(1);
|
||||||
|
setPageSizeInput(String(n));
|
||||||
|
} else {
|
||||||
|
setPageSizeInput(String(pageSize));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPageNumbers = () => {
|
||||||
|
const delta = 2;
|
||||||
|
let start = Math.max(1, safePage - delta);
|
||||||
|
let end = Math.min(totalPages, safePage + delta);
|
||||||
|
if (end - start < delta * 2) {
|
||||||
|
if (start === 1) end = Math.min(totalPages, start + delta * 2);
|
||||||
|
else if (end === totalPages) start = Math.max(1, end - delta * 2);
|
||||||
|
}
|
||||||
|
const pages: (number | "...")[] = [];
|
||||||
|
if (start > 1) { pages.push(1); if (start > 2) pages.push("..."); }
|
||||||
|
for (let i = start; i <= end; i++) pages.push(i);
|
||||||
|
if (end < totalPages) { if (end < totalPages - 1) pages.push("..."); pages.push(totalPages); }
|
||||||
|
return pages;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 인라인 편집
|
||||||
|
const startEdit = (rowId: string, colKey: string, currentVal: any) => {
|
||||||
|
const col = columns.find((c) => c.key === colKey);
|
||||||
|
if (!col?.editable) return;
|
||||||
|
setEditingCell({ rowId, colKey });
|
||||||
|
setEditValue(currentVal != null ? String(currentVal) : "");
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveEdit = useCallback(async () => {
|
||||||
|
if (!editingCell) return;
|
||||||
|
const { rowId, colKey } = editingCell;
|
||||||
|
const row = paginatedData.find((r) => getRowId(r, rowKey) === rowId);
|
||||||
|
if (!row) { setEditingCell(null); return; }
|
||||||
|
|
||||||
|
const originalVal = String(row[colKey] ?? "");
|
||||||
|
if (originalVal === editValue) { setEditingCell(null); return; }
|
||||||
|
|
||||||
|
if (tableName && row.id) {
|
||||||
|
try {
|
||||||
|
await apiClient.put(`/table-management/tables/${tableName}/edit`, {
|
||||||
|
originalData: { id: row.id },
|
||||||
|
updatedData: { [colKey]: editValue || null },
|
||||||
|
});
|
||||||
|
(row as any)[colKey] = editValue;
|
||||||
|
toast.success("저장되었어요");
|
||||||
|
} catch {
|
||||||
|
toast.error("저장에 실패했어요");
|
||||||
|
setEditingCell(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onCellEdit?.(rowId, colKey, editValue, row as T);
|
||||||
|
setEditingCell(null);
|
||||||
|
}, [editingCell, editValue, paginatedData, tableName, onCellEdit, rowKey]);
|
||||||
|
|
||||||
|
const cancelEdit = () => setEditingCell(null);
|
||||||
|
|
||||||
|
const handleEditKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === "Enter") { e.preventDefault(); saveEdit(); }
|
||||||
|
else if (e.key === "Escape") { e.preventDefault(); cancelEdit(); }
|
||||||
|
else if (e.key === "Tab") { e.preventDefault(); saveEdit(); }
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (editingCell && editRef.current) {
|
||||||
|
editRef.current.focus();
|
||||||
|
if ("select" in editRef.current) editRef.current.select();
|
||||||
|
}
|
||||||
|
}, [editingCell]);
|
||||||
|
|
||||||
|
// 체크박스
|
||||||
|
const allChecked = processedData.length > 0 && checkedIds.length === processedData.length;
|
||||||
|
|
||||||
|
// colSpan 계산
|
||||||
|
const colSpan = columns.length + (showCheckbox ? 1 : 0) + (showRowNumber ? 1 : 0);
|
||||||
|
|
||||||
|
// 셀 렌더링
|
||||||
|
const renderCell = (row: T, col: EDataTableColumn<T>, rowIdx: number) => {
|
||||||
|
const id = getRowId(row, rowKey);
|
||||||
|
const isEditing = editingCell?.rowId === id && editingCell?.colKey === col.key;
|
||||||
|
const val = row[col.key];
|
||||||
|
|
||||||
|
// 편집 모드
|
||||||
|
if (isEditing) {
|
||||||
|
if (col.inputType === "select" && col.selectOptions) {
|
||||||
|
return (
|
||||||
|
<select
|
||||||
|
ref={editRef as any}
|
||||||
|
value={editValue}
|
||||||
|
onChange={(e) => setEditValue(e.target.value)}
|
||||||
|
onKeyDown={handleEditKeyDown}
|
||||||
|
onBlur={() => saveEdit()}
|
||||||
|
className="h-8 w-full rounded border border-primary bg-background px-2 text-[13px] focus:ring-1 focus:ring-primary"
|
||||||
|
>
|
||||||
|
<option value="">선택</option>
|
||||||
|
{col.selectOptions.map((o) => (
|
||||||
|
<option key={o.value} value={o.value}>{o.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
ref={editRef as any}
|
||||||
|
type={col.inputType === "number" ? "number" : col.inputType === "date" ? "date" : "text"}
|
||||||
|
value={editValue}
|
||||||
|
onChange={(e) => setEditValue(e.target.value)}
|
||||||
|
onKeyDown={handleEditKeyDown}
|
||||||
|
onBlur={() => saveEdit()}
|
||||||
|
className={cn(
|
||||||
|
"h-8 w-full rounded border border-primary bg-background px-2 text-[13px] focus:ring-1 focus:ring-primary",
|
||||||
|
col.align === "right" && "text-right"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 커스텀 렌더러
|
||||||
|
if (col.render) {
|
||||||
|
return col.render(val, row, rowIdx);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기본 렌더링
|
||||||
|
let display: React.ReactNode = val ?? "";
|
||||||
|
if (col.formatNumber || col.inputType === "number") display = fmtNum(val);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
col.truncate !== false && "block truncate",
|
||||||
|
col.align === "right" && "text-right w-full inline-block",
|
||||||
|
col.align === "center" && "text-center w-full inline-block",
|
||||||
|
)}
|
||||||
|
title={String(val ?? "")}
|
||||||
|
>
|
||||||
|
{display}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("flex flex-col h-full flex-1 min-h-0", className)}>
|
||||||
|
<div className="flex-1 min-h-0 overflow-auto">
|
||||||
|
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||||
|
<Table>
|
||||||
|
<TableHeader className="sticky top-0 z-10">
|
||||||
|
<SortableContext items={columns.map((c) => c.key)} strategy={horizontalListSortingStrategy}>
|
||||||
|
<TableRow className="bg-muted hover:bg-muted">
|
||||||
|
{/* 체크박스 */}
|
||||||
|
{showCheckbox && (
|
||||||
|
<TableHead className="w-10 text-center">
|
||||||
|
<Checkbox
|
||||||
|
checked={allChecked}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
onCheckedChange?.(checked ? processedData.map((r) => getRowId(r, rowKey)) : []);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</TableHead>
|
||||||
|
)}
|
||||||
|
{/* 행번호 */}
|
||||||
|
{showRowNumber && (
|
||||||
|
<TableHead className="w-10 text-center text-[11px] font-bold uppercase tracking-wide text-muted-foreground">
|
||||||
|
#
|
||||||
|
</TableHead>
|
||||||
|
)}
|
||||||
|
{/* 데이터 컬럼 */}
|
||||||
|
{columns.map((col) => (
|
||||||
|
<SortableHeaderCell
|
||||||
|
key={col.key}
|
||||||
|
col={col}
|
||||||
|
sortKey={sortState?.key ?? null}
|
||||||
|
sortDir={sortState?.direction ?? "asc"}
|
||||||
|
onSort={handleSort}
|
||||||
|
headerFilterValues={headerFilters[col.key] || new Set()}
|
||||||
|
uniqueValues={columnUniqueValues[col.key] || []}
|
||||||
|
onToggleFilter={toggleHeaderFilter}
|
||||||
|
onClearFilter={clearHeaderFilter}
|
||||||
|
draggable={draggableColumns}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
</SortableContext>
|
||||||
|
</TableHeader>
|
||||||
|
|
||||||
|
<TableBody>
|
||||||
|
{loading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={colSpan} className="py-16 text-center">
|
||||||
|
<Loader2 className="mx-auto h-6 w-6 animate-spin text-primary" />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : paginatedData.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={colSpan} className="py-16 text-center">
|
||||||
|
<div className="flex flex-col items-center gap-2 text-muted-foreground">
|
||||||
|
{emptyIcon || <Inbox className="h-8 w-8 opacity-30" />}
|
||||||
|
<span className="text-sm">{emptyMessage}</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
paginatedData.map((row, rowIdx) => {
|
||||||
|
// 그룹 소계 행 처리
|
||||||
|
if ((row as any)._isGroupSummary) {
|
||||||
|
return (
|
||||||
|
<TableRow key={`summary-${rowIdx}`} className="bg-muted/60 font-semibold border-t border-primary/20">
|
||||||
|
{showCheckbox && <TableCell />}
|
||||||
|
{showRowNumber && <TableCell />}
|
||||||
|
{columns.map((col) => (
|
||||||
|
<TableCell
|
||||||
|
key={col.key}
|
||||||
|
className={cn(
|
||||||
|
typeof row[col.key] === "number" ? "text-right font-mono text-[13px]" : "text-[13px] text-primary",
|
||||||
|
col.width, col.minWidth,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{typeof row[col.key] === "number" ? Number(row[col.key]).toLocaleString() : (row[col.key] || "")}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = getRowId(row, rowKey);
|
||||||
|
const isSelected = selectedId === id;
|
||||||
|
const isChecked = checkedIds.includes(id);
|
||||||
|
const highlighted = isSelected || isChecked;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow
|
||||||
|
key={id || rowIdx}
|
||||||
|
className={cn(
|
||||||
|
"cursor-pointer border-l-[3px] border-l-transparent transition-all",
|
||||||
|
highlighted
|
||||||
|
? "border-l-primary bg-primary/5"
|
||||||
|
: "hover:bg-accent"
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
onSelect?.(id);
|
||||||
|
onRowClick?.(row, pageOffset + rowIdx);
|
||||||
|
if (showCheckbox && onCheckedChange) {
|
||||||
|
const next = checkedIds.includes(id)
|
||||||
|
? checkedIds.filter((cid) => cid !== id)
|
||||||
|
: [...checkedIds, id];
|
||||||
|
onCheckedChange(next);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onDoubleClick={() => onRowDoubleClick?.(row, pageOffset + rowIdx)}
|
||||||
|
>
|
||||||
|
{showCheckbox && (
|
||||||
|
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<Checkbox
|
||||||
|
checked={isChecked}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
const next = checked
|
||||||
|
? [...checkedIds, id]
|
||||||
|
: checkedIds.filter((cid) => cid !== id);
|
||||||
|
onCheckedChange?.(next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
{showRowNumber && (
|
||||||
|
<TableCell className="text-center text-[11px] text-muted-foreground font-mono">
|
||||||
|
{pageOffset + rowIdx + 1}
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
{columns.map((col) => (
|
||||||
|
<TableCell
|
||||||
|
key={col.key}
|
||||||
|
className={cn(
|
||||||
|
col.width, col.minWidth,
|
||||||
|
col.editable && "cursor-text",
|
||||||
|
col.align === "right" && "text-right",
|
||||||
|
col.align === "center" && "text-center",
|
||||||
|
)}
|
||||||
|
onDoubleClick={(e) => {
|
||||||
|
if (col.editable) {
|
||||||
|
e.stopPropagation();
|
||||||
|
startEdit(id, col.key, row[col.key]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{renderCell(row, col, pageOffset + rowIdx)}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</DndContext>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 페이지네이션 */}
|
||||||
|
{showPagination && (
|
||||||
|
<div className="flex items-center justify-between border-t px-4 py-2.5 text-xs text-muted-foreground shrink-0">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span>전체</span>
|
||||||
|
<span className="font-medium text-foreground">{totalItems.toLocaleString()}</span>
|
||||||
|
<span>건</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={pageSizeInput}
|
||||||
|
onChange={(e) => setPageSizeInput(e.target.value)}
|
||||||
|
onBlur={applyPageSize}
|
||||||
|
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); applyPageSize(); } }}
|
||||||
|
className="h-7 w-16 text-center text-xs"
|
||||||
|
/>
|
||||||
|
<span>건씩 보기</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-0.5">
|
||||||
|
<button onClick={() => setCurrentPage(1)} disabled={safePage === 1}
|
||||||
|
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed">
|
||||||
|
<ChevronsLeft className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setCurrentPage((p) => Math.max(1, p - 1))} disabled={safePage === 1}
|
||||||
|
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed">
|
||||||
|
<ChevronLeft className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
{getPageNumbers().map((page, idx) =>
|
||||||
|
page === "..." ? (
|
||||||
|
<span key={`dot-${idx}`} className="h-7 w-7 flex items-center justify-center text-muted-foreground">...</span>
|
||||||
|
) : (
|
||||||
|
<button key={page} onClick={() => setCurrentPage(page as number)}
|
||||||
|
className={cn("h-7 w-7 flex items-center justify-center rounded text-xs",
|
||||||
|
page === safePage ? "bg-primary text-primary-foreground font-medium" : "hover:bg-muted")}>
|
||||||
|
{page}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
<button onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))} disabled={safePage === totalPages}
|
||||||
|
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed">
|
||||||
|
<ChevronRight className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setCurrentPage(totalPages)} disabled={safePage === totalPages}
|
||||||
|
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed">
|
||||||
|
<ChevronsRight className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-[180px]" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -652,20 +652,22 @@ export function TableSettingsModal({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 그룹별 합산 토글 */}
|
|
||||||
<div className="mt-3 flex items-center justify-between rounded-lg border p-3">
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-medium">그룹별 합산</div>
|
|
||||||
<div className="text-xs text-muted-foreground">같은 값끼리 그룹핑하여 합산</div>
|
|
||||||
</div>
|
|
||||||
<Switch checked={tempGroupSum} onCheckedChange={setTempGroupSum} />
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* ===== 탭 3: 그룹 설정 ===== */}
|
{/* ===== 탭 3: 그룹 설정 ===== */}
|
||||||
<TabsContent value="groups" className="mt-0 pt-3 overflow-y-auto max-h-[calc(80vh-220px)]">
|
<TabsContent value="groups" className="mt-0 pt-3 overflow-y-auto max-h-[calc(80vh-220px)]">
|
||||||
<div className="px-2 pb-3 border-b mb-2">
|
{/* 헤더 + 합산 토글 */}
|
||||||
<span className="text-sm font-medium">사용 가능한 컬럼</span>
|
<div className="flex items-center justify-between px-2 pb-3 border-b mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium">그룹 컬럼</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{tempGroups.filter((g) => g.enabled).length}개 선택
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-muted-foreground">소계 합산</span>
|
||||||
|
<Switch checked={tempGroupSum} onCheckedChange={setTempGroupSum} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ export function useTableSettings<T extends { key: string }>(
|
|||||||
() => initialVisibleKeys || defaultColumns.map((c) => c.key),
|
() => initialVisibleKeys || defaultColumns.map((c) => c.key),
|
||||||
);
|
);
|
||||||
const [baseFilter, setBaseFilter] = useState<BaseFilter | undefined>();
|
const [baseFilter, setBaseFilter] = useState<BaseFilter | undefined>();
|
||||||
|
const [groupColumns, setGroupColumns] = useState<string[]>([]);
|
||||||
|
const [groupSumEnabled, setGroupSumEnabled] = useState(false);
|
||||||
|
|
||||||
// 초기 filterConfig: GRID_COLUMNS에 있는 컬럼만 필터 가능 (전부 비활성)
|
// 초기 filterConfig: GRID_COLUMNS에 있는 컬럼만 필터 가능 (전부 비활성)
|
||||||
const [filterConfig, setFilterConfig] = useState<TableSettings["filters"]>(
|
const [filterConfig, setFilterConfig] = useState<TableSettings["filters"]>(
|
||||||
@@ -96,6 +98,11 @@ export function useTableSettings<T extends { key: string }>(
|
|||||||
|
|
||||||
// 기본 데이터 필터
|
// 기본 데이터 필터
|
||||||
setBaseFilter(settings.baseFilter);
|
setBaseFilter(settings.baseFilter);
|
||||||
|
|
||||||
|
// 그룹 설정
|
||||||
|
const enabledGroups = (settings.groups || []).filter((g) => g.enabled).map((g) => g.columnName);
|
||||||
|
setGroupColumns(enabledGroups);
|
||||||
|
setGroupSumEnabled(settings.groupSumEnabled || false);
|
||||||
},
|
},
|
||||||
[defaultColumns, initialVisibleKeys],
|
[defaultColumns, initialVisibleKeys],
|
||||||
);
|
);
|
||||||
@@ -148,6 +155,50 @@ export function useTableSettings<T extends { key: string }>(
|
|||||||
[columnWidths],
|
[columnWidths],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 데이터를 그룹핑하고 소계 행을 삽입한 배열을 반환합니다.
|
||||||
|
* groupColumns가 비어있으면 원본 배열을 그대로 반환합니다.
|
||||||
|
* 소계 행은 _isGroupSummary: true, _groupKey, _groupValue 속성을 가집니다.
|
||||||
|
*/
|
||||||
|
const groupData = useCallback(
|
||||||
|
<R extends Record<string, any>>(rows: R[]): (R & { _isGroupSummary?: boolean; _groupKey?: string; _groupValue?: string })[] => {
|
||||||
|
if (groupColumns.length === 0) return rows;
|
||||||
|
|
||||||
|
const groupCol = groupColumns[0]; // 첫 번째 그룹 컬럼 기준
|
||||||
|
const groups = new Map<string, R[]>();
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const key = String(row[groupCol] ?? "(빈 값)");
|
||||||
|
if (!groups.has(key)) groups.set(key, []);
|
||||||
|
groups.get(key)!.push(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: (R & { _isGroupSummary?: boolean; _groupKey?: string; _groupValue?: string })[] = [];
|
||||||
|
|
||||||
|
for (const [groupValue, groupRows] of groups) {
|
||||||
|
// 그룹 내 데이터 행
|
||||||
|
result.push(...groupRows);
|
||||||
|
|
||||||
|
// 소계 행 (groupSumEnabled일 때만)
|
||||||
|
if (groupSumEnabled) {
|
||||||
|
const summaryRow: any = { _isGroupSummary: true, _groupKey: groupCol, _groupValue: groupValue };
|
||||||
|
// 숫자 컬럼 합산
|
||||||
|
for (const col of defaultColumns) {
|
||||||
|
const values = groupRows.map((r) => Number(r[col.key])).filter((v) => !isNaN(v));
|
||||||
|
if (values.length > 0 && values.some((v) => v !== 0)) {
|
||||||
|
summaryRow[col.key] = values.reduce((a, b) => a + b, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
summaryRow[groupCol] = `${groupValue} 소계 (${groupRows.length}건)`;
|
||||||
|
result.push(summaryRow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
[groupColumns, groupSumEnabled, defaultColumns],
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
/** 모달 open 상태 */
|
/** 모달 open 상태 */
|
||||||
open,
|
open,
|
||||||
@@ -171,6 +222,12 @@ export function useTableSettings<T extends { key: string }>(
|
|||||||
filterConfig,
|
filterConfig,
|
||||||
/** 기본 데이터 필터 (예: division = '판매') */
|
/** 기본 데이터 필터 (예: division = '판매') */
|
||||||
baseFilter,
|
baseFilter,
|
||||||
|
/** 데이터 그룹핑 + 소계 삽입 함수 */
|
||||||
|
groupData,
|
||||||
|
/** 그룹 컬럼 목록 */
|
||||||
|
groupColumns,
|
||||||
|
/** 그룹별 합산 활성 여부 */
|
||||||
|
groupSumEnabled,
|
||||||
/** GRID_COLUMNS 기본 컬럼 키 목록 (TableSettingsModal defaultVisibleKeys용) */
|
/** GRID_COLUMNS 기본 컬럼 키 목록 (TableSettingsModal defaultVisibleKeys용) */
|
||||||
defaultVisibleKeys: initialVisibleKeys || defaultColumns.map((c) => c.key),
|
defaultVisibleKeys: initialVisibleKeys || defaultColumns.map((c) => c.key),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
hello
|
||||||
Reference in New Issue
Block a user