Files
invyone/frontend/components/admin/provisioning/wizard/Step2Template.tsx
T
gbpark 8c861144dc
Build & Deploy to K8s / build-and-deploy (push) Successful in 6s
서브도메인 배포 작업
2026-04-24 17:49:16 +09:00

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>
);
}