Files
invyone/frontend/components/admin/provisioning/wizard/Step4Run.tsx
T
gbpark 68f85f3736 회사 관리 기능 확장 + 테넌트/비번 보안 하드닝
- 첫 로그인 비번 강제 변경 (RUN_082): FORCE_PASSWORD_CHANGE 컬럼,
  ForcePasswordChangeGuardFilter, /auth/change-password API + 페이지
- 테넌트 일관성 가드: TenantConsistencyGuardFilter 로 JWT.company_code
  ↔ 서브도메인 company_code 대조, CompanyResolver 가 (db_name, company_code)
  동시 반환
- 회사 관리 확장 (RUN_083 audit log, RUN_084 lifecycle 컬럼):
  CompanyAdmin/Members/Templates/Lifecycle/AuditLog 서비스 +
  CompanyMgmtController + SuperAdminGuard
- 회사 관리 UI: CompanyAccordionRow 탭화 + 모달 4종
  (AdminInfo/Deactivate/Delete/RecopyTemplates) + AuditLogDrawer + csvExport
- 프로비저닝 마법사: force_password_change 토글 반영
- 프론트 인증: storage 이벤트 멀티탭 동기화, 403 errorCode
  (PASSWORD_CHANGE_REQUIRED / CROSS_TENANT_REJECTED / TENANT_NOT_RESOLVED)
  전역 리다이렉트
- 기타: StartupSchemaMigrator, OS별 도커 기동 스크립트, CLAUDE.md 트래킹

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 00:36:05 +09:00

914 lines
31 KiB
TypeScript

