441 lines
13 KiB
TypeScript
441 lines
13 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState } from "react";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import {
|
|
Check,
|
|
Lock,
|
|
ChevronDown,
|
|
ChevronUp,
|
|
Monitor,
|
|
Sliders,
|
|
CalendarClock,
|
|
Workflow,
|
|
Shield,
|
|
Database,
|
|
} from "lucide-react";
|
|
import { getTableGroups } from "@/lib/api/provisioning";
|
|
|
|
/**
|
|
* Step 2 · 템플릿 그룹 선택 (시안 v2 포팅).
|
|
* 왼쪽: 카드 리스트 (필수 / 선택 subheader 로 분리, 각 카드에 아이콘·체크·확장)
|
|
* 오른쪽: SUMMARY 패널 (선택된 그룹 · 복제될 테이블 · 예상 레코드 · 예상 소요 시간)
|
|
*
|
|
* 로직 (유지):
|
|
* - /table-groups 로드
|
|
* - g.required 는 서버가 강제하므로 UI 도 disabled + 항상 체크
|
|
* - selected_groups 에는 선택 그룹(required=false) 만 저장
|
|
* - valid = true 항상
|
|
*/
|
|
export default function Step2Template({
|
|
state,
|
|
setState,
|
|
onValidChange,
|
|
}: {
|
|
state: Record<string, any>;
|
|
setState: (patch: Record<string, any>) => void;
|
|
onValidChange: (valid: boolean) => void;
|
|
}) {
|
|
const { data: groups = [], isLoading } = useQuery({
|
|
queryKey: ["provisioning-table-groups"],
|
|
queryFn: getTableGroups,
|
|
staleTime: 60_000,
|
|
});
|
|
const [expanded, setExpanded] = useState<string | null>(null);
|
|
const selected: string[] = state.selected_groups || [];
|
|
|
|
useEffect(() => {
|
|
if (!state.selected_groups) setState({ selected_groups: [] });
|
|
}, []); // eslint-disable-line
|
|
|
|
useEffect(() => {
|
|
onValidChange(true);
|
|
}, [onValidChange]);
|
|
|
|
function toggle(id: string, required: boolean) {
|
|
if (required) return;
|
|
const next = selected.includes(id) ? selected.filter((x) => x !== id) : [...selected, id];
|
|
setState({ selected_groups: next });
|
|
}
|
|
|
|
const required = groups.filter((g: any) => g.required);
|
|
const optional = groups.filter((g: any) => !g.required);
|
|
const selectedGroups = groups.filter((g: any) => g.required || selected.includes(g.id));
|
|
const totalTables = selectedGroups.reduce(
|
|
(a: number, g: any) => a + (Array.isArray(g.tables) ? g.tables.length : 0),
|
|
0,
|
|
);
|
|
const totalRows = selectedGroups.reduce((a: number, g: any) => a + (Number(g.count) || 0), 0);
|
|
const estimatedSec = 10 + selectedGroups.length * 3;
|
|
|
|
return (
|
|
<div
|
|
className="wiz-step"
|
|
style={{
|
|
padding: "1.6rem 1.8rem",
|
|
display: "grid",
|
|
gridTemplateColumns: "1fr 320px",
|
|
gap: "1.8rem",
|
|
animation: "wizSlideUp 0.35s cubic-bezier(0.16, 1, 0.3, 1)",
|
|
}}
|
|
>
|
|
<div>
|
|
<div
|
|
style={{
|
|
fontSize: "0.72rem",
|
|
fontWeight: 700,
|
|
letterSpacing: "0.18em",
|
|
textTransform: "uppercase",
|
|
color: "var(--v5-cyan)",
|
|
marginBottom: 8,
|
|
fontFamily: "var(--v5-font-mono)",
|
|
}}
|
|
>
|
|
02 · 템플릿 선택
|
|
</div>
|
|
<h2 style={{ fontSize: "1.4rem", fontWeight: 800, letterSpacing: "-0.02em", margin: 0, color: "var(--v5-text)" }}>
|
|
복사할 기준 데이터
|
|
</h2>
|
|
<div style={{ fontSize: "0.85rem", color: "var(--v5-text-sec)", marginTop: 6, maxWidth: 620, fontWeight: 500 }}>
|
|
기본 회사(INVION_DEFAULT) 의 기준 데이터 중 새 회사에 복제할 항목을 선택합니다.
|
|
<b style={{ color: "var(--v5-text)" }}> 필수 그룹</b>은 해제할 수 없습니다.
|
|
</div>
|
|
|
|
{isLoading && (
|
|
<div style={{ padding: "1.2rem", color: "var(--v5-text-muted)", fontSize: "0.82rem" }}>로딩 중...</div>
|
|
)}
|
|
|
|
<div style={{ marginTop: "1.3rem", display: "flex", flexDirection: "column", gap: "0.7rem" }}>
|
|
{required.length > 0 && (
|
|
<>
|
|
<SubHead>필수 (해제 불가)</SubHead>
|
|
{required.map((g: any) => (
|
|
<TemplateCard
|
|
key={g.id}
|
|
g={g}
|
|
checked
|
|
onToggle={() => {}}
|
|
expanded={expanded === g.id}
|
|
onExpand={() => setExpanded(expanded === g.id ? null : g.id)}
|
|
/>
|
|
))}
|
|
</>
|
|
)}
|
|
{optional.length > 0 && (
|
|
<>
|
|
<SubHead style={{ paddingTop: 10 }}>선택</SubHead>
|
|
{optional.map((g: any) => (
|
|
<TemplateCard
|
|
key={g.id}
|
|
g={g}
|
|
checked={selected.includes(g.id)}
|
|
onToggle={() => toggle(g.id, false)}
|
|
expanded={expanded === g.id}
|
|
onExpand={() => setExpanded(expanded === g.id ? null : g.id)}
|
|
/>
|
|
))}
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 요약 패널 */}
|
|
<div style={{ position: "sticky", top: 0, alignSelf: "start" }}>
|
|
<div
|
|
style={{
|
|
padding: "1.15rem 1.2rem",
|
|
background: "var(--v5-surface-solid)",
|
|
border: "1px solid var(--v5-border)",
|
|
borderRadius: 11,
|
|
boxShadow: "0 0 12px rgba(var(--v5-cyan-rgb), 0.04)",
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
fontSize: "0.68rem",
|
|
fontWeight: 700,
|
|
letterSpacing: "0.2em",
|
|
textTransform: "uppercase",
|
|
color: "var(--v5-cyan)",
|
|
marginBottom: "0.85rem",
|
|
fontFamily: "var(--v5-font-mono)",
|
|
}}
|
|
>
|
|
요약
|
|
</div>
|
|
<SummaryRow
|
|
label="선택된 그룹"
|
|
value={`${selectedGroups.length} / ${groups.length}`}
|
|
accent="cyan"
|
|
isLast={false}
|
|
/>
|
|
<SummaryRow label="복제될 테이블" value={totalTables} mono isLast={false} />
|
|
<SummaryRow label="예상 레코드" value={`${totalRows.toLocaleString()}건`} mono isLast={false} />
|
|
<SummaryRow label="예상 소요 시간" value={`약 ${estimatedSec}초`} mono isLast />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ─── 카드 ─────────────────────────────────────────────── */
|
|
|
|
const ICON_MAP: Record<string, React.ComponentType<{ size?: number; strokeWidth?: number }>> = {
|
|
screen: Monitor,
|
|
control: Sliders,
|
|
batch: CalendarClock,
|
|
dataflow: Workflow,
|
|
authmenu: Shield,
|
|
menu: Shield,
|
|
auth: Shield,
|
|
};
|
|
|
|
function TemplateCard({
|
|
g,
|
|
checked,
|
|
onToggle,
|
|
expanded,
|
|
onExpand,
|
|
}: {
|
|
g: Record<string, any>;
|
|
checked: boolean;
|
|
onToggle: () => void;
|
|
expanded: boolean;
|
|
onExpand: () => void;
|
|
}) {
|
|
const locked = !!g.required;
|
|
const Ic = ICON_MAP[g.id] || ICON_MAP[g.key] || Database;
|
|
const tables: string[] = Array.isArray(g.tables) ? g.tables : [];
|
|
const meta: string[] = [];
|
|
meta.push(`${tables.length}개 테이블`);
|
|
if (g.count != null) meta.push(`${Number(g.count).toLocaleString()}건`);
|
|
if (g.size) meta.push(String(g.size));
|
|
|
|
return (
|
|
<div
|
|
className="wiz-tplcard"
|
|
style={{
|
|
border: `1px solid ${checked ? "rgba(var(--v5-cyan-rgb), 0.4)" : "var(--v5-border)"}`,
|
|
borderRadius: 11,
|
|
background: checked ? "rgba(var(--v5-cyan-rgb), 0.04)" : "var(--v5-surface-solid)",
|
|
transition: "transform 0.22s cubic-bezier(0.4,0,0.2,1), border-color 0.22s ease, background 0.22s ease, box-shadow 0.22s ease",
|
|
overflow: "hidden",
|
|
boxShadow: checked ? "0 0 10px rgba(var(--v5-cyan-rgb), 0.07)" : "none",
|
|
}}
|
|
>
|
|
<div
|
|
onClick={() => !locked && onToggle()}
|
|
style={{
|
|
padding: "1rem 1.15rem",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: "0.85rem",
|
|
cursor: locked ? "not-allowed" : "pointer",
|
|
}}
|
|
>
|
|
{/* checkbox */}
|
|
<div
|
|
style={{
|
|
width: 22,
|
|
height: 22,
|
|
borderRadius: 5,
|
|
border: `1.5px solid ${checked ? "var(--v5-cyan)" : "var(--v5-border)"}`,
|
|
background: checked ? "var(--v5-cyan)" : "transparent",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
color: "#fff",
|
|
flexShrink: 0,
|
|
boxShadow: checked ? "0 0 6px rgba(var(--v5-cyan-rgb), 0.25)" : "none",
|
|
opacity: locked ? 0.7 : 1,
|
|
transition: "all 0.2s ease",
|
|
}}
|
|
>
|
|
{checked && <Check size={14} strokeWidth={3} />}
|
|
</div>
|
|
|
|
{/* icon tile */}
|
|
<div
|
|
style={{
|
|
width: 38,
|
|
height: 38,
|
|
borderRadius: 9,
|
|
background: checked ? "rgba(var(--v5-cyan-rgb), 0.12)" : "var(--v5-bg-subtle)",
|
|
color: checked ? "var(--v5-cyan)" : "var(--v5-text-muted)",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
flexShrink: 0,
|
|
transition: "all 0.22s ease",
|
|
}}
|
|
>
|
|
<Ic size={18} strokeWidth={1.75} />
|
|
</div>
|
|
|
|
{/* label */}
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<div style={{ display: "flex", alignItems: "center", gap: 7 }}>
|
|
<span style={{ fontSize: "0.95rem", fontWeight: 700, color: "var(--v5-text)", letterSpacing: "-0.01em" }}>
|
|
{g.label || g.id}
|
|
</span>
|
|
{locked && (
|
|
<span
|
|
title="필수 템플릿입니다"
|
|
style={{
|
|
fontSize: "0.62rem",
|
|
fontWeight: 700,
|
|
padding: "0.15rem 0.5rem",
|
|
borderRadius: 999,
|
|
background: "rgba(var(--v5-red-rgb), 0.1)",
|
|
color: "var(--v5-red)",
|
|
letterSpacing: "0.08em",
|
|
display: "inline-flex",
|
|
alignItems: "center",
|
|
gap: 3,
|
|
}}
|
|
>
|
|
<Lock size={10} /> 필수
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div
|
|
style={{
|
|
fontSize: "0.72rem",
|
|
color: "var(--v5-text-muted)",
|
|
fontFamily: "var(--v5-font-mono)",
|
|
marginTop: 3,
|
|
fontWeight: 500,
|
|
}}
|
|
>
|
|
{meta.join(" · ")}
|
|
</div>
|
|
</div>
|
|
|
|
{tables.length > 0 && (
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onExpand();
|
|
}}
|
|
aria-label={expanded ? "접기" : "펼치기"}
|
|
className="wiz-chevron"
|
|
style={{
|
|
width: 30,
|
|
height: 30,
|
|
border: "1px solid var(--v5-border)",
|
|
borderRadius: 7,
|
|
background: "transparent",
|
|
color: "var(--v5-text-muted)",
|
|
cursor: "pointer",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
transition: "all 0.2s ease",
|
|
}}
|
|
>
|
|
{expanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
<div
|
|
style={{
|
|
display: "grid",
|
|
gridTemplateRows: expanded && tables.length > 0 ? "1fr" : "0fr",
|
|
transition: "grid-template-rows 0.3s cubic-bezier(0.4,0,0.2,1)",
|
|
}}
|
|
>
|
|
<div style={{ overflow: "hidden" }}>
|
|
{tables.length > 0 && (
|
|
<div
|
|
style={{
|
|
padding: "0.8rem 1.15rem 1rem 3.85rem",
|
|
borderTop: "1px solid var(--v5-border)",
|
|
background: "var(--v5-bg)",
|
|
display: "flex",
|
|
flexWrap: "wrap",
|
|
gap: 5,
|
|
}}
|
|
>
|
|
{tables.map((t) => (
|
|
<span
|
|
key={t}
|
|
style={{
|
|
padding: "0.22rem 0.55rem",
|
|
fontFamily: "var(--v5-font-mono)",
|
|
fontSize: "0.72rem",
|
|
background: "var(--v5-surface-solid)",
|
|
border: "1px solid var(--v5-border)",
|
|
borderRadius: 5,
|
|
color: "var(--v5-text-sec)",
|
|
fontWeight: 500,
|
|
}}
|
|
>
|
|
{t}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SubHead({ children, style }: { children: React.ReactNode; style?: React.CSSProperties }) {
|
|
return (
|
|
<div
|
|
style={{
|
|
fontSize: "0.68rem",
|
|
fontWeight: 700,
|
|
letterSpacing: "0.15em",
|
|
textTransform: "uppercase",
|
|
color: "var(--v5-text-muted)",
|
|
padding: "6px 0",
|
|
fontFamily: "var(--v5-font-mono)",
|
|
...style,
|
|
}}
|
|
>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SummaryRow({
|
|
label,
|
|
value,
|
|
mono,
|
|
accent,
|
|
isLast,
|
|
}: {
|
|
label: string;
|
|
value: React.ReactNode;
|
|
mono?: boolean;
|
|
accent?: "cyan";
|
|
isLast: boolean;
|
|
}) {
|
|
return (
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
justifyContent: "space-between",
|
|
alignItems: "baseline",
|
|
padding: "0.55rem 0",
|
|
borderBottom: isLast ? "none" : "1px solid var(--v5-border-subtle, rgba(0,0,0,0.06))",
|
|
}}
|
|
>
|
|
<span style={{ fontSize: "0.78rem", color: "var(--v5-text-muted)", fontWeight: 500 }}>{label}</span>
|
|
<span
|
|
style={{
|
|
fontSize: "0.92rem",
|
|
fontWeight: 700,
|
|
fontFamily: mono ? "var(--v5-font-mono)" : "inherit",
|
|
color: accent === "cyan" ? "var(--v5-cyan)" : "var(--v5-text)",
|
|
}}
|
|
>
|
|
{value}
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|