544 lines
17 KiB
TypeScript
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>
|
|
);
|
|
}
|