"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { Check, X, AlertOctagon, Rocket, Sparkles, Terminal, Zap } from "lucide-react";
import { createCompany, getProvisioningStatus, CreateCompanyRequest } from "@/lib/api/provisioning";
/**
* Step 4 · 생성 진행 — 대대적 개편.
* 좌측: 중앙 pulsing orb + 진행률 + 단계 타임라인 + 결과 배너
* 우측: 터미널 로그 (auto-scroll)
*
* 로직 (유지):
* - 마운트 즉시 POST /companies → jobId 취득 → 폴링 시작
* - 폴링 결과 status.currentStep 변화에 맞춰 단계 state 갱신
* - onDone 으로 최종 결과 상위에 통보
*/
const DISPLAY_STEPS: { key: string; label: string; sub: string; sec: number }[] = [
{ key: "CREATE_DATABASE", label: "DB 생성", sub: "CREATE DATABASE '%db%'", sec: 2 },
{ key: "COPY_SCHEMA", label: "스키마 복제", sub: "pg_dump --schema-only | psql", sec: 5 },
{ key: "COPY_DATA", label: "템플릿 데이터 복사", sub: "선택된 그룹의 공통 데이터", sec: 12 },
{ key: "CREATE_ADMIN", label: "관리자 계정 생성", sub: "BCrypt · user_info", sec: 3 },
{ key: "FINALIZE", label: "마무리", sub: "db_status=active · 캐시 무효화", sec: 2 },
];
type RowStatus = "pending" | "running" | "done" | "failed";
export default function Step4Run({
state,
onDone,
}: {
state: Record<string, any>;
onDone: (result: { success: boolean; subdomain?: string; companyCode?: string; error?: string }) => void;
}) {
const [jobId, setJobId] = useState<string | null>(null);
const [status, setStatus] = useState<Record<string, any>>({
status: "in_progress",
currentStep: "",
progress: 0,
});
const [createError, setCreateError] = useState<string | null>(null);
const [log, setLog] = useState<string[]>([]);
const [startedAt] = useState<number>(() => Date.now());
const pollRef = useRef<NodeJS.Timeout | null>(null);
const startedRef = useRef(false);
const prevStepRef = useRef<string>("");
const logScrollRef = useRef<HTMLDivElement>(null);
function appendLog(line: string) {
const hhmmss = new Date().toTimeString().slice(0, 8);
setLog((lg) => [...lg, `[${hhmmss}] ${line}`]);
}
// 로그 auto-scroll
useEffect(() => {
const el = logScrollRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [log]);
useEffect(() => {
if (startedRef.current) return;
startedRef.current = true;
appendLog(`▸ START provisioning · ${state.company_code || "—"}`);
appendLog(`· subdomain=${state.subdomain}`);
appendLog(`· db_prefix=${state.db_prefix}`);
(async () => {
try {
const payload: CreateCompanyRequest = {
company_code: state.company_code,
company_name: state.company_name,
subdomain: state.subdomain,
db_prefix: state.db_prefix,
business_registration_number: state.business_registration_number,
representative_name: state.representative_name,
representative_phone: state.representative_phone,
email: state.email,
website: state.website,
address: state.address,
selected_groups: state.selected_groups || [],
initial_password: state.initial_password,
force_password_change: state.force_password_change !== false,
};
const resp = await createCompany(payload);
setJobId(resp.provisioning_id);
appendLog(`✓ accepted · job_id=${resp.provisioning_id}`);
} catch (e: any) {
const msg = e?.response?.data?.message || e?.response?.data?.error || e?.message || "요청 실패";
setCreateError(msg);
appendLog(`✗ FAILED · ${msg}`);
onDone({ success: false, error: msg });
}
})();
return () => {
if (pollRef.current) clearTimeout(pollRef.current);
};
// eslint-disable-next-line
}, []);
useEffect(() => {
if (!jobId) return;
let cancelled = false;
async function tick() {
try {
const s = await getProvisioningStatus(jobId!);
if (cancelled) return;
setStatus(s);
if (s.currentStep && s.currentStep !== prevStepRef.current) {
const disp = DISPLAY_STEPS.find((d) => d.key === s.currentStep);
appendLog(`${s.currentStep}${disp ? ` · ${disp.label}` : ""}`);
prevStepRef.current = s.currentStep;
}
if (s.status === "completed") {
appendLog(`━ READY · https://${s.subdomain || state.subdomain}.invyone.com`);
onDone({ success: true, subdomain: s.subdomain || state.subdomain, companyCode: s.companyCode });
return;
}
if (s.status === "failed") {
appendLog(`✗ FAILED · ${s.failedStep || "—"} · ${s.errorMessage || "—"}`);
onDone({ success: false, error: s.errorMessage || "프로비저닝 실패" });
return;
}
} catch {
// 네트워크 일시 장애 → 다음 틱에서 재시도
}
pollRef.current = setTimeout(tick, 2000);
}
tick();
return () => {
cancelled = true;
if (pollRef.current) clearTimeout(pollRef.current);
};
// eslint-disable-next-line
}, [jobId]);
const current = status?.currentStep as string | undefined;
const failed = status?.status === "failed";
const done = status?.status === "completed";
function rowStatus(key: string): RowStatus {
if (!current && !done && !failed) return "pending";
if (done) return "done";
const idx = DISPLAY_STEPS.findIndex((s) => s.key === key);
const curIdx = DISPLAY_STEPS.findIndex((s) => s.key === current);
if (failed && status.failedStep === key) return "failed";
if (idx < curIdx) return "done";
if (idx === curIdx) return failed ? "failed" : "running";
return "pending";
}
const progress: number = useMemo(() => {
if (done) return 100;
if (typeof status?.progress === "number") return Math.max(0, Math.min(100, Math.round(status.progress)));
if (!current) return 0;
const curIdx = DISPLAY_STEPS.findIndex((s) => s.key === current);
if (curIdx < 0) return 0;
return Math.round(((curIdx + 0.5) / DISPLAY_STEPS.length) * 100);
}, [done, current, status?.progress]);
const elapsedSec = Math.max(0, Math.round((Date.now() - startedAt) / 1000));
const variant: "running" | "success" | "failed" = failed ? "failed" : done ? "success" : "running";
const barLabel = variant === "success" ? "완료" : variant === "failed" ? "중단" : "진행 중";
const accent =
variant === "success" ? "rgb(var(--v5-green-rgb))" : variant === "failed" ? "var(--v5-red)" : "var(--v5-cyan)";
const accentRgb =
variant === "success" ? "var(--v5-green-rgb)" : variant === "failed" ? "var(--v5-red-rgb)" : "var(--v5-cyan-rgb)";
return (
<div
className="wiz-step"
style={{
padding: "1.6rem 1.8rem",
display: "grid",
gridTemplateColumns: "1fr 360px",
gap: "1.8rem",
animation: "wizSlideUp 0.35s cubic-bezier(0.16, 1, 0.3, 1)",
}}
>
<style>{`
@keyframes s4Orb {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.05); }
}
@keyframes s4OrbGlow {
0%, 100% { opacity: 0.7; transform: scale(1); }
50% { opacity: 1; transform: scale(1.15); }
}
@keyframes s4OrbRing1 {
0% { transform: translate(-50%, -50%) rotate(0deg); }
100% { transform: translate(-50%, -50%) rotate(360deg); }
}
@keyframes s4OrbRing2 {
0% { transform: translate(-50%, -50%) rotate(360deg); }
100% { transform: translate(-50%, -50%) rotate(0deg); }
}
@keyframes s4StepIn {
from { opacity: 0; transform: translateX(-8px); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes s4SuccessBurst {
0% { transform: scale(0.2); opacity: 0; }
60% { transform: scale(1.2); opacity: 1; }
100% { transform: scale(1); opacity: 1; }
}
@keyframes s4SuccessRay {
0% { opacity: 0; transform: scaleY(0); }
50% { opacity: 0.9; transform: scaleY(1); }
100% { opacity: 0; transform: scaleY(1); }
}
@keyframes s4Shake {
0%, 100% { transform: translateX(0); }
20% { transform: translateX(-4px); }
40% { transform: translateX(4px); }
60% { transform: translateX(-3px); }
80% { transform: translateX(3px); }
}
@keyframes s4Sweep {
0% { left: -40%; }
100% { left: 100%; }
}
@keyframes s4LogLine {
from { opacity: 0; transform: translateY(-2px); }
to { opacity: 1; transform: translateY(0); }
}
.s4-step-row {
animation: s4StepIn 0.35s cubic-bezier(0.16, 1, 0.3, 1) both;
}
.s4-log-line {
animation: s4LogLine 0.2s ease-out both;
}
.s4-shake {
animation: s4Shake 0.5s cubic-bezier(0.36, 0.07, 0.19, 0.97);
}
`}</style>
<div>
<div
style={{
fontSize: "0.72rem",
fontWeight: 700,
letterSpacing: "0.18em",
textTransform: "uppercase",
color: accent,
marginBottom: 8,
fontFamily: "var(--v5-font-mono)",
}}
>
04 ·
</div>
<h2
style={{
fontSize: "1.4rem",
fontWeight: 800,
letterSpacing: "-0.02em",
margin: 0,
color: "var(--v5-text)",
}}
>
{variant === "success" ? "회사 생성 완료" : variant === "failed" ? "생성 실패" : "회사 생성 중"}
</h2>
<div style={{ fontSize: "0.85rem", color: "var(--v5-text-sec)", marginTop: 6, fontWeight: 500, lineHeight: 1.55 }}>
{variant === "success" && (
<>
<b style={{ color: "rgb(var(--v5-green-rgb))" }}>
{state.subdomain}.invyone.com
</b>{" "}
. URL .
</>
)}
{variant === "failed" && <> . / .</>}
{variant === "running" && <> .</>}
</div>
{createError && (
<div style={{ marginTop: 8, fontSize: "0.78rem", color: "var(--v5-red)", fontFamily: "var(--v5-font-mono)" }}>
{createError}
</div>
)}
{/* ── 중앙 Orb + 큰 진행 바 ── */}
<div
className={variant === "failed" ? "s4-shake" : ""}
style={{
marginTop: "1.3rem",
padding: "1.6rem 1.4rem 1.25rem",
border: "1px solid var(--v5-border)",
borderRadius: 14,
background:
variant === "running"
? "linear-gradient(180deg, rgba(var(--v5-cyan-rgb), 0.04), var(--v5-surface-solid))"
: variant === "success"
? "linear-gradient(180deg, rgba(var(--v5-green-rgb), 0.05), var(--v5-surface-solid))"
: "linear-gradient(180deg, rgba(var(--v5-red-rgb), 0.04), var(--v5-surface-solid))",
boxShadow: `0 0 22px rgba(${accentRgb}, 0.08)`,
position: "relative",
overflow: "hidden",
}}
>
{/* Orb */}
<div
style={{
position: "relative",
height: 140,
display: "flex",
alignItems: "center",
justifyContent: "center",
marginBottom: "1.1rem",
}}
>
{/* outer glow */}
<div
style={{
position: "absolute",
top: "50%",
left: "50%",
width: 180,
height: 180,
borderRadius: "50%",
transform: "translate(-50%, -50%)",
background: `radial-gradient(circle, rgba(${accentRgb}, 0.14) 0%, rgba(${accentRgb}, 0) 70%)`,
animation: variant === "running" ? "s4OrbGlow 2.4s ease-in-out infinite" : "none",
pointerEvents: "none",
}}
/>
{/* rotating ring (single solid arc) */}
{variant === "running" && (
<div
style={{
position: "absolute",
top: "50%",
left: "50%",
width: 110,
height: 110,
borderRadius: "50%",
border: "1.5px solid rgba(var(--v5-cyan-rgb), 0.12)",
borderTopColor: "rgba(var(--v5-cyan-rgb), 0.85)",
animation: "s4OrbRing2 2.4s linear infinite",
pointerEvents: "none",
}}
/>
)}
{/* success rays */}
{variant === "success" &&
Array.from({ length: 8 }).map((_, i) => (
<div
key={i}
style={{
position: "absolute",
top: "50%",
left: "50%",
width: 2,
height: 46,
marginLeft: -1,
marginTop: -46,
background: "linear-gradient(to top, rgba(var(--v5-green-rgb), 0.7), transparent)",
transformOrigin: "bottom center",
transform: `rotate(${i * 45}deg)`,
animation: `s4SuccessRay 1.2s ease-out ${0.15 + i * 0.04}s both`,
pointerEvents: "none",
}}
/>
))}
{/* core */}
<div
style={{
position: "relative",
width: 78,
height: 78,
borderRadius: "50%",
background:
variant === "success"
? "linear-gradient(135deg, rgb(var(--v5-green-rgb)), #5de6b5)"
: variant === "failed"
? "linear-gradient(135deg, var(--v5-red), #ff6b6b)"
: "linear-gradient(135deg, var(--v5-cyan), var(--v5-primary))",
boxShadow: `0 0 18px rgba(${accentRgb}, 0.4), inset 0 -4px 10px rgba(0, 0, 0, 0.18)`,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "#fff",
animation:
variant === "running"
? "s4Orb 2.4s ease-in-out infinite"
: variant === "success"
? "s4SuccessBurst 0.6s cubic-bezier(0.34, 1.56, 0.64, 1) both"
: "none",
}}
>
{variant === "success" ? (
<Check size={36} strokeWidth={2.5} />
) : variant === "failed" ? (
<X size={36} strokeWidth={2.5} />
) : (
<Rocket size={30} strokeWidth={1.75} />
)}
</div>
</div>
{/* 진행 바 */}
<div
style={{
display: "flex",
alignItems: "baseline",
justifyContent: "space-between",
marginBottom: "0.55rem",
}}
>
<div
style={{
fontSize: "0.72rem",
fontWeight: 700,
letterSpacing: "0.15em",
textTransform: "uppercase",
color: "var(--v5-text-muted)",
fontFamily: "var(--v5-font-mono)",
display: "inline-flex",
alignItems: "center",
gap: 6,
}}
>
{variant === "running" && (
<span
style={{
width: 7,
height: 7,
borderRadius: "50%",
background: accent,
boxShadow: `0 0 4px ${accent}`,
animation: "pulsedot 1.4s ease-in-out infinite",
}}
/>
)}
{barLabel}
</div>
<div
style={{
fontSize: "1.75rem",
fontWeight: 800,
letterSpacing: "-0.03em",
fontFamily: "var(--v5-font-mono)",
color: accent,
fontVariantNumeric: "tabular-nums",
}}
>
{progress}
<span style={{ fontSize: "0.9rem", color: "var(--v5-text-muted)", marginLeft: 1 }}>%</span>
</div>
</div>
<div
style={{
height: 8,
borderRadius: 4,
background: "var(--v5-bg-subtle)",
overflow: "hidden",
position: "relative",
border: "1px solid var(--v5-border)",
}}
>
<div
style={{
height: "100%",
width: `${progress}%`,
background:
variant === "success"
? "linear-gradient(90deg, rgb(var(--v5-green-rgb)), #5de6b5)"
: variant === "failed"
? "linear-gradient(90deg, var(--v5-red), #ff6b6b)"
: "linear-gradient(90deg, var(--v5-cyan), var(--v5-primary))",
boxShadow: `0 0 8px rgba(${accentRgb}, 0.35)`,
transition: "width 0.5s cubic-bezier(0.4, 0, 0.2, 1)",
position: "relative",
}}
/>
{variant === "running" && (
<div
style={{
position: "absolute",
top: 0,
bottom: 0,
width: "40%",
background: "linear-gradient(90deg, transparent, rgba(255,255,255,0.45), transparent)",
animation: "s4Sweep 1.6s ease-in-out infinite",
pointerEvents: "none",
}}
/>
)}
</div>
<div
style={{
marginTop: "0.7rem",
display: "flex",
justifyContent: "space-between",
fontFamily: "var(--v5-font-mono)",
fontSize: "0.72rem",
color: "var(--v5-text-muted)",
fontWeight: 500,
}}
>
<span> · {jobId || "—"}</span>
<span>
{elapsedSec}s
{variant === "running" && " · 진행 중"}
{variant === "success" && " · 완료"}
{variant === "failed" && " · 실패"}
</span>
</div>
</div>
{/* 단계 타임라인 */}
<div
style={{
marginTop: "1.1rem",
padding: "0.5rem 1.2rem",
border: "1px solid var(--v5-border)",
borderRadius: 11,
background: "var(--v5-surface-solid)",
}}
>
{DISPLAY_STEPS.map((s, i) => (
<RunRow
key={s.key}
step={s}
idx={i}
status={rowStatus(s.key)}
isLast={i === DISPLAY_STEPS.length - 1}
dbName={`${state.db_prefix || "___"}_invyone`}
/>
))}
</div>
{/* 실패 에러 메시지 */}
{failed && (
<div
style={{
marginTop: "1.1rem",
padding: "1rem 1.15rem",
border: "1px solid rgba(var(--v5-red-rgb), 0.35)",
background: "rgba(var(--v5-red-rgb), 0.05)",
borderRadius: 11,
}}
>
<div
style={{
fontSize: "0.75rem",
fontWeight: 700,
color: "var(--v5-red)",
letterSpacing: "0.12em",
textTransform: "uppercase",
marginBottom: 8,
display: "flex",
alignItems: "center",
gap: 7,
fontFamily: "var(--v5-font-mono)",
}}
>
<AlertOctagon size={14} /> · {status?.failedStep || "—"}
</div>
<div
style={{
fontFamily: "var(--v5-font-mono)",
fontSize: "0.82rem",
padding: "0.7rem 0.8rem",
background: "var(--v5-bg)",
borderRadius: 7,
border: "1px solid var(--v5-border)",
color: "var(--v5-text)",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
overflowX: "auto",
fontWeight: 500,
}}
>
{status?.errorMessage || createError || "—"}
</div>
</div>
)}
{/* 성공 접속 카드 */}
{done && (
<div
style={{
marginTop: "1.1rem",
padding: "1.15rem 1.3rem",
border: "1px solid rgba(var(--v5-green-rgb), 0.4)",
background: "linear-gradient(135deg, rgba(var(--v5-green-rgb), 0.08), rgba(var(--v5-green-rgb), 0.03))",
borderRadius: 12,
boxShadow: "0 0 16px rgba(var(--v5-green-rgb), 0.08)",
display: "flex",
alignItems: "center",
gap: "1rem",
animation: "s4SuccessBurst 0.5s cubic-bezier(0.34, 1.56, 0.64, 1) both 0.2s",
}}
>
<div
style={{
width: 48,
height: 48,
borderRadius: 12,
background: "linear-gradient(135deg, rgb(var(--v5-green-rgb)), #5de6b5)",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "#fff",
boxShadow: "0 0 12px rgba(var(--v5-green-rgb), 0.3)",
flexShrink: 0,
}}
>
<Sparkles size={22} strokeWidth={1.75} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: "0.72rem",
fontWeight: 700,
color: "rgb(var(--v5-green-rgb))",
letterSpacing: "0.08em",
}}
>
</div>
<div style={{ fontSize: "1rem", fontWeight: 700, marginTop: 3, color: "var(--v5-text)" }}>
<span style={{ fontFamily: "var(--v5-font-mono)", color: "var(--v5-cyan)" }}>
{state.subdomain}.invyone.com
</span>
<span style={{ color: "var(--v5-text-sec)", fontWeight: 400 }}> </span>
</div>
<div
style={{
fontSize: "0.78rem",
color: "var(--v5-text-muted)",
marginTop: 3,
fontFamily: "var(--v5-font-mono)",
fontWeight: 500,
}}
>
: {state.db_prefix}_admin / [Step 3 ]
</div>
</div>
</div>
)}
</div>
{/* ── 우측: 터미널 로그 콘솔 ── */}
<div style={{ alignSelf: "start" }}>
<div
style={{
background: "#0a0a0c",
borderRadius: 11,
border: "1px solid var(--v5-border)",
overflow: "hidden",
boxShadow: "0 0 16px rgba(0, 0, 0, 0.25)",
}}
>
<div
style={{
padding: "0.75rem 0.95rem",
borderBottom: "1px solid rgba(255,255,255,0.08)",
display: "flex",
alignItems: "center",
gap: 8,
fontFamily: "var(--v5-font-mono)",
fontSize: "0.7rem",
letterSpacing: "0.15em",
textTransform: "uppercase",
color: "#a8a8b0",
fontWeight: 600,
background: "linear-gradient(180deg, #111114, #0a0a0c)",
}}
>
<Terminal size={13} />
<span
style={{
width: 7,
height: 7,
borderRadius: "50%",
background: accent,
boxShadow: `0 0 8px ${accent}`,
animation: variant === "running" ? "pulsedot 1.4s ease-in-out infinite" : "none",
}}
/>
<span style={{ flex: 1 }} />
<span style={{ fontSize: "0.62rem", color: "#6a6a72", letterSpacing: "0.08em" }}>
{log.length} lines
</span>
</div>
<div
ref={logScrollRef}
style={{
padding: "0.75rem 0.95rem",
fontFamily: "var(--v5-font-mono)",
fontSize: "0.72rem",
lineHeight: 1.7,
color: "#d4d4dc",
maxHeight: 480,
overflow: "auto",
whiteSpace: "pre-wrap",
background:
"radial-gradient(ellipse at top, rgba(var(--v5-cyan-rgb), 0.04), transparent 60%), #0a0a0c",
}}
>
{log.length === 0 ? (
<span style={{ color: "#70707a" }}>( )</span>
) : (
log.map((l, i) => (
<div key={i} className="s4-log-line" style={{ color: colorizeLog(l) }}>
{l}
</div>
))
)}
</div>
</div>
</div>
</div>
);
}
function colorizeLog(line: string): string {
if (line.includes("✓") || line.includes("━ READY")) return "#5de6b5";
if (line.includes("✗") || line.includes("FAILED")) return "#ff8a8a";
if (line.includes("▸")) return "#8ce7ff";
return "#d4d4dc";
}
function RunRow({
step,
idx,
status,
isLast,
dbName,
}: {
step: { key: string; label: string; sub: string; sec: number };
idx: number;
status: RowStatus;
isLast: boolean;
dbName: string;
}) {
const isDone = status === "done";
const isActive = status === "running";
const isFailed = status === "failed";
const isPending = status === "pending";
const barColor = isFailed
? "var(--v5-red)"
: isDone
? "rgb(var(--v5-green-rgb))"
: isActive
? "var(--v5-cyan)"
: "var(--v5-border)";
return (
<div
className="s4-step-row"
style={{
position: "relative",
display: "grid",
gridTemplateColumns: "40px 1fr 100px",
gap: "1rem",
padding: "0.85rem 0",
alignItems: "center",
animationDelay: `${idx * 60}ms`,
}}
>
{/* circle + connecting line */}
<div
style={{
position: "relative",
height: 38,
display: "flex",
justifyContent: "center",
}}
>
{!isLast && (
<div
style={{
position: "absolute",
top: 32,
bottom: -18,
width: 2,
background: isDone ? "rgb(var(--v5-green-rgb))" : "var(--v5-border)",
left: 19,
transition: "background 0.4s ease",
}}
/>
)}
<div
style={{
width: 34,
height: 34,
borderRadius: "50%",
background: isFailed
? "var(--v5-red)"
: isDone
? "rgb(var(--v5-green-rgb))"
: isActive
? "var(--v5-cyan)"
: "var(--v5-bg)",
border: `2px solid ${barColor}`,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: isPending ? "var(--v5-text-muted)" : "#fff",
boxShadow: isActive
? "0 0 10px rgba(var(--v5-cyan-rgb), 0.35)"
: isFailed
? "0 0 10px rgba(var(--v5-red-rgb), 0.3)"
: isDone
? "0 0 6px rgba(var(--v5-green-rgb), 0.25)"
: "none",
transition: "all 0.35s cubic-bezier(0.4,0,0.2,1)",
position: "relative",
zIndex: 1,
flexShrink: 0,
animation: isActive ? "wizPulseRing 1.8s ease-out infinite" : "none",
}}
>
{isDone && <Check size={15} strokeWidth={2.5} />}
{isFailed && <X size={15} strokeWidth={2.5} />}
{isActive && (
<span
style={{
width: 13,
height: 13,
border: "2.5px solid rgba(255,255,255,0.35)",
borderTopColor: "#fff",
borderRadius: "50%",
}}
className="spin"
/>
)}
{isPending && (
<span
style={{
fontSize: "0.78rem",
fontWeight: 700,
color: "var(--v5-text-muted)",
fontFamily: "var(--v5-font-mono)",
}}
>
{idx + 1}
</span>
)}
</div>
</div>
<div>
<div
style={{
fontSize: "0.92rem",
fontWeight: isActive ? 700 : 600,
letterSpacing: "-0.01em",
color: isFailed
? "var(--v5-red)"
: isActive
? "var(--v5-cyan)"
: isDone
? "var(--v5-text)"
: "var(--v5-text-muted)",
transition: "color 0.3s ease",
display: "inline-flex",
alignItems: "center",
gap: 6,
}}
>
{step.label}
{isActive && (
<Zap
size={12}
style={{ color: "var(--v5-cyan)", animation: "pulsedot 1.4s ease-in-out infinite" }}
/>
)}
</div>
<div
style={{
fontSize: "0.72rem",
color: "var(--v5-text-muted)",
fontFamily: "var(--v5-font-mono)",
marginTop: 3,
fontWeight: 500,
}}
>
{step.sub.replace("%db%", dbName)}
</div>
</div>
<div
style={{
textAlign: "right",
fontSize: "0.76rem",
fontFamily: "var(--v5-font-mono)",
fontWeight: 600,
color: isFailed
? "var(--v5-red)"
: isDone
? "rgb(var(--v5-green-rgb))"
: isActive
? "var(--v5-cyan)"
: "var(--v5-text-muted)",
transition: "color 0.3s ease",
}}
>
{isDone && "완료"}
{isActive && "진행 중…"}
{isPending && "대기"}
{isFailed && "실패"}
</div>
</div>
);
}