Merge pull request 'feat: COMPANY_9 수주관리 페이지 추가 및 생산계획/공정 개선' (#430) from jskim-node into main

This commit is contained in:
kjs
2026-03-27 22:35:16 +09:00
41 changed files with 3062 additions and 103 deletions
@@ -409,7 +409,10 @@ export default function ProductionPlanManagementPage() {
.filter((item) => selectedItemGroups.has(item.item_code))
.forEach((item) => {
const leadTime = Number(item.lead_time) || 0;
const totalRequired = Number(item.required_plan_qty);
// 재계산 모드: 기존 planned를 삭제 후 재생성 → 수주 잔량에서 진행중만 빼기
const totalRequired = recalculateUnstarted
? Number(item.total_balance_qty || 0) - Number(item.in_progress_qty || 0)
: Number(item.required_plan_qty);
if (totalRequired <= 0) return;
// 수주가 여러 건이고 납기일이 다르면 각각 분리
@@ -460,6 +463,14 @@ export default function ProductionPlanManagementPage() {
}
});
// items가 비어있으면 사용자에게 알림
if (items.length === 0) {
toast.error("계획 수량이 있는 품목이 없습니다. 수주 잔량을 확인해주세요.");
return;
}
setGenerating(true);
try {
const req: GenerateScheduleRequest = {
@@ -491,7 +502,9 @@ export default function ProductionPlanManagementPage() {
.filter((item) => selectedItemGroups.has(item.item_code))
.forEach((item) => {
const leadTime = Number(item.lead_time) || 0;
const totalRequired = Number(item.required_plan_qty);
const totalRequired = recalculateUnstarted
? Number(item.required_plan_qty) + Number(item.existing_plan_qty || 0)
: Number(item.required_plan_qty);
if (totalRequired <= 0) return;
if (item.orders && item.orders.length > 1) {
@@ -768,9 +781,12 @@ export default function ProductionPlanManagementPage() {
.map((item) => ({
item_code: item.item_code,
item_name: item.item_name,
required_qty: Number(item.required_plan_qty),
required_qty: (importMode !== "new" && recalculateUnstarted)
? Number(item.total_balance_qty || 0) - Number(item.in_progress_qty || 0)
: Number(item.required_plan_qty),
earliest_due_date: item.earliest_due_date || new Date().toISOString().split("T")[0],
}));
}))
.filter((item) => item.required_qty > 0);
setGenerating(true);
try {
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,763 @@
"use client";
/**
* 크롤링 관리 — 외부 웹사이트 데이터 수집 설정/실행/로그 관리
*/
import React, { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import {
Plus,
Search,
Play,
Pencil,
Trash2,
RefreshCw,
Globe,
Eye,
Clock,
CheckCircle,
XCircle,
Loader2,
Check,
ChevronsUpDown,
} from "lucide-react";
import { toast } from "sonner";
import { apiClient } from "@/lib/api/client";
import { tableTypeApi } from "@/lib/api/screen";
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { cn } from "@/lib/utils";
interface CrawlConfig {
id: string;
company_code: string;
name: string;
url: string;
method: string;
headers: Record<string, string>;
request_body?: string;
selector_type: string;
row_selector: string;
column_mappings: Array<{
selector: string;
column: string;
type: string;
attribute?: string;
}>;
target_table: string;
upsert_key?: string;
cron_schedule?: string;
is_active: string;
last_executed_at?: string;
last_status?: string;
last_error?: string;
}
interface CrawlLog {
id: string;
status: string;
rows_collected: number;
rows_saved: number;
error_message?: string;
started_at: string;
finished_at?: string;
}
const EMPTY_CONFIG: Partial<CrawlConfig> = {
name: "",
url: "",
method: "GET",
headers: {},
selector_type: "css",
row_selector: "",
column_mappings: [],
target_table: "",
upsert_key: "",
cron_schedule: "",
is_active: "Y",
};
export default function CrawlingManagementPage() {
const [configs, setConfigs] = useState<CrawlConfig[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [searchText, setSearchText] = useState("");
const [loading, setLoading] = useState(false);
// 모달
const [modalOpen, setModalOpen] = useState(false);
const [modalMode, setModalMode] = useState<"add" | "edit">("add");
const [form, setForm] = useState<Partial<CrawlConfig>>(EMPTY_CONFIG);
const [saving, setSaving] = useState(false);
// 테이블/컬럼 목록
const [allTables, setAllTables] = useState<Array<{ tableName: string; displayName: string }>>([]);
const [targetColumns, setTargetColumns] = useState<Array<{ columnName: string; columnLabel?: string }>>([]);
const [tablePopoverOpen, setTablePopoverOpen] = useState(false);
// URL 분석
const [analyzing, setAnalyzing] = useState(false);
const [analyzedTables, setAnalyzedTables] = useState<Array<{
index: number; selector: string; caption: string; headers: string[]; rowCount: number; sampleRows: string[][];
}>>([]);
const [selectedAnalyzedIdx, setSelectedAnalyzedIdx] = useState<number | null>(null);
// 컬럼 매핑 시각적 폼
const [mappingRows, setMappingRows] = useState<Array<{ selector: string; column: string; type: string }>>([]);
// 미리보기
const [previewOpen, setPreviewOpen] = useState(false);
const [previewData, setPreviewData] = useState<any>(null);
const [previewing, setPreviewing] = useState(false);
// 실행 로그
const [logs, setLogs] = useState<CrawlLog[]>([]);
const [logsLoading, setLogsLoading] = useState(false);
// 실행 중
const [executing, setExecuting] = useState<string | null>(null);
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
// ─── 데이터 로드 ───
const loadConfigs = useCallback(async () => {
setLoading(true);
try {
const res = await apiClient.get("/crawl/configs");
setConfigs(res.data.data || []);
} catch {
toast.error("크롤링 설정 로드 실패");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadConfigs();
tableTypeApi.getTables().then((t) => setAllTables(t || [])).catch(() => {});
}, [loadConfigs]);
// target_table 변경 시 컬럼 목록 로드
useEffect(() => {
if (!form.target_table) { setTargetColumns([]); return; }
tableTypeApi.getColumns(form.target_table).then((cols) => {
setTargetColumns(cols.map((c: any) => ({ columnName: c.columnName || c.column_name, columnLabel: c.displayName || c.columnLabel || c.column_label })));
}).catch(() => setTargetColumns([]));
}, [form.target_table]);
const loadLogs = useCallback(async (configId: string) => {
setLogsLoading(true);
try {
const res = await apiClient.get(`/crawl/configs/${configId}/logs?limit=20`);
setLogs(res.data.data || []);
} catch {
setLogs([]);
} finally {
setLogsLoading(false);
}
}, []);
useEffect(() => {
if (selectedId) loadLogs(selectedId);
else setLogs([]);
}, [selectedId, loadLogs]);
// ─── 필터링 ───
const filteredConfigs = configs.filter(
(c) =>
!searchText ||
c.name.toLowerCase().includes(searchText.toLowerCase()) ||
c.url.toLowerCase().includes(searchText.toLowerCase())
);
const selectedConfig = configs.find((c) => c.id === selectedId);
// ─── CRUD ───
const openAddModal = () => {
setModalMode("add");
setForm({ ...EMPTY_CONFIG });
setMappingRows([]);
setAnalyzedTables([]);
setSelectedAnalyzedIdx(null);
setModalOpen(true);
};
const openEditModal = (config: CrawlConfig) => {
setModalMode("edit");
setForm({ ...config });
const mappings = typeof config.column_mappings === "string"
? JSON.parse(config.column_mappings) : config.column_mappings || [];
setMappingRows(mappings.map((m: any) => ({ selector: m.selector || "", column: m.column || "", type: m.type || "text" })));
setAnalyzedTables([]);
setSelectedAnalyzedIdx(null);
setModalOpen(true);
};
// URL 분석
const handleAnalyze = async () => {
if (!form.url) { toast.error("URL을 입력하세요."); return; }
setAnalyzing(true);
try {
const res = await apiClient.post("/crawl/analyze", { url: form.url });
const data = res.data.data;
setAnalyzedTables(data.tables || []);
if (data.tables?.length > 0) {
toast.success(`${data.tables.length}개 테이블 감지됨`);
} else {
toast.info("페이지에서 테이블을 찾지 못했습니다.");
}
} catch (err: any) {
toast.error(err.response?.data?.message || "URL 분석 실패");
} finally {
setAnalyzing(false);
}
};
// 분석된 테이블 선택 시 자동 매핑 생성
const handleSelectAnalyzedTable = (idx: number) => {
const table = analyzedTables[idx];
if (!table) return;
setSelectedAnalyzedIdx(idx);
setForm((p) => ({ ...p, row_selector: `${table.selector} tbody tr` }));
// 헤더 기반으로 컬럼 매핑 자동 생성
const newMappings = table.headers.map((h, i) => ({
selector: `td:nth-child(${i + 1})`,
column: h.replace(/\s+/g, "_").replace(/[^a-zA-Z0-9_가-힣]/g, "").toLowerCase() || `col_${i + 1}`,
type: "text",
}));
setMappingRows(newMappings);
};
const handleSave = async () => {
if (!form.name || !form.url || !form.target_table) {
toast.error("이름, URL, 대상 테이블은 필수입니다.");
return;
}
setSaving(true);
try {
const payload = {
...form,
column_mappings: mappingRows.filter((m) => m.selector && m.column),
headers: form.headers || {},
};
if (modalMode === "add") {
await apiClient.post("/crawl/configs", payload);
toast.success("크롤링 설정이 생성되었습니다.");
} else {
await apiClient.put(`/crawl/configs/${form.id}`, payload);
toast.success("크롤링 설정이 수정되었습니다.");
}
setModalOpen(false);
loadConfigs();
} catch (err: any) {
toast.error(err.response?.data?.message || "저장 실패");
} finally {
setSaving(false);
}
};
const handleDelete = async (id: string) => {
const ok = await confirm("정말 삭제하시겠습니까?", { variant: "destructive", confirmText: "삭제" });
if (!ok) return;
try {
await apiClient.delete(`/crawl/configs/${id}`);
toast.success("삭제되었습니다.");
if (selectedId === id) setSelectedId(null);
loadConfigs();
} catch {
toast.error("삭제 실패");
}
};
// ─── 실행 & 미리보기 ───
const handleExecute = async (id: string) => {
setExecuting(id);
try {
const res = await apiClient.post(`/crawl/execute/${id}`);
const data = res.data.data;
toast.success(`수집 ${data.collected}건, 저장 ${data.saved}`);
loadConfigs();
if (selectedId === id) loadLogs(id);
} catch (err: any) {
toast.error(err.response?.data?.message || "실행 실패");
} finally {
setExecuting(null);
}
};
const handlePreview = async () => {
setPreviewing(true);
try {
const res = await apiClient.post("/crawl/preview", {
url: form.url,
row_selector: form.row_selector,
column_mappings: mappingRows.filter((m) => m.selector && m.column),
method: form.method,
headers: form.headers || {},
request_body: form.request_body,
});
setPreviewData(res.data.data);
setPreviewOpen(true);
} catch (err: any) {
toast.error(err.response?.data?.message || "미리보기 실패");
} finally {
setPreviewing(false);
}
};
// ─── 렌더링 ───
return (
<div className="flex h-full gap-4 p-4">
{/* 좌측: 설정 목록 */}
<div className="flex w-[340px] shrink-0 flex-col rounded-lg border bg-card">
<div className="flex items-center justify-between border-b p-3">
<h2 className="text-sm font-semibold"> </h2>
<div className="flex gap-1">
<Button variant="outline" size="sm" onClick={loadConfigs} disabled={loading}>
<RefreshCw className={`h-3.5 w-3.5 ${loading ? "animate-spin" : ""}`} />
</Button>
<Button size="sm" onClick={openAddModal}>
<Plus className="mr-1 h-3.5 w-3.5" />
</Button>
</div>
</div>
<div className="border-b p-2">
<div className="relative">
<Search className="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
placeholder="검색..."
className="h-8 pl-8 text-xs"
/>
</div>
</div>
<div className="flex-1 overflow-auto">
{filteredConfigs.length === 0 ? (
<div className="p-4 text-center text-xs text-muted-foreground">
{loading ? "로딩 중..." : "설정이 없습니다."}
</div>
) : (
filteredConfigs.map((config) => (
<div
key={config.id}
className={`cursor-pointer border-b p-3 transition-colors hover:bg-muted/50 ${
selectedId === config.id ? "bg-muted" : ""
}`}
onClick={() => setSelectedId(config.id)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Globe className="h-4 w-4 text-primary" />
<span className="text-sm font-medium">{config.name}</span>
</div>
<Badge variant={config.is_active === "Y" ? "default" : "secondary"} className="text-[10px]">
{config.is_active === "Y" ? "활성" : "비활성"}
</Badge>
</div>
<div className="mt-1 truncate text-[11px] text-muted-foreground">{config.url}</div>
<div className="mt-1 flex items-center gap-2 text-[10px] text-muted-foreground">
{config.cron_schedule && (
<span className="flex items-center gap-0.5">
<Clock className="h-3 w-3" /> {config.cron_schedule}
</span>
)}
{config.last_status && (
<span className="flex items-center gap-0.5">
{config.last_status === "success" ? (
<CheckCircle className="h-3 w-3 text-green-500" />
) : (
<XCircle className="h-3 w-3 text-red-500" />
)}
{config.last_status}
</span>
)}
</div>
</div>
))
)}
</div>
</div>
{/* 우측: 상세 + 로그 */}
<div className="flex flex-1 flex-col gap-4">
{selectedConfig ? (
<>
{/* 상세 정보 */}
<div className="rounded-lg border bg-card p-4">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-sm font-semibold">{selectedConfig.name}</h3>
<div className="flex gap-1">
<Button
variant="outline"
size="sm"
onClick={() => handleExecute(selectedConfig.id)}
disabled={executing === selectedConfig.id}
>
{executing === selectedConfig.id ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
) : (
<Play className="mr-1 h-3.5 w-3.5" />
)}
</Button>
<Button variant="outline" size="sm" onClick={() => openEditModal(selectedConfig)}>
<Pencil className="mr-1 h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="sm" onClick={() => handleDelete(selectedConfig.id)}>
<Trash2 className="h-3.5 w-3.5 text-destructive" />
</Button>
</div>
</div>
<div className="grid grid-cols-2 gap-3 text-xs">
<div>
<span className="text-muted-foreground">URL</span>
<div className="mt-0.5 truncate font-mono">{selectedConfig.url}</div>
</div>
<div>
<span className="text-muted-foreground"> </span>
<div className="mt-0.5 font-mono">{selectedConfig.target_table}</div>
</div>
<div>
<span className="text-muted-foreground"> </span>
<div className="mt-0.5 font-mono">{selectedConfig.row_selector || "-"}</div>
</div>
<div>
<span className="text-muted-foreground"></span>
<div className="mt-0.5">{selectedConfig.cron_schedule || "수동 실행"}</div>
</div>
<div>
<span className="text-muted-foreground">UPSERT </span>
<div className="mt-0.5">{selectedConfig.upsert_key || "-"}</div>
</div>
<div>
<span className="text-muted-foreground"> </span>
<div className="mt-0.5">{(selectedConfig.column_mappings || []).length}</div>
</div>
</div>
{selectedConfig.last_error && (
<div className="mt-3 rounded bg-destructive/10 p-2 text-xs text-destructive">
{selectedConfig.last_error}
</div>
)}
</div>
{/* 실행 로그 */}
<div className="flex-1 rounded-lg border bg-card">
<div className="flex items-center justify-between border-b p-3">
<h3 className="text-sm font-semibold"> </h3>
<Button variant="ghost" size="sm" onClick={() => loadLogs(selectedConfig.id)} disabled={logsLoading}>
<RefreshCw className={`h-3.5 w-3.5 ${logsLoading ? "animate-spin" : ""}`} />
</Button>
</div>
<div className="max-h-[400px] overflow-auto">
{logs.length === 0 ? (
<div className="p-4 text-center text-xs text-muted-foreground"> .</div>
) : (
<table className="w-full text-xs">
<thead className="bg-muted/50">
<tr>
<th className="px-3 py-2 text-left"></th>
<th className="px-3 py-2 text-left"></th>
<th className="px-3 py-2 text-right"></th>
<th className="px-3 py-2 text-right"></th>
<th className="px-3 py-2 text-left"></th>
</tr>
</thead>
<tbody>
{logs.map((log) => (
<tr key={log.id} className="border-b hover:bg-muted/30">
<td className="px-3 py-2">
<Badge
variant={
log.status === "success" ? "default" : log.status === "running" ? "secondary" : "destructive"
}
className="text-[10px]"
>
{log.status}
</Badge>
</td>
<td className="px-3 py-2">{new Date(log.started_at).toLocaleString("ko-KR")}</td>
<td className="px-3 py-2 text-right">{log.rows_collected}</td>
<td className="px-3 py-2 text-right">{log.rows_saved}</td>
<td className="max-w-[200px] truncate px-3 py-2 text-destructive">
{log.error_message || "-"}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
</>
) : (
<div className="flex flex-1 items-center justify-center rounded-lg border bg-card text-sm text-muted-foreground">
.
</div>
)}
</div>
{/* 추가/수정 모달 */}
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
<DialogContent className="max-h-[85vh] max-w-3xl overflow-auto">
<DialogHeader>
<DialogTitle>{modalMode === "add" ? "크롤링 설정 추가" : "크롤링 설정 수정"}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{/* STEP 1: 기본 정보 */}
<div className="rounded-lg border p-3 space-y-3">
<h4 className="text-xs font-semibold text-muted-foreground">1. </h4>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-xs"> *</Label>
<Input value={form.name || ""} onChange={(e) => setForm((p) => ({ ...p, name: e.target.value }))} placeholder="예: 철강 시세 수집" className="h-8 text-xs" />
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-xs"> (cron)</Label>
<Input value={form.cron_schedule || ""} onChange={(e) => setForm((p) => ({ ...p, cron_schedule: e.target.value }))} placeholder="0 9 * * 1-5" className="h-8 text-xs font-mono" />
</div>
<div className="flex items-end gap-2 pb-0.5">
<Switch checked={form.is_active === "Y"} onCheckedChange={(v) => setForm((p) => ({ ...p, is_active: v ? "Y" : "N" }))} />
<Label className="text-xs"></Label>
</div>
</div>
</div>
</div>
{/* STEP 2: URL 입력 + 분석 */}
<div className="rounded-lg border p-3 space-y-3">
<h4 className="text-xs font-semibold text-muted-foreground">2. </h4>
<div className="flex gap-2">
<Input value={form.url || ""} onChange={(e) => setForm((p) => ({ ...p, url: e.target.value }))} placeholder="https://example.com/prices" className="h-8 flex-1 text-xs font-mono" />
<Button variant="outline" size="sm" onClick={handleAnalyze} disabled={analyzing || !form.url}>
{analyzing ? <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" /> : <Search className="mr-1 h-3.5 w-3.5" />}
</Button>
</div>
{/* 분석 결과: 감지된 테이블 목록 */}
{analyzedTables.length > 0 && (
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">{analyzedTables.length} </Label>
<div className="space-y-2 max-h-[200px] overflow-auto">
{analyzedTables.map((t, idx) => (
<div
key={idx}
className={cn(
"cursor-pointer rounded border p-2 text-xs transition-colors hover:bg-muted/50",
selectedAnalyzedIdx === idx && "border-primary bg-primary/5"
)}
onClick={() => handleSelectAnalyzedTable(idx)}
>
<div className="flex items-center justify-between">
<span className="font-medium">{t.caption || `테이블 ${idx + 1}`}</span>
<Badge variant="secondary" className="text-[10px]">{t.rowCount} · {t.headers.length}</Badge>
</div>
{t.headers.length > 0 && (
<div className="mt-1 text-[10px] text-muted-foreground truncate">
: {t.headers.join(", ")}
</div>
)}
{t.sampleRows.length > 0 && (
<div className="mt-1 text-[10px] text-muted-foreground truncate">
: {t.sampleRows[0].join(" | ")}
</div>
)}
</div>
))}
</div>
</div>
)}
</div>
{/* STEP 3: 컬럼 매핑 (시각적 폼) */}
<div className="rounded-lg border p-3 space-y-3">
<div className="flex items-center justify-between">
<h4 className="text-xs font-semibold text-muted-foreground">3. </h4>
<Button variant="ghost" size="sm" className="h-6 text-[10px]" onClick={() => setMappingRows((p) => [...p, { selector: "", column: "", type: "text" }])}>
<Plus className="mr-0.5 h-3 w-3" />
</Button>
</div>
{mappingRows.length === 0 ? (
<div className="text-center text-xs text-muted-foreground py-3">
"페이지 분석" .
</div>
) : (
<div className="space-y-1">
<div className="grid grid-cols-[1fr_1fr_80px_32px] gap-1.5 text-[10px] text-muted-foreground px-1">
<span>CSS </span>
<span> </span>
<span></span>
<span></span>
</div>
{mappingRows.map((row, i) => (
<div key={i} className="grid grid-cols-[1fr_1fr_80px_32px] gap-1.5">
<Input value={row.selector} onChange={(e) => { const n = [...mappingRows]; n[i] = { ...n[i], selector: e.target.value }; setMappingRows(n); }} placeholder="td:nth-child(1)" className="h-7 text-xs font-mono" />
<Input value={row.column} onChange={(e) => { const n = [...mappingRows]; n[i] = { ...n[i], column: e.target.value }; setMappingRows(n); }} placeholder="item_name" className="h-7 text-xs" />
<Select value={row.type} onValueChange={(v) => { const n = [...mappingRows]; n[i] = { ...n[i], type: v }; setMappingRows(n); }}>
<SelectTrigger className="h-7 text-[10px]"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="text"></SelectItem>
<SelectItem value="number"></SelectItem>
<SelectItem value="date"></SelectItem>
</SelectContent>
</Select>
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => setMappingRows((p) => p.filter((_, j) => j !== i))}>
<Trash2 className="h-3 w-3 text-muted-foreground" />
</Button>
</div>
))}
</div>
)}
{form.row_selector && (
<div className="text-[10px] text-muted-foreground">
: <code className="bg-muted px-1 rounded">{form.row_selector}</code>
</div>
)}
</div>
{/* STEP 4: 저장 대상 */}
<div className="rounded-lg border p-3 space-y-3">
<h4 className="text-xs font-semibold text-muted-foreground">4. </h4>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-xs"> *</Label>
<Popover open={tablePopoverOpen} onOpenChange={setTablePopoverOpen}>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" className="h-8 w-full justify-between text-xs font-normal">
{form.target_table ? allTables.find((t) => t.tableName === form.target_table)?.displayName || form.target_table : "테이블 선택"}
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[280px] p-0">
<Command>
<CommandInput placeholder="테이블 검색..." className="text-xs" />
<CommandEmpty> .</CommandEmpty>
<CommandGroup className="max-h-[200px] overflow-auto">
{allTables.map((t) => (
<CommandItem key={t.tableName} value={`${t.displayName || ""} ${t.tableName}`} onSelect={() => { setForm((p) => ({ ...p, target_table: t.tableName, upsert_key: "" })); setTablePopoverOpen(false); }}>
<Check className={cn("mr-2 h-3 w-3", form.target_table === t.tableName ? "opacity-100" : "opacity-0")} />
<span className="text-xs">{t.displayName || t.tableName}</span>
{t.displayName && <span className="ml-1 text-[10px] text-muted-foreground">({t.tableName})</span>}
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
</div>
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Select value={form.upsert_key || "__none__"} onValueChange={(v) => setForm((p) => ({ ...p, upsert_key: v === "__none__" ? "" : v }))}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="없음 (항상 추가)" /></SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> ( )</SelectItem>
{targetColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>{col.columnLabel || col.columnName}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
</div>
<DialogFooter className="gap-2">
<Button variant="outline" size="sm" onClick={handlePreview} disabled={previewing || !form.url || mappingRows.length === 0}>
{previewing ? <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" /> : <Eye className="mr-1 h-3.5 w-3.5" />}
</Button>
<Button size="sm" onClick={handleSave} disabled={saving}>
{saving ? <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" /> : null}
{modalMode === "add" ? "생성" : "저장"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 미리보기 모달 */}
<Dialog open={previewOpen} onOpenChange={setPreviewOpen}>
<DialogContent className="max-h-[70vh] max-w-3xl overflow-auto">
<DialogHeader>
<DialogTitle> </DialogTitle>
</DialogHeader>
{previewData && (
<div className="space-y-3">
<div className="flex gap-4 text-xs">
<span>
: <strong>{previewData.totalElements}</strong>
</span>
<span>
HTML : <strong>{(previewData.htmlLength / 1024).toFixed(1)}KB</strong>
</span>
<span>
: <strong>{previewData.previewRows?.length || 0}</strong>
</span>
</div>
{previewData.previewRows?.length > 0 ? (
<div className="overflow-auto rounded border">
<table className="w-full text-xs">
<thead className="bg-muted/50">
<tr>
{Object.keys(previewData.previewRows[0]).map((key) => (
<th key={key} className="px-3 py-2 text-left font-medium">
{key}
</th>
))}
</tr>
</thead>
<tbody>
{previewData.previewRows.map((row: any, i: number) => (
<tr key={i} className="border-t">
{Object.values(row).map((val: any, j: number) => (
<td key={j} className="max-w-[200px] truncate px-3 py-1.5">
{val != null ? String(val) : "-"}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="p-4 text-center text-xs text-muted-foreground">
. .
</div>
)}
</div>
)}
</DialogContent>
</Dialog>
{ConfirmDialogComponent}
</div>
);
}
@@ -5,6 +5,7 @@ import dynamic from "next/dynamic";
import { Loader2 } from "lucide-react";
import { ScreenViewPageWrapper } from "@/app/(main)/screens/[screenId]/page";
import { apiClient } from "@/lib/api/client";
import { useAuth } from "@/hooks/useAuth";
const LoadingFallback = () => (
<div className="flex h-full items-center justify-center">
@@ -89,24 +90,35 @@ const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = {
// 자동화 관리
"/admin/automaticMng/flowMgmtList": dynamic(() => import("@/app/(main)/admin/automaticMng/flowMgmtList/page"), { ssr: false, loading: LoadingFallback }),
"/admin/automaticMng/batchmngList": dynamic(() => import("@/app/(main)/admin/automaticMng/batchmngList/page"), { ssr: false, loading: LoadingFallback }),
"/admin/automaticMng/crawlingList": dynamic(() => import("@/app/(main)/admin/automaticMng/crawlingList/page"), { ssr: false, loading: LoadingFallback }),
// 설계 관리 (커스텀 페이지)
"/design/task-management": dynamic(() => import("@/app/(main)/design/task-management/page"), { ssr: false, loading: LoadingFallback }),
"/design/my-work": dynamic(() => import("@/app/(main)/design/my-work/page"), { ssr: false, loading: LoadingFallback }),
"/design/design-request": dynamic(() => import("@/app/(main)/design/design-request/page"), { ssr: false, loading: LoadingFallback }),
// === COMPANY_7 (탑씰) ===
"/COMPANY_7/master-data/item-info": dynamic(() => import("@/app/(main)/COMPANY_7/master-data/item-info/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_7/master-data/department": dynamic(() => import("@/app/(main)/COMPANY_7/master-data/department/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_7/sales/order": dynamic(() => import("@/app/(main)/COMPANY_7/sales/order/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_7/sales/customer": dynamic(() => import("@/app/(main)/COMPANY_7/sales/customer/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_7/sales/sales-item": dynamic(() => import("@/app/(main)/COMPANY_7/sales/sales-item/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_7/sales/shipping-order": dynamic(() => import("@/app/(main)/COMPANY_7/sales/shipping-order/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_7/sales/shipping-plan": dynamic(() => import("@/app/(main)/COMPANY_7/sales/shipping-plan/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_7/sales/claim": dynamic(() => import("@/app/(main)/COMPANY_7/sales/claim/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_7/production/process-info": dynamic(() => import("@/app/(main)/COMPANY_7/production/process-info/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_7/production/work-instruction": dynamic(() => import("@/app/(main)/COMPANY_7/production/work-instruction/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_7/production/plan-management": dynamic(() => import("@/app/(main)/COMPANY_7/production/plan-management/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_7/equipment/info": dynamic(() => import("@/app/(main)/COMPANY_7/equipment/info/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_7/logistics/material-status": dynamic(() => import("@/app/(main)/COMPANY_7/logistics/material-status/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_7/logistics/outbound": dynamic(() => import("@/app/(main)/COMPANY_7/logistics/outbound/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_7/logistics/receiving": dynamic(() => import("@/app/(main)/COMPANY_7/logistics/receiving/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_7/logistics/packaging": dynamic(() => import("@/app/(main)/COMPANY_7/logistics/packaging/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_7/outsourcing/subcontractor": dynamic(() => import("@/app/(main)/COMPANY_7/outsourcing/subcontractor/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_7/outsourcing/subcontractor-item": dynamic(() => import("@/app/(main)/COMPANY_7/outsourcing/subcontractor-item/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_7/design/project": dynamic(() => import("@/app/(main)/COMPANY_7/design/project/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_7/design/change-management": dynamic(() => import("@/app/(main)/COMPANY_7/design/change-management/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_7/design/my-work": dynamic(() => import("@/app/(main)/COMPANY_7/design/my-work/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_7/design/design-request": dynamic(() => import("@/app/(main)/COMPANY_7/design/design-request/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_7/design/task-management": dynamic(() => import("@/app/(main)/COMPANY_7/design/task-management/page"), { ssr: false, loading: LoadingFallback }),
// === COMPANY_9 (제일그라스) ===
"/COMPANY_9/sales/order": dynamic(() => import("@/app/(main)/COMPANY_9/sales/order/page"), { ssr: false, loading: LoadingFallback }),
// 영업 관리 (커스텀 페이지)
"/sales/shipping-plan": dynamic(() => import("@/app/(main)/sales/shipping-plan/page"), { ssr: false, loading: LoadingFallback }),
"/sales/shipping-order": dynamic(() => import("@/app/(main)/sales/shipping-order/page"), { ssr: false, loading: LoadingFallback }),
// 생산 관리 (커스텀 페이지)
"/production/work-instruction": dynamic(() => import("@/app/(main)/production/work-instruction/page"), { ssr: false, loading: LoadingFallback }),
// 물류 관리 (커스텀 페이지)
"/logistics/material-status": dynamic(() => import("@/app/(main)/logistics/material-status/page"), { ssr: false, loading: LoadingFallback }),
// 설계 관리 (커스텀 페이지)
"/design/change-management": dynamic(() => import("@/app/(main)/design/change-management/page"), { ssr: false, loading: LoadingFallback }),
"/admin/automaticMng/exconList": dynamic(() => import("@/app/(main)/admin/automaticMng/exconList/page"), { ssr: false, loading: LoadingFallback }),
"/admin/automaticMng/exCallConfList": dynamic(() => import("@/app/(main)/admin/automaticMng/exCallConfList/page"), { ssr: false, loading: LoadingFallback }),
@@ -157,6 +169,34 @@ const DYNAMIC_ADMIN_IMPORTS: Record<string, () => Promise<any>> = {
"/admin/automaticMng/batchmngList/create": () => import("@/app/(main)/admin/automaticMng/batchmngList/create/page"),
"/admin/systemMng/dataflow/node-editorList": () => import("@/app/(main)/admin/systemMng/dataflow/page"),
"/admin/standards/new": () => import("@/app/(main)/admin/standards/new/page"),
// === 회사별 커스텀 페이지 (resolvedUrl로 매칭) ===
// COMPANY_7 (탑씰)
"/COMPANY_7/master-data/item-info": () => import("@/app/(main)/COMPANY_7/master-data/item-info/page"),
"/COMPANY_7/master-data/department": () => import("@/app/(main)/COMPANY_7/master-data/department/page"),
"/COMPANY_7/sales/order": () => import("@/app/(main)/COMPANY_7/sales/order/page"),
"/COMPANY_7/sales/customer": () => import("@/app/(main)/COMPANY_7/sales/customer/page"),
"/COMPANY_7/sales/sales-item": () => import("@/app/(main)/COMPANY_7/sales/sales-item/page"),
"/COMPANY_7/sales/shipping-order": () => import("@/app/(main)/COMPANY_7/sales/shipping-order/page"),
"/COMPANY_7/sales/shipping-plan": () => import("@/app/(main)/COMPANY_7/sales/shipping-plan/page"),
"/COMPANY_7/sales/claim": () => import("@/app/(main)/COMPANY_7/sales/claim/page"),
"/COMPANY_7/production/process-info": () => import("@/app/(main)/COMPANY_7/production/process-info/page"),
"/COMPANY_7/production/work-instruction": () => import("@/app/(main)/COMPANY_7/production/work-instruction/page"),
"/COMPANY_7/production/plan-management": () => import("@/app/(main)/COMPANY_7/production/plan-management/page"),
"/COMPANY_7/equipment/info": () => import("@/app/(main)/COMPANY_7/equipment/info/page"),
"/COMPANY_7/logistics/material-status": () => import("@/app/(main)/COMPANY_7/logistics/material-status/page"),
"/COMPANY_7/logistics/outbound": () => import("@/app/(main)/COMPANY_7/logistics/outbound/page"),
"/COMPANY_7/logistics/receiving": () => import("@/app/(main)/COMPANY_7/logistics/receiving/page"),
"/COMPANY_7/logistics/packaging": () => import("@/app/(main)/COMPANY_7/logistics/packaging/page"),
"/COMPANY_7/outsourcing/subcontractor": () => import("@/app/(main)/COMPANY_7/outsourcing/subcontractor/page"),
"/COMPANY_7/outsourcing/subcontractor-item": () => import("@/app/(main)/COMPANY_7/outsourcing/subcontractor-item/page"),
"/COMPANY_7/design/project": () => import("@/app/(main)/COMPANY_7/design/project/page"),
"/COMPANY_7/design/change-management": () => import("@/app/(main)/COMPANY_7/design/change-management/page"),
"/COMPANY_7/design/my-work": () => import("@/app/(main)/COMPANY_7/design/my-work/page"),
"/COMPANY_7/design/design-request": () => import("@/app/(main)/COMPANY_7/design/design-request/page"),
"/COMPANY_7/design/task-management": () => import("@/app/(main)/COMPANY_7/design/task-management/page"),
// COMPANY_9 (제일그라스)
"/COMPANY_9/sales/order": () => import("@/app/(main)/COMPANY_9/sales/order/page"),
};
const DYNAMIC_ADMIN_PATTERNS: Array<{
@@ -291,10 +331,34 @@ interface AdminPageRendererProps {
url: string;
}
// 회사별 커스텀 페이지 경로 prefix 목록
// 이 prefix로 시작하는 URL은 회사코드 폴더에서 로드
const COMPANY_PAGE_PREFIXES = [
"/sales/",
"/master-data/",
"/production/",
"/equipment/",
"/logistics/",
"/outsourcing/",
"/design/",
];
function isCompanyPage(url: string): boolean {
return COMPANY_PAGE_PREFIXES.some((prefix) => url.startsWith(prefix));
}
export function AdminPageRenderer({ url }: AdminPageRendererProps) {
const { user } = useAuth();
const companyCode = user?.companyCode || user?.company_code;
const cleanUrl = url.split("?")[0].split("#")[0].replace(/\/$/, "");
console.log("[AdminPageRenderer] 렌더링:", { url, cleanUrl });
// 회사별 커스텀 페이지: companyCode를 prefix로 붙여 경로 변환
// 예: /sales/order → /COMPANY_7/sales/order
const resolvedUrl = (companyCode && isCompanyPage(cleanUrl))
? `/${companyCode}${cleanUrl}`
: cleanUrl;
console.log("[AdminPageRenderer] 렌더링:", { url, cleanUrl, resolvedUrl, companyCode });
// 화면 할당: /screens/[id]
const screensIdMatch = cleanUrl.match(/^\/screens\/(\d+)$/);
@@ -317,13 +381,13 @@ export function AdminPageRenderer({ url }: AdminPageRendererProps) {
return <DashboardViewPage params={Promise.resolve({ dashboardId: dashboardMatch[1] })} />;
}
// URL 직접 입력: 레지스트리 매칭
// URL 직접 입력: 레지스트리 매칭 (resolvedUrl 우선, cleanUrl 폴백)
const PageComponent = useMemo(() => {
return ADMIN_PAGE_REGISTRY[cleanUrl] || null;
}, [cleanUrl]);
return ADMIN_PAGE_REGISTRY[resolvedUrl] || ADMIN_PAGE_REGISTRY[cleanUrl] || null;
}, [resolvedUrl, cleanUrl]);
if (PageComponent) {
console.log("[AdminPageRenderer] → 레지스트리 매칭:", cleanUrl);
console.log("[AdminPageRenderer] → 레지스트리 매칭:", resolvedUrl || cleanUrl);
return <PageComponent />;
}
@@ -339,6 +403,7 @@ export function AdminPageRenderer({ url }: AdminPageRendererProps) {
}
// 레지스트리/패턴에 없으면 DynamicAdminLoader가 자동 import 시도
console.log("[AdminPageRenderer] → 자동 import 시도:", cleanUrl);
return <DynamicAdminLoader url={cleanUrl} />;
// 회사별 페이지는 resolvedUrl로 import (예: COMPANY_7/sales/order/page)
console.log("[AdminPageRenderer] → 자동 import 시도:", resolvedUrl);
return <DynamicAdminLoader url={resolvedUrl} />;
}
@@ -1507,53 +1507,51 @@ export const V2SplitPanelLayoutConfigPanel: React.FC<V2SplitPanelLayoutConfigPan
</div>
{/* 우측 패널 컬럼 설정 (접이식) */}
{config.rightPanel?.displayMode !== "custom" && (
<Collapsible open={rightColumnsOpen} onOpenChange={setRightColumnsOpen}>
<CollapsibleTrigger asChild>
<button
type="button"
className="bg-muted/20 hover:bg-muted/40 flex w-full items-center justify-between rounded-md border px-3 py-2 text-left transition-colors"
>
<div className="flex items-center gap-2">
<Columns3 className="text-muted-foreground h-3.5 w-3.5" />
<span className="text-xs font-medium">
({config.rightPanel?.columns?.length || 0})
</span>
</div>
<ChevronDown
className={cn(
"text-muted-foreground h-3.5 w-3.5 transition-transform duration-200",
rightColumnsOpen && "rotate-180",
)}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="mt-2 rounded-md border p-3">
{loadingColumns[rightTableName] ? (
<div className="text-muted-foreground flex items-center gap-2 py-4 text-xs">
<Loader2 className="h-3 w-3 animate-spin" />
...
</div>
) : rightTableColumns.length === 0 ? (
<p className="text-muted-foreground py-4 text-center text-xs">
</p>
) : (
<PanelColumnSection
panelKey="rightPanel"
columns={config.rightPanel?.columns}
availableColumns={rightTableColumns}
entityJoinData={rightEntityJoins}
loadingEntityJoins={loadingEntityJoins[rightTableName] || false}
tableName={rightTableName}
onColumnsChange={(columns) => updateRightPanel({ columns })}
/>
)}
<Collapsible open={rightColumnsOpen} onOpenChange={setRightColumnsOpen}>
<CollapsibleTrigger asChild>
<button
type="button"
className="bg-muted/20 hover:bg-muted/40 flex w-full items-center justify-between rounded-md border px-3 py-2 text-left transition-colors"
>
<div className="flex items-center gap-2">
<Columns3 className="text-muted-foreground h-3.5 w-3.5" />
<span className="text-xs font-medium">
({config.rightPanel?.columns?.length || 0})
</span>
</div>
</CollapsibleContent>
</Collapsible>
)}
<ChevronDown
className={cn(
"text-muted-foreground h-3.5 w-3.5 transition-transform duration-200",
rightColumnsOpen && "rotate-180",
)}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="mt-2 rounded-md border p-3">
{loadingColumns[rightTableName] ? (
<div className="text-muted-foreground flex items-center gap-2 py-4 text-xs">
<Loader2 className="h-3 w-3 animate-spin" />
...
</div>
) : rightTableColumns.length === 0 ? (
<p className="text-muted-foreground py-4 text-center text-xs">
</p>
) : (
<PanelColumnSection
panelKey="rightPanel"
columns={config.rightPanel?.columns}
availableColumns={rightTableColumns}
entityJoinData={rightEntityJoins}
loadingEntityJoins={loadingEntityJoins[rightTableName] || false}
tableName={rightTableName}
onColumnsChange={(columns) => updateRightPanel({ columns })}
/>
)}
</div>
</CollapsibleContent>
</Collapsible>
{/* 우측 패널 데이터 필터 (접이식) */}
<Collapsible open={rightFilterOpen} onOpenChange={setRightFilterOpen}>
@@ -2064,6 +2062,51 @@ export const V2SplitPanelLayoutConfigPanel: React.FC<V2SplitPanelLayoutConfigPan
</div>
)}
{/* 탭 컬럼 설정 */}
{tab.tableName && (loadedTableColumns[tab.tableName] || []).length > 0 && (
<Collapsible defaultOpen>
<CollapsibleTrigger asChild>
<button
type="button"
className="bg-muted/20 hover:bg-muted/40 flex w-full items-center justify-between rounded-md border px-3 py-2 text-left transition-colors"
>
<div className="flex items-center gap-2">
<Columns3 className="text-muted-foreground h-3.5 w-3.5" />
<span className="text-xs font-medium">
({tab.columns?.length || 0})
</span>
</div>
<ChevronDown className="text-muted-foreground h-3.5 w-3.5 transition-transform duration-200 [[data-state=open]>&]:rotate-180" />
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="mt-2 rounded-md border p-3">
{loadingColumns[tab.tableName] ? (
<div className="text-muted-foreground flex items-center gap-2 py-4 text-xs">
<Loader2 className="h-3 w-3 animate-spin" />
...
</div>
) : (
<PanelColumnSection
panelKey="rightPanel"
columns={tab.columns}
availableColumns={loadedTableColumns[tab.tableName] || []}
entityJoinData={
entityJoinColumns[tab.tableName] || {
availableColumns: [],
joinTables: [],
}
}
loadingEntityJoins={loadingEntityJoins[tab.tableName] || false}
tableName={tab.tableName}
onColumnsChange={(columns) => updateTab(tabIndex, { columns })}
/>
)}
</div>
</CollapsibleContent>
</Collapsible>
)}
{/* 탭 기능 토글 */}
<div className="space-y-0.5">
<SwitchRow