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

544 lines
17 KiB
TypeScript

"use client";
import { useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import {
X,
ArrowLeft,
ArrowRight,
Rocket,
Building2,
AlertTriangle,
FileText,
ArrowUpRight,
RefreshCw,
Trash2,
Info,
XOctagon,
} from "lucide-react";
import StepIndicator from "./StepIndicator";
import Step1Basic from "./Step1Basic";
import Step2Template from "./Step2Template";
import Step3Admin from "./Step3Admin";
import Step4Run from "./Step4Run";
type RunDone = {
success: boolean;
subdomain?: string;
companyCode?: string;
error?: string;
} | null;
/**
* 회사 생성 마법사 메인 (시안 v2 포팅).
* 모달로 열림. ESC · 배경 클릭으로 닫기 (진행 중 생성은 경고).
* Step 4 진입하면 Step4Run 이 POST /companies 를 즉시 호출 → 폴링.
* 성공/실패 후 메인 목록 invalidate.
*/
export default function Wizard({ onClose }: { onClose: () => void }) {
const qc = useQueryClient();
const [step, setStep] = useState(1);
const [state, setStateRaw] = useState<Record<string, any>>({ selected_groups: [] });
const [validByStep, setValidByStep] = useState<Record<number, boolean>>({
1: false,
2: true,
3: false,
4: false,
});
const [runDone, setRunDone] = useState<RunDone>(null);
function setState(patch: Record<string, any>) {
setStateRaw((s) => ({ ...s, ...patch }));
}
function mark(n: number, v: boolean) {
setValidByStep((x) => (x[n] === v ? x : { ...x, [n]: v }));
}
const canNext = step < 4 && validByStep[step];
function next() {
if (step < 4 && canNext) setStep((s) => s + 1);
}
function prev() {
if (step > 1 && step < 4) setStep((s) => s - 1);
}
function tryClose() {
if (step === 4 && !runDone) {
if (
!window.confirm(
"생성 작업이 진행 중입니다. 창을 닫아도 서버에서는 계속 진행되지만, 이 화면은 다시 볼 수 없습니다. 닫을까요?",
)
)
return;
}
if (runDone?.success) qc.invalidateQueries({ queryKey: ["companies-stats"] });
onClose();
}
return (
<div
role="dialog"
aria-modal="true"
onClick={(e) => {
if (e.target === e.currentTarget) tryClose();
}}
className="wiz-overlay"
style={{
position: "fixed",
inset: 0,
zIndex: 100,
background: "rgba(6, 5, 14, 0.55)",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: 24,
}}
>
<style>{`
.spin { animation: cspin 0.8s linear infinite; }
@keyframes cspin { to { transform: rotate(360deg); } }
@keyframes shimmer {
0% { transform: translateX(-40px); }
100% { transform: translateX(calc(100% + 40px)); }
}
@keyframes wizOverlayIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes wizModalIn {
from { opacity: 0; transform: translateY(12px) scale(0.97); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes wizSlideUp {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes wizFadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes wizPulseRing {
0% { box-shadow: 0 0 0 0 rgba(var(--v5-cyan-rgb), 0.25); }
70% { box-shadow: 0 0 0 10px rgba(var(--v5-cyan-rgb), 0); }
100% { box-shadow: 0 0 0 0 rgba(var(--v5-cyan-rgb), 0); }
}
@keyframes wizOrbitSpin {
to { transform: rotate(360deg); }
}
@keyframes wizSweep {
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}
@keyframes wizSuccessPop {
0% { transform: scale(0.4); opacity: 0; }
60% { transform: scale(1.15); opacity: 1; }
100% { transform: scale(1); opacity: 1; }
}
@keyframes wizShake {
0%, 100% { transform: translateX(0); }
20% { transform: translateX(-5px); }
40% { transform: translateX(5px); }
60% { transform: translateX(-3px); }
80% { transform: translateX(3px); }
}
@keyframes wizLogAppear {
from { opacity: 0; transform: translateY(-3px); }
to { opacity: 1; transform: translateY(0); }
}
.wiz-overlay { animation: wizOverlayIn 0.18s ease-out; }
.wiz-modal { animation: wizModalIn 0.28s cubic-bezier(0.16, 1, 0.3, 1); }
.wiz-btn {
transition: transform 0.18s cubic-bezier(0.4,0,0.2,1),
box-shadow 0.18s ease,
background 0.18s ease,
border-color 0.18s ease,
opacity 0.18s ease;
}
.wiz-btn:not(:disabled):hover { transform: translateY(-1px); }
.wiz-btn:not(:disabled):active { transform: translateY(0) scale(0.97); }
.wiz-btn-cyan:not(:disabled):hover {
box-shadow: 0 0 14px rgba(var(--v5-cyan-rgb), 0.3) !important;
}
.wiz-btn-primary:not(:disabled):hover {
box-shadow: 0 0 14px rgba(var(--v5-primary-rgb), 0.28) !important;
}
.wiz-btn-danger:not(:disabled):hover {
box-shadow: 0 0 14px rgba(var(--v5-red-rgb), 0.28) !important;
}
.wiz-btn-secondary:not(:disabled):hover {
background: var(--v5-surface-hover) !important;
border-color: var(--v5-primary) !important;
}
.wiz-btn-ghost:not(:disabled):hover {
color: var(--v5-text) !important;
background: var(--v5-surface-hover) !important;
}
.wiz-iconbtn {
transition: all 0.18s ease;
}
.wiz-iconbtn:hover {
background: var(--v5-surface-hover) !important;
color: var(--v5-text) !important;
border-color: var(--v5-primary) !important;
transform: rotate(90deg);
}
`}</style>
<div
className="wiz-modal"
style={{
width: "min(1260px, 100%)",
height: "min(880px, 100%)",
display: "flex",
flexDirection: "column",
background: "var(--v5-bg)",
borderRadius: 14,
overflow: "hidden",
border: "1px solid var(--v5-border)",
boxShadow: "0 10px 40px rgba(0, 0, 0, 0.3), 0 0 30px rgba(var(--v5-cyan-rgb), 0.08)",
}}
>
{/* 브랜디드 헤더 */}
<div
style={{
display: "flex",
alignItems: "center",
gap: "0.95rem",
padding: "1rem 1.3rem",
background: "var(--v5-surface-solid)",
borderBottom: "1px solid var(--v5-border)",
position: "relative",
}}
>
<div
style={{
width: 38,
height: 38,
borderRadius: 10,
background: "linear-gradient(135deg, var(--v5-cyan), var(--v5-primary))",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "#fff",
boxShadow: "0 0 12px rgba(var(--v5-cyan-rgb), 0.3)",
flexShrink: 0,
}}
>
<Building2 size={20} strokeWidth={1.75} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: "0.72rem",
fontWeight: 700,
letterSpacing: "0.02em",
color: "var(--v5-cyan)",
}}
>
</div>
<div
style={{
fontSize: "1.15rem",
fontWeight: 800,
letterSpacing: "-0.02em",
marginTop: 2,
color: "var(--v5-text)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{(state.company_name || state.subdomain) && (
<span style={{ color: "var(--v5-text-muted)", fontWeight: 400 }}>
{" · "}
{state.company_name || state.subdomain}
</span>
)}
</div>
</div>
<div
style={{
fontSize: "0.75rem",
color: "var(--v5-text-muted)",
letterSpacing: "0.02em",
flexShrink: 0,
fontWeight: 600,
}}
>
</div>
<button
onClick={tryClose}
aria-label="닫기"
className="wiz-iconbtn"
style={{
width: 34,
height: 34,
borderRadius: 8,
border: "1px solid var(--v5-border)",
background: "transparent",
color: "var(--v5-text-sec)",
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
}}
>
<X size={16} />
</button>
{/* glow line */}
<div
style={{
position: "absolute",
left: 0,
right: 0,
bottom: -1,
height: 1,
background:
"linear-gradient(90deg, transparent, var(--v5-cyan), var(--v5-primary), transparent)",
opacity: 0.5,
}}
/>
</div>
<StepIndicator current={step} />
{/* 본문 (스크롤) */}
<div style={{ flex: 1, overflow: "auto", background: "var(--v5-bg)", minHeight: 0 }}>
{step === 1 && <Step1Basic state={state} setState={setState} onValidChange={(v) => mark(1, v)} />}
{step === 2 && <Step2Template state={state} setState={setState} onValidChange={(v) => mark(2, v)} />}
{step === 3 && <Step3Admin state={state} setState={setState} onValidChange={(v) => mark(3, v)} />}
{step === 4 && (
<Step4Run
state={state}
onDone={(r) => {
setRunDone(r);
if (r.success) qc.invalidateQueries({ queryKey: ["companies-stats"] });
}}
/>
)}
</div>
{/* Footer (상황별) */}
<div
style={{
padding: "0.9rem 1.3rem",
borderTop: "1px solid var(--v5-border)",
background: "var(--v5-surface-solid)",
display: "flex",
alignItems: "center",
gap: "0.55rem",
}}
>
{step < 4 && (
<>
<Btn variant="ghost" onClick={tryClose}>
</Btn>
<div style={{ flex: 1, fontSize: "0.75rem", color: "var(--v5-text-muted)", textAlign: "center", fontWeight: 500 }}>
{validByStep[step] ? "다음 단계로 이동 가능" : "필수 항목을 확인하세요"}
</div>
<Btn variant="secondary" icon={<ArrowLeft size={13} strokeWidth={1.75} />} onClick={prev} disabled={step <= 1}>
</Btn>
{step < 3 ? (
<Btn
variant="cyan"
onClick={next}
disabled={!canNext}
icon={<ArrowRight size={13} strokeWidth={1.75} />}
iconRight
>
</Btn>
) : (
<Btn variant="cyan" onClick={next} disabled={!canNext} icon={<Rocket size={13} strokeWidth={1.75} />}>
</Btn>
)}
</>
)}
{step === 4 && runDone?.success && (
<>
<div
style={{
flex: 1,
fontSize: "0.76rem",
color: "rgb(var(--v5-green-rgb))",
fontFamily: "var(--v5-font-mono)",
fontWeight: 600,
}}
>
·
</div>
<Btn icon={<FileText size={13} strokeWidth={1.75} />} disabled soon> </Btn>
<Btn
variant="cyan"
icon={<ArrowUpRight size={13} strokeWidth={1.75} />}
onClick={() => {
if (runDone.subdomain) {
window.open(`http://${runDone.subdomain}.invyone.com`, "_blank");
}
tryClose();
}}
>
</Btn>
</>
)}
{step === 4 && runDone && !runDone.success && (
<>
<div
style={{
flex: 1,
fontSize: "0.76rem",
color: "var(--v5-red)",
fontWeight: 600,
display: "inline-flex",
alignItems: "center",
gap: 6,
}}
>
<AlertTriangle size={14} strokeWidth={1.75} />
· DB
</div>
<Btn variant="danger" icon={<Trash2 size={13} strokeWidth={1.75} />} disabled soon>
(DB )
</Btn>
<Btn variant="cyan" icon={<RefreshCw size={13} strokeWidth={1.75} />} onClick={tryClose}>
</Btn>
</>
)}
{step === 4 && !runDone && (
<>
<div
style={{
flex: 1,
fontSize: "0.75rem",
color: "var(--v5-text-muted)",
display: "inline-flex",
alignItems: "center",
gap: 6,
fontWeight: 500,
}}
>
<Info size={13} strokeWidth={1.75} />
.
</div>
<Btn variant="ghost" icon={<XOctagon size={13} strokeWidth={1.75} />} onClick={tryClose}>
</Btn>
<Btn variant="secondary" disabled>
</Btn>
</>
)}
</div>
</div>
</div>
);
}
function Btn({
children,
onClick,
disabled,
variant = "secondary",
icon,
iconRight,
soon,
}: {
children: React.ReactNode;
onClick?: () => void;
disabled?: boolean;
variant?: "primary" | "secondary" | "ghost" | "cyan" | "danger";
icon?: React.ReactNode;
iconRight?: boolean;
soon?: boolean;
}) {
const styles: Record<string, React.CSSProperties> = {
primary: {
background: "var(--v5-primary)",
color: "#fff",
borderColor: "var(--v5-primary)",
},
cyan: {
background: "var(--v5-cyan)",
color: "#fff",
borderColor: "var(--v5-cyan)",
boxShadow: disabled ? "none" : "0 0 10px rgba(var(--v5-cyan-rgb), 0.15)",
},
secondary: {
background: "var(--v5-surface-solid)",
color: "var(--v5-text)",
borderColor: "var(--v5-border)",
},
ghost: {
background: "transparent",
color: "var(--v5-text-sec)",
borderColor: "transparent",
},
danger: {
background: "var(--v5-red)",
color: "#fff",
borderColor: "var(--v5-red)",
},
};
return (
<button
onClick={disabled ? undefined : onClick}
disabled={disabled}
title={soon ? "준비 중인 기능입니다" : undefined}
className={`wiz-btn wiz-btn-${variant}`}
style={{
display: "inline-flex",
alignItems: "center",
gap: "0.45rem",
height: 36,
padding: "0 0.95rem",
borderRadius: 9,
fontSize: "0.82rem",
fontWeight: 600,
border: "1px solid transparent",
cursor: disabled ? "not-allowed" : "pointer",
whiteSpace: "nowrap",
opacity: disabled ? 0.5 : 1,
fontFamily: "inherit",
...styles[variant],
}}
>
{!iconRight && icon}
{children}
{soon && <SoonBadge />}
{iconRight && icon}
</button>
);
}
function SoonBadge() {
return (
<span
style={{
fontSize: "0.6rem",
fontWeight: 700,
padding: "2px 6px",
borderRadius: 3,
background: "rgba(var(--v5-amber-rgb), 0.15)",
color: "var(--v5-amber)",
letterSpacing: "0.03em",
marginLeft: 2,
}}
>
</span>
);
}