서브도메인 배포 작업
Build & Deploy to K8s / build-and-deploy (push) Successful in 6s

This commit is contained in:
2026-04-24 17:49:16 +09:00
parent 8be7e16e56
commit 8c861144dc
15 changed files with 1196 additions and 463 deletions
@@ -50,7 +50,8 @@ public class ProvisioningController {
private static final Set<String> RESERVED_SUBDOMAINS = Set.of(
"www", "admin", "api", "app", "static", "assets",
"main", "mail", "blog", "dev", "test", "staging", "prod", "console"
"main", "mail", "blog", "dev", "test", "staging", "prod", "console",
"solution"
);
@GetMapping("/table-groups")
+3 -1
View File
@@ -16,7 +16,9 @@ RUN ./gradlew bootJar --no-daemon
FROM eclipse-temurin:21-jre-alpine AS runner
WORKDIR /app
RUN apk add --no-cache curl
# postgresql16-client: 회사 프로비저닝 시 pg_dump 로 스키마 복사.
# 서버 PG 16.x 와 버전 맞춤 (alpine 기본 postgresql-client 는 18 이라 불일치).
RUN apk add --no-cache curl postgresql16-client
COPY --from=build /app/build/libs/*.jar app.jar
@@ -81,17 +81,75 @@ export default function SubdomainListPage() {
flexDirection: "column",
background: "var(--v5-bg)",
color: "var(--v5-text)",
fontFamily: "var(--v5-font-sans)",
overflow: "hidden",
}}
>
<style>{`
@keyframes pulsedot { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } }
@keyframes pulsedot { 0%,100% { opacity: 1; } 50% { opacity: 0.35; transform: scale(0.88); } }
@keyframes provPageFadeUp {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes provPageFade {
from { opacity: 0; }
to { opacity: 1; }
}
[data-accrow]:last-child { border-bottom: 0 !important; }
[data-prov-enter] {
animation: provPageFadeUp 0.45s cubic-bezier(0.16, 1, 0.3, 1) both;
}
[data-prov-enter="1"] { animation-delay: 0ms; }
[data-prov-enter="2"] { animation-delay: 70ms; }
[data-prov-enter="3"] { animation-delay: 140ms; }
[data-prov-enter="4"] { animation-delay: 210ms; }
[data-prov-enter="5"] { animation-delay: 280ms; }
[data-prov-row] {
transition: background 0.18s ease, box-shadow 0.2s ease, transform 0.2s ease;
animation: provPageFade 0.4s ease-out both;
}
[data-prov-row]:hover {
background: var(--v5-surface-hover) !important;
}
.prov-hbtn {
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;
}
.prov-hbtn:not(:disabled):hover { transform: translateY(-1px); }
.prov-hbtn:not(:disabled):active { transform: translateY(0) scale(0.97); }
.prov-hbtn-primary:not(:disabled):hover {
box-shadow: 0 0 14px rgba(var(--v5-primary-rgb), 0.28) !important;
}
.prov-hbtn-secondary:not(:disabled):hover {
background: var(--v5-surface-hover) !important;
border-color: var(--v5-primary) !important;
color: var(--v5-primary) !important;
}
.prov-pagebtn {
transition: all 0.15s ease;
}
.prov-pagebtn:not(:disabled):hover {
background: var(--v5-surface-hover) !important;
border-color: var(--v5-primary) !important;
color: var(--v5-primary) !important;
}
.prov-pagebtn-active {
box-shadow: 0 0 5px rgba(var(--v5-primary-rgb), 0.2);
}
.prov-input:focus {
border-color: var(--v5-primary) !important;
box-shadow: 0 0 0 3px rgba(var(--v5-primary-rgb), 0.15) !important;
}
`}</style>
{/* 페이지 헤더 */}
<div
data-prov-enter="1"
style={{
display: "flex",
alignItems: "flex-end",
@@ -112,7 +170,6 @@ export default function SubdomainListPage() {
fontSize: "0.72rem",
color: "var(--v5-text-sec)",
marginTop: "0.25rem",
fontFamily: "var(--v5-font-mono)",
fontWeight: 500,
}}
>
@@ -123,8 +180,8 @@ export default function SubdomainListPage() {
<HeaderBtn onClick={() => refetch()} icon={<RefreshCw size={11} strokeWidth={1.75} />}>
</HeaderBtn>
<HeaderBtn icon={<FileText size={11} strokeWidth={1.75} />}> </HeaderBtn>
<HeaderBtn icon={<Download size={11} strokeWidth={1.75} />}>CSV </HeaderBtn>
<HeaderBtn icon={<FileText size={11} strokeWidth={1.75} />} soon> </HeaderBtn>
<HeaderBtn icon={<Download size={11} strokeWidth={1.75} />} soon>CSV </HeaderBtn>
<HeaderBtn
variant="primary"
icon={<Plus size={11} strokeWidth={1.75} />}
@@ -136,10 +193,13 @@ export default function SubdomainListPage() {
</div>
{/* stats strip */}
<CompanyStatsStrip rows={rows} />
<div data-prov-enter="2">
<CompanyStatsStrip rows={rows} />
</div>
{/* toolbar */}
<div
data-prov-enter="3"
style={{
marginLeft: "-1.1rem",
marginRight: "-1.1rem",
@@ -157,6 +217,7 @@ export default function SubdomainListPage() {
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="회사명 · subdomain · company_code 검색"
className="prov-input"
style={{
width: "100%",
padding: "0.4rem 0.45rem 0.4rem 1.7rem",
@@ -167,6 +228,7 @@ export default function SubdomainListPage() {
fontSize: "0.72rem",
fontFamily: "var(--v5-font-mono)",
outline: "none",
transition: "all 0.18s ease",
}}
/>
<span
@@ -214,11 +276,12 @@ export default function SubdomainListPage() {
{/* list header */}
<div
data-prov-enter="4"
style={{
marginLeft: "-1.1rem",
marginRight: "-1.1rem",
display: "grid",
gridTemplateColumns: "14px 1fr 110px 100px 80px 18px",
gridTemplateColumns: "14px minmax(280px, 420px) minmax(160px, 1fr) 90px 110px 110px 100px 80px 18px",
gap: "0.85rem",
padding: "0.65rem 2rem",
fontSize: "0.7rem",
@@ -231,6 +294,9 @@ export default function SubdomainListPage() {
>
<span />
<span> / </span>
<span></span>
<span></span>
<span></span>
<span></span>
<span>DB </span>
<span></span>
@@ -239,6 +305,7 @@ export default function SubdomainListPage() {
{/* rows (scrollable when long) */}
<div
data-prov-enter="5"
style={{
marginLeft: "-1.1rem",
marginRight: "-1.1rem",
@@ -331,16 +398,21 @@ function HeaderBtn({
icon,
variant = "secondary",
onClick,
soon,
}: {
children: React.ReactNode;
icon?: React.ReactNode;
variant?: "primary" | "secondary";
onClick?: () => void;
soon?: boolean;
}) {
const isPrimary = variant === "primary";
return (
<button
onClick={onClick}
onClick={soon ? undefined : onClick}
disabled={soon}
title={soon ? "준비 중인 기능입니다" : undefined}
className={soon ? undefined : `prov-hbtn prov-hbtn-${isPrimary ? "primary" : "secondary"}`}
style={{
display: "inline-flex",
alignItems: "center",
@@ -353,15 +425,31 @@ function HeaderBtn({
border: `1px solid ${isPrimary ? "var(--v5-primary)" : "var(--v5-border)"}`,
background: isPrimary ? "var(--v5-primary)" : "var(--v5-surface-solid)",
color: isPrimary ? "#fff" : "var(--v5-text)",
boxShadow: isPrimary ? "0 0 18px rgba(var(--v5-primary-rgb), 0.22)" : "none",
cursor: "pointer",
boxShadow: isPrimary && !soon ? "0 0 10px rgba(var(--v5-primary-rgb), 0.14)" : "none",
cursor: soon ? "not-allowed" : "pointer",
whiteSpace: "nowrap",
fontFamily: "inherit",
transition: "all 0.15s",
opacity: soon ? 0.55 : 1,
}}
>
{icon}
{children}
{soon && (
<span
style={{
fontSize: "0.58rem",
fontWeight: 700,
padding: "1px 5px",
borderRadius: 3,
background: "rgba(var(--v5-amber-rgb), 0.18)",
color: "var(--v5-amber)",
letterSpacing: "0.03em",
marginLeft: 2,
}}
>
</span>
)}
</button>
);
}
@@ -389,6 +477,7 @@ function Pagination({
key={key}
onClick={() => !disabled && onChange(p)}
disabled={disabled}
className={`prov-pagebtn ${active ? "prov-pagebtn-active" : ""}`}
style={{
minWidth: 24,
height: 24,
@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useLayoutEffect, useRef, useState } from "react";
import {
ChevronRight,
ChevronDown,
@@ -19,6 +19,13 @@ import {
} from "lucide-react";
import StatusDot from "./StatusDot";
const TABS = [
{ key: "overview", label: "개요", Icon: Info, soon: false },
{ key: "members", label: "구성원", Icon: Users, soon: true },
{ key: "templates", label: "템플릿", Icon: Layers, soon: true },
{ key: "danger", label: "위험 영역", Icon: AlertTriangle, soon: false },
] as const;
/**
* 단일 회사 row. 클릭 시 accordion 펼쳐지며 탭 4개 (개요/구성원/템플릿/위험영역) 표시.
* 목업 v9 AccRow 포팅. API 데이터 스키마는 /companies-stats 응답.
@@ -33,6 +40,20 @@ export default function CompanyAccordionRow({
onToggle: () => void;
}) {
const [tab, setTab] = useState<"overview" | "members" | "templates" | "danger">("overview");
const tabsWrapRef = useRef<HTMLDivElement>(null);
const tabBtnRefs = useRef<Record<string, HTMLButtonElement | null>>({});
const [indicator, setIndicator] = useState<{ left: number; width: number }>({ left: 0, width: 0 });
useLayoutEffect(() => {
if (!open) return;
const el = tabBtnRefs.current[tab];
const wrap = tabsWrapRef.current;
if (el && wrap) {
const r1 = el.getBoundingClientRect();
const r2 = wrap.getBoundingClientRect();
setIndicator({ left: r1.left - r2.left, width: r1.width });
}
}, [tab, open]);
const sub = r.subdomain || "";
const name = r.company_name || r.name || r.company_code;
@@ -45,25 +66,26 @@ export default function CompanyAccordionRow({
return (
<div
data-accrow
data-prov-row
style={{
background: open ? "var(--v5-surface-hover)" : "var(--v5-surface-solid)",
borderBottom: "1px solid var(--v5-border)",
position: "relative",
transition: "background 0.12s",
}}
>
{open && (
<div
style={{
position: "absolute",
left: 0,
top: 0,
bottom: 0,
width: 2,
background: "var(--v5-primary)",
}}
/>
)}
<div
style={{
position: "absolute",
left: 0,
top: 0,
bottom: 0,
width: 2,
background: "var(--v5-primary)",
transform: open ? "scaleY(1)" : "scaleY(0)",
transformOrigin: "center",
transition: "transform 0.25s cubic-bezier(0.4,0,0.2,1)",
}}
/>
<button
onClick={onToggle}
@@ -75,35 +97,26 @@ export default function CompanyAccordionRow({
cursor: "pointer",
padding: "0.6rem 2rem 0.6rem 2rem",
display: "grid",
gridTemplateColumns: "14px 1fr 110px 100px 80px 18px",
gridTemplateColumns: "14px minmax(280px, 420px) minmax(160px, 1fr) 90px 110px 110px 100px 80px 18px",
gap: "0.85rem",
alignItems: "center",
}}
>
{open ? (
<ChevronDown size={12} color="var(--v5-text-muted)" strokeWidth={1.75} />
) : (
<ChevronRight size={12} color="var(--v5-text-muted)" strokeWidth={1.75} />
)}
<ChevronRight
size={12}
color={open ? "var(--v5-primary)" : "var(--v5-text-muted)"}
strokeWidth={1.75}
style={{
transform: open ? "rotate(90deg)" : "rotate(0deg)",
transition: "transform 0.25s cubic-bezier(0.4,0,0.2,1), color 0.2s ease",
}}
/>
<div style={{ minWidth: 0 }}>
<div style={{ display: "flex", alignItems: "center", gap: 7 }}>
<span style={{ fontSize: "0.82rem", fontWeight: 700, letterSpacing: "-0.01em", color: "var(--v5-text)" }}>
{name}
</span>
<span
style={{
fontSize: "0.58rem",
padding: "1px 6px",
borderRadius: 3,
background: "var(--v5-border)",
color: "var(--v5-text)",
fontWeight: 700,
letterSpacing: "0.04em",
}}
>
{plan.toUpperCase()}
</span>
</div>
<div
style={{
@@ -128,6 +141,87 @@ export default function CompanyAccordionRow({
</div>
</div>
{/* 담당자 */}
<div style={{ minWidth: 0 }}>
{r.owner || r.representative_name ? (
<>
<div
style={{
fontSize: "0.76rem",
fontWeight: 600,
color: "var(--v5-text)",
lineHeight: 1.15,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{r.owner || r.representative_name}
</div>
{r.email && (
<div
style={{
fontSize: "0.6rem",
color: "var(--v5-text-muted)",
fontFamily: "var(--v5-font-mono)",
marginTop: 2,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{r.email}
</div>
)}
{!r.email && r.representative_phone && (
<div
style={{
fontSize: "0.6rem",
color: "var(--v5-text-muted)",
fontFamily: "var(--v5-font-mono)",
marginTop: 2,
}}
>
{r.representative_phone}
</div>
)}
</>
) : (
<div style={{ fontSize: "0.72rem", color: "var(--v5-text-muted)" }}></div>
)}
</div>
{/* 플랜 */}
<div>
<PlanBadge plan={plan} />
</div>
{/* 생성일 */}
<div>
<div style={labelSm}></div>
<div
style={{
fontSize: "0.72rem",
fontWeight: 600,
fontFamily: "var(--v5-font-mono)",
color: "var(--v5-text)",
lineHeight: 1.1,
}}
>
{formatRelative(r.created) || "—"}
</div>
<div
style={{
fontSize: "0.58rem",
color: "var(--v5-text-muted)",
fontFamily: "var(--v5-font-mono)",
marginTop: 2,
}}
>
{formatDate(r.created)}
</div>
</div>
<div>
<div style={labelSm}></div>
<div
@@ -169,18 +263,37 @@ export default function CompanyAccordionRow({
<MoreHorizontal size={12} color="var(--v5-text-muted)" strokeWidth={1.75} />
</button>
{open && (
<div style={{ padding: "0 2rem 0.9rem 3rem" }}>
<div
style={{
display: "grid",
gridTemplateRows: open ? "1fr" : "0fr",
transition: "grid-template-rows 0.3s cubic-bezier(0.4,0,0.2,1)",
}}
>
<div style={{ overflow: "hidden" }}>
<div
style={{
padding: "0 2rem 0.9rem 3rem",
opacity: open ? 1 : 0,
transform: open ? "translateY(0)" : "translateY(-4px)",
transition: "opacity 0.25s ease 0.05s, transform 0.25s ease 0.05s",
}}
>
{/* tabs */}
<div style={{ display: "flex", gap: 0, borderBottom: "1px solid var(--v5-border)", marginBottom: "0.7rem" }}>
{([
["overview", "개요", Info],
["members", "구성원", Users],
["templates", "템플릿", Layers],
["danger", "위험 영역", AlertTriangle],
] as const).map(([k, l, IconC]) => (
<div
ref={tabsWrapRef}
style={{
display: "flex",
gap: 0,
borderBottom: "1px solid var(--v5-border)",
marginBottom: "0.7rem",
position: "relative",
}}
>
{TABS.map(({ key: k, label: l, Icon: IconC, soon }) => (
<button
key={k}
ref={(el) => { tabBtnRefs.current[k] = el; }}
onClick={(e) => {
e.stopPropagation();
setTab(k);
@@ -193,15 +306,30 @@ export default function CompanyAccordionRow({
fontSize: "0.72rem",
fontWeight: 600,
color: tab === k ? "var(--v5-primary)" : "var(--v5-text-sec)",
borderBottom: `2px solid ${tab === k ? "var(--v5-primary)" : "transparent"}`,
marginBottom: -1,
display: "inline-flex",
alignItems: "center",
gap: 5,
fontFamily: "inherit",
transition: "color 0.2s ease",
}}
>
<IconC size={11} strokeWidth={1.75} /> {l}
{soon && (
<span
style={{
fontSize: "0.52rem",
fontWeight: 700,
padding: "1px 5px",
borderRadius: 3,
background: "rgba(var(--v5-amber-rgb), 0.18)",
color: "var(--v5-amber)",
letterSpacing: "0.03em",
marginLeft: 2,
}}
>
</span>
)}
</button>
))}
<div style={{ flex: 1 }} />
@@ -219,6 +347,21 @@ export default function CompanyAccordionRow({
{r.created && <> {formatDate(r.created)}</>}
{r.writer && <> · writer {r.writer}</>}
</div>
{/* sliding indicator */}
<div
style={{
position: "absolute",
bottom: -1,
left: indicator.left,
width: indicator.width,
height: 2,
background: "var(--v5-primary)",
boxShadow: "0 0 5px rgba(var(--v5-primary-rgb), 0.25)",
transition: "left 0.28s cubic-bezier(0.4,0,0.2,1), width 0.28s cubic-bezier(0.4,0,0.2,1)",
pointerEvents: "none",
}}
/>
</div>
{tab === "overview" && (
@@ -313,10 +456,10 @@ export default function CompanyAccordionRow({
if (sub) window.open(`http://${sub}.invyone.com`, "_blank");
}}
>
</ABtn>
<ABtn icon={<KeyRound size={11} strokeWidth={1.75} />}> </ABtn>
<ABtn icon={<Copy size={11} strokeWidth={1.75} />}>릿 </ABtn>
<ABtn icon={<KeyRound size={11} strokeWidth={1.75} />} soon> </ABtn>
<ABtn icon={<Copy size={11} strokeWidth={1.75} />} soon>릿 </ABtn>
</div>
</div>
</div>
@@ -407,7 +550,8 @@ export default function CompanyAccordionRow({
</div>
)}
</div>
)}
</div>
</div>
</div>
);
}
@@ -445,17 +589,22 @@ function ABtn({
children,
icon,
onClick,
soon,
}: {
children: React.ReactNode;
icon?: React.ReactNode;
onClick?: (e: React.MouseEvent) => void;
soon?: boolean;
}) {
return (
<button
onClick={(e) => {
onClick={soon ? (e) => e.stopPropagation() : (e) => {
e.stopPropagation();
onClick?.(e);
}}
disabled={soon}
title={soon ? "준비 중인 기능입니다" : undefined}
className={soon ? undefined : "prov-hbtn prov-hbtn-secondary"}
style={{
display: "inline-flex",
alignItems: "center",
@@ -468,14 +617,30 @@ function ABtn({
border: "1px solid var(--v5-border)",
background: "var(--v5-surface-solid)",
color: "var(--v5-text)",
cursor: "pointer",
cursor: soon ? "not-allowed" : "pointer",
whiteSpace: "nowrap",
fontFamily: "inherit",
transition: "all 0.15s",
opacity: soon ? 0.55 : 1,
}}
>
{icon}
{children}
{soon && (
<span
style={{
fontSize: "0.54rem",
fontWeight: 700,
padding: "1px 5px",
borderRadius: 3,
background: "rgba(var(--v5-amber-rgb), 0.18)",
color: "var(--v5-amber)",
letterSpacing: "0.03em",
marginLeft: 2,
}}
>
</span>
)}
</button>
);
}
@@ -512,3 +677,71 @@ function formatDate(v: any): string {
return String(v);
}
}
function formatRelative(v: any): string {
if (!v) return "";
try {
const d = typeof v === "number" ? new Date(v) : new Date(String(v));
if (isNaN(d.getTime())) return "";
const diffMs = Date.now() - d.getTime();
const sec = Math.round(diffMs / 1000);
if (sec < 60) return "방금";
const min = Math.round(sec / 60);
if (min < 60) return `${min}분 전`;
const hr = Math.round(min / 60);
if (hr < 24) return `${hr}시간 전`;
const day = Math.round(hr / 24);
if (day < 7) return `${day}일 전`;
const wk = Math.round(day / 7);
if (wk < 5) return `${wk}주 전`;
const mo = Math.round(day / 30);
if (mo < 12) return `${mo}개월 전`;
const yr = Math.round(day / 365);
return `${yr}년 전`;
} catch {
return "";
}
}
const PLAN_STYLE: Record<string, { bg: string; color: string; border: string }> = {
ENTERPRISE: {
bg: "rgba(var(--v5-primary-rgb), 0.12)",
color: "var(--v5-primary)",
border: "rgba(var(--v5-primary-rgb), 0.3)",
},
STANDARD: {
bg: "rgba(var(--v5-cyan-rgb), 0.1)",
color: "var(--v5-cyan)",
border: "rgba(var(--v5-cyan-rgb), 0.3)",
},
STARTER: {
bg: "var(--v5-bg-subtle)",
color: "var(--v5-text-sec)",
border: "var(--v5-border)",
},
};
function PlanBadge({ plan }: { plan: string }) {
const key = plan.toUpperCase();
const s = PLAN_STYLE[key] || PLAN_STYLE.STARTER;
return (
<span
style={{
display: "inline-flex",
alignItems: "center",
fontSize: "0.6rem",
fontWeight: 700,
padding: "3px 8px",
borderRadius: 4,
background: s.bg,
color: s.color,
border: `1px solid ${s.border}`,
letterSpacing: "0.06em",
fontFamily: "var(--v5-font-mono)",
whiteSpace: "nowrap",
}}
>
{key}
</span>
);
}
@@ -32,7 +32,7 @@ export default function StatusDot({ status }: { status?: string }) {
height: 6,
borderRadius: "50%",
background: m.color,
boxShadow: `0 0 6px ${m.color}`,
boxShadow: `0 0 3px ${m.color}`,
animation: isProvisioning ? "pulsedot 1.4s ease-in-out infinite" : "none",
}}
/>
@@ -105,43 +105,45 @@ export default function Step1Basic({
return (
<div
className="wiz-step"
style={{
padding: "1.4rem 1.6rem",
padding: "1.6rem 1.8rem",
display: "grid",
gridTemplateColumns: "1fr 320px",
gap: "1.6rem",
gridTemplateColumns: "1fr 360px",
gap: "1.8rem",
animation: "wizSlideUp 0.35s cubic-bezier(0.16, 1, 0.3, 1)",
}}
>
{/* 왼쪽: 폼 */}
<div>
<div
style={{
fontSize: "0.55rem",
fontSize: "0.72rem",
fontWeight: 700,
letterSpacing: "0.15em",
letterSpacing: "0.18em",
textTransform: "uppercase",
color: "var(--v5-cyan)",
marginBottom: 6,
marginBottom: 8,
fontFamily: "var(--v5-font-mono)",
}}
>
01 · BASIC
01 ·
</div>
<h2 style={{ fontSize: "1.15rem", fontWeight: 800, letterSpacing: "-0.02em", margin: 0, color: "var(--v5-text)" }}>
<h2 style={{ fontSize: "1.4rem", fontWeight: 800, letterSpacing: "-0.02em", margin: 0, color: "var(--v5-text)" }}>
</h2>
<div style={{ fontSize: "0.7rem", color: "var(--v5-text-sec)", marginTop: 4 }}>
<div style={{ fontSize: "0.85rem", color: "var(--v5-text-sec)", marginTop: 6, fontWeight: 500 }}>
subdomain URL DB . .
</div>
{/* ── 식별자 ── */}
<div style={{ marginTop: "1.2rem" }}>
<div style={{ marginTop: "1.4rem" }}>
<SectionHead
icon={<LinkIcon size={11} strokeWidth={1.75} />}
icon={<LinkIcon size={13} strokeWidth={1.75} />}
label="식별자"
hint="생성 후 변경 불가"
/>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.7rem 0.85rem" }}>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.9rem 1rem" }}>
<Field
label="SUBDOMAIN"
required
@@ -150,7 +152,7 @@ export default function Step1Basic({
<TextInput
value={state.subdomain || ""}
onChange={onSubChange}
placeholder="qnc"
placeholder="영문 소문자 · 숫자 · 하이픈"
suffix=".invyone.com"
mono
status={availToInputStatus(subStatus)}
@@ -164,7 +166,7 @@ export default function Step1Basic({
<TextInput
value={state.db_prefix || ""}
onChange={onDbPrefixChange}
placeholder="qnc"
placeholder="영문 소문자 · 숫자 · 밑줄"
suffix="_vexplor"
mono
status={availToInputStatus(prefStatus)}
@@ -178,7 +180,7 @@ export default function Step1Basic({
<TextInput
value={state.company_code || ""}
onChange={onCompanyCodeChange}
placeholder="QNC"
placeholder="영문 대문자 · 숫자 · 밑줄"
mono
status={availToInputStatus(codeStatus)}
/>
@@ -187,21 +189,21 @@ export default function Step1Basic({
<TextInput
value={state.company_name || ""}
onChange={(v) => setState({ company_name: v })}
placeholder="큐엔씨 주식회사"
placeholder="화면·문서에 표시될 회사 이름"
/>
</Field>
</div>
</div>
{/* ── 법인 정보 ── */}
<div style={{ marginTop: "1.2rem" }}>
<SectionHead icon={<Briefcase size={11} strokeWidth={1.75} />} label="법인 정보" />
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.7rem 0.85rem" }}>
<div style={{ marginTop: "1.4rem" }}>
<SectionHead icon={<Briefcase size={13} strokeWidth={1.75} />} label="법인 정보" />
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.9rem 1rem" }}>
<Field label="사업자번호">
<TextInput
value={state.business_registration_number || ""}
onChange={(v) => setState({ business_registration_number: v })}
placeholder="123-45-67890"
placeholder="xxx-xx-xxxxx"
mono
/>
</Field>
@@ -209,14 +211,14 @@ export default function Step1Basic({
<TextInput
value={state.representative_name || ""}
onChange={(v) => setState({ representative_name: v })}
placeholder="홍길동"
placeholder="대표자 성함"
/>
</Field>
<Field label="대표 연락처">
<TextInput
value={state.representative_phone || ""}
onChange={(v) => setState({ representative_phone: v })}
placeholder="02-1234-5678"
placeholder="02-0000-0000"
mono
/>
</Field>
@@ -224,7 +226,7 @@ export default function Step1Basic({
<TextInput
value={state.email || ""}
onChange={(v) => setState({ email: v })}
placeholder="admin@company.kr"
placeholder="admin@example.com"
mono
/>
</Field>
@@ -232,14 +234,14 @@ export default function Step1Basic({
<TextInput
value={state.address || ""}
onChange={(v) => setState({ address: v })}
placeholder="서울특별시 강남구 테헤란로 123"
placeholder="도로명 주소"
/>
</Field>
<Field label="웹사이트" full>
<TextInput
value={state.website || ""}
onChange={(v) => setState({ website: v })}
placeholder="https://company.kr"
placeholder="https://"
mono
/>
</Field>
@@ -251,25 +253,25 @@ export default function Step1Basic({
<div style={{ position: "sticky", top: 0, alignSelf: "start" }}>
<div
style={{
padding: "0.95rem 1rem",
padding: "1.1rem 1.2rem",
background: "var(--v5-surface-solid)",
border: "1px solid var(--v5-border)",
borderRadius: 10,
boxShadow: "0 0 24px rgba(var(--v5-cyan-rgb), 0.08)",
borderRadius: 11,
boxShadow: "0 0 14px rgba(var(--v5-cyan-rgb), 0.05)",
}}
>
<div
style={{
fontSize: "0.52rem",
fontSize: "0.68rem",
fontWeight: 700,
letterSpacing: "0.18em",
letterSpacing: "0.2em",
textTransform: "uppercase",
color: "var(--v5-cyan)",
marginBottom: "0.65rem",
marginBottom: "0.85rem",
fontFamily: "var(--v5-font-mono)",
}}
>
LIVE PREVIEW
</div>
<PreviewField label="접속 URL">
@@ -283,7 +285,7 @@ export default function Step1Basic({
</PreviewField>
<PreviewField label="DB 이름">
<Database size={11} color="var(--v5-text-muted)" />
<Database size={13} color="var(--v5-text-muted)" />
<span style={{ color: "var(--v5-primary)", fontWeight: 700 }}>
{state.db_prefix || "___"}
</span>
@@ -300,20 +302,21 @@ export default function Step1Basic({
<div
style={{
padding: "0.6rem 0.7rem",
padding: "0.75rem 0.85rem",
background: "rgba(var(--v5-amber-rgb), 0.08)",
border: "1px solid rgba(var(--v5-amber-rgb), 0.3)",
borderRadius: 6,
fontSize: "0.62rem",
borderRadius: 7,
fontSize: "0.78rem",
color: "var(--v5-amber)",
lineHeight: 1.5,
display: "flex",
alignItems: "flex-start",
gap: 6,
marginTop: "0.4rem",
gap: 7,
marginTop: "0.55rem",
fontWeight: 500,
}}
>
<AlertTriangle size={11} strokeWidth={1.75} style={{ marginTop: 2, flexShrink: 0 }} />
<AlertTriangle size={14} strokeWidth={1.75} style={{ marginTop: 2, flexShrink: 0 }} />
<div>
<b></b> subdomain db_prefix .
</div>
@@ -336,15 +339,15 @@ function SectionHead({
return (
<div
style={{
fontSize: "0.58rem",
fontSize: "0.75rem",
fontWeight: 700,
color: "var(--v5-text-sec)",
letterSpacing: "0.1em",
textTransform: "uppercase",
marginBottom: "0.55rem",
marginBottom: "0.75rem",
display: "flex",
alignItems: "center",
gap: 6,
gap: 7,
fontFamily: "var(--v5-font-mono)",
}}
>
@@ -357,6 +360,7 @@ function SectionHead({
fontWeight: 400,
textTransform: "none",
letterSpacing: 0,
fontSize: "0.72rem",
}}
>
{hint}
@@ -368,30 +372,32 @@ function SectionHead({
function PreviewField({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div style={{ marginBottom: "0.85rem" }}>
<div style={{ marginBottom: "1rem" }}>
<div
style={{
fontSize: "0.52rem",
fontSize: "0.68rem",
color: "var(--v5-text-muted)",
letterSpacing: "0.08em",
textTransform: "uppercase",
fontFamily: "var(--v5-font-mono)",
marginBottom: 3,
marginBottom: 5,
fontWeight: 600,
}}
>
{label}
</div>
<div
style={{
padding: "0.5rem 0.6rem",
padding: "0.6rem 0.75rem",
background: "var(--v5-bg)",
border: "1px solid var(--v5-border)",
borderRadius: 6,
borderRadius: 7,
fontFamily: "var(--v5-font-mono)",
fontSize: "0.72rem",
fontSize: "0.85rem",
fontWeight: 500,
display: "flex",
alignItems: "center",
gap: 5,
gap: 6,
}}
>
{children}
@@ -70,40 +70,42 @@ export default function Step2Template({
return (
<div
className="wiz-step"
style={{
padding: "1.4rem 1.6rem",
padding: "1.6rem 1.8rem",
display: "grid",
gridTemplateColumns: "1fr 280px",
gap: "1.6rem",
gridTemplateColumns: "1fr 320px",
gap: "1.8rem",
animation: "wizSlideUp 0.35s cubic-bezier(0.16, 1, 0.3, 1)",
}}
>
<div>
<div
style={{
fontSize: "0.55rem",
fontSize: "0.72rem",
fontWeight: 700,
letterSpacing: "0.15em",
letterSpacing: "0.18em",
textTransform: "uppercase",
color: "var(--v5-cyan)",
marginBottom: 6,
marginBottom: 8,
fontFamily: "var(--v5-font-mono)",
}}
>
02 · TEMPLATE
02 · 릿
</div>
<h2 style={{ fontSize: "1.15rem", fontWeight: 800, letterSpacing: "-0.02em", margin: 0, color: "var(--v5-text)" }}>
<h2 style={{ fontSize: "1.4rem", fontWeight: 800, letterSpacing: "-0.02em", margin: 0, color: "var(--v5-text)" }}>
</h2>
<div style={{ fontSize: "0.7rem", color: "var(--v5-text-sec)", marginTop: 4, maxWidth: 560 }}>
<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: "1rem", color: "var(--v5-text-muted)", fontSize: "0.68rem" }}> ...</div>
<div style={{ padding: "1.2rem", color: "var(--v5-text-muted)", fontSize: "0.82rem" }}> ...</div>
)}
<div style={{ marginTop: "1.1rem", display: "flex", flexDirection: "column", gap: "0.55rem" }}>
<div style={{ marginTop: "1.3rem", display: "flex", flexDirection: "column", gap: "0.7rem" }}>
{required.length > 0 && (
<>
<SubHead> ( )</SubHead>
@@ -141,24 +143,25 @@ export default function Step2Template({
<div style={{ position: "sticky", top: 0, alignSelf: "start" }}>
<div
style={{
padding: "1rem",
padding: "1.15rem 1.2rem",
background: "var(--v5-surface-solid)",
border: "1px solid var(--v5-border)",
borderRadius: 10,
borderRadius: 11,
boxShadow: "0 0 12px rgba(var(--v5-cyan-rgb), 0.04)",
}}
>
<div
style={{
fontSize: "0.52rem",
fontSize: "0.68rem",
fontWeight: 700,
letterSpacing: "0.18em",
letterSpacing: "0.2em",
textTransform: "uppercase",
color: "var(--v5-cyan)",
marginBottom: "0.7rem",
marginBottom: "0.85rem",
fontFamily: "var(--v5-font-mono)",
}}
>
SUMMARY
</div>
<SummaryRow
label="선택된 그룹"
@@ -210,31 +213,32 @@ function TemplateCard({
return (
<div
className="wiz-tplcard"
style={{
border: `1px solid ${checked ? "rgba(var(--v5-cyan-rgb), 0.35)" : "var(--v5-border)"}`,
borderRadius: 10,
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: "all 0.2s",
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 18px rgba(var(--v5-cyan-rgb), 0.08)" : "none",
boxShadow: checked ? "0 0 10px rgba(var(--v5-cyan-rgb), 0.07)" : "none",
}}
>
<div
onClick={() => !locked && onToggle()}
style={{
padding: "0.85rem 1rem",
padding: "1rem 1.15rem",
display: "flex",
alignItems: "center",
gap: "0.7rem",
gap: "0.85rem",
cursor: locked ? "not-allowed" : "pointer",
}}
>
{/* checkbox */}
<div
style={{
width: 18,
height: 18,
borderRadius: 4,
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",
@@ -242,43 +246,45 @@ function TemplateCard({
justifyContent: "center",
color: "#fff",
flexShrink: 0,
boxShadow: checked ? "0 0 10px rgba(var(--v5-cyan-rgb), 0.4)" : "none",
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={12} strokeWidth={3} />}
{checked && <Check size={14} strokeWidth={3} />}
</div>
{/* icon tile */}
<div
style={{
width: 32,
height: 32,
borderRadius: 8,
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={15} strokeWidth={1.75} />
<Ic size={18} strokeWidth={1.75} />
</div>
{/* label */}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<span style={{ fontSize: "0.82rem", fontWeight: 700, color: "var(--v5-text)" }}>
<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.5rem",
fontSize: "0.62rem",
fontWeight: 700,
padding: "0.1rem 0.4rem",
padding: "0.15rem 0.5rem",
borderRadius: 999,
background: "rgba(var(--v5-red-rgb), 0.1)",
color: "var(--v5-red)",
@@ -288,16 +294,17 @@ function TemplateCard({
gap: 3,
}}
>
<Lock size={8} />
<Lock size={10} />
</span>
)}
</div>
<div
style={{
fontSize: "0.6rem",
fontSize: "0.72rem",
color: "var(--v5-text-muted)",
fontFamily: "var(--v5-font-mono)",
marginTop: 2,
marginTop: 3,
fontWeight: 500,
}}
>
{meta.join(" · ")}
@@ -311,53 +318,66 @@ function TemplateCard({
onExpand();
}}
aria-label={expanded ? "접기" : "펼치기"}
className="wiz-chevron"
style={{
width: 24,
height: 24,
width: 30,
height: 30,
border: "1px solid var(--v5-border)",
borderRadius: 6,
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={12} /> : <ChevronDown size={12} />}
{expanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</button>
)}
</div>
{expanded && tables.length > 0 && (
<div
style={{
padding: "0.7rem 1rem 0.85rem 3.4rem",
borderTop: "1px solid var(--v5-border)",
background: "var(--v5-bg)",
display: "flex",
flexWrap: "wrap",
gap: 4,
}}
>
{tables.map((t) => (
<span
key={t}
<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.15rem 0.45rem",
fontFamily: "var(--v5-font-mono)",
fontSize: "0.58rem",
background: "var(--v5-surface-solid)",
border: "1px solid var(--v5-border)",
borderRadius: 4,
color: "var(--v5-text-sec)",
padding: "0.8rem 1.15rem 1rem 3.85rem",
borderTop: "1px solid var(--v5-border)",
background: "var(--v5-bg)",
display: "flex",
flexWrap: "wrap",
gap: 5,
}}
>
{t}
</span>
))}
{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>
);
}
@@ -366,12 +386,12 @@ function SubHead({ children, style }: { children: React.ReactNode; style?: React
return (
<div
style={{
fontSize: "0.52rem",
fontSize: "0.68rem",
fontWeight: 700,
letterSpacing: "0.15em",
textTransform: "uppercase",
color: "var(--v5-text-muted)",
padding: "4px 0",
padding: "6px 0",
fontFamily: "var(--v5-font-mono)",
...style,
}}
@@ -400,14 +420,14 @@ function SummaryRow({
display: "flex",
justifyContent: "space-between",
alignItems: "baseline",
padding: "0.45rem 0",
borderBottom: isLast ? "none" : "1px solid var(--v5-border-subtle, rgba(0,0,0,0.05))",
padding: "0.55rem 0",
borderBottom: isLast ? "none" : "1px solid var(--v5-border-subtle, rgba(0,0,0,0.06))",
}}
>
<span style={{ fontSize: "0.6rem", color: "var(--v5-text-muted)" }}>{label}</span>
<span style={{ fontSize: "0.78rem", color: "var(--v5-text-muted)", fontWeight: 500 }}>{label}</span>
<span
style={{
fontSize: "0.78rem",
fontSize: "0.92rem",
fontWeight: 700,
fontFamily: mono ? "var(--v5-font-mono)" : "inherit",
color: accent === "cyan" ? "var(--v5-cyan)" : "var(--v5-text)",
@@ -68,80 +68,88 @@ export default function Step3Admin({
}
const passwordActions = (
<div style={{ display: "flex", gap: 4 }}>
<div style={{ display: "flex", gap: 5 }}>
<IconBtn title={visible ? "숨기기" : "보기"} onClick={() => setVisible((v) => !v)}>
{visible ? <EyeOff size={12} /> : <Eye size={12} />}
{visible ? <EyeOff size={14} /> : <Eye size={14} />}
</IconBtn>
<IconBtn title="재생성" onClick={regen}>
<RefreshCw size={12} />
<RefreshCw size={14} />
</IconBtn>
<button
onClick={copy}
title="복사"
className={`wiz-btn wiz-btn-${copied ? "primary" : "cyan"}`}
style={{
height: 30,
padding: "0 0.6rem",
borderRadius: 7,
height: 36,
padding: "0 0.85rem",
borderRadius: 8,
border: `1px solid ${copied ? "rgb(var(--v5-green-rgb))" : "var(--v5-cyan)"}`,
background: copied ? "rgb(var(--v5-green-rgb))" : "var(--v5-cyan)",
color: "#fff",
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
gap: 4,
fontSize: "0.62rem",
gap: 5,
fontSize: "0.78rem",
fontWeight: 700,
transition: "all 0.2s",
boxShadow: copied
? "0 0 14px rgba(var(--v5-green-rgb), 0.4)"
: "0 0 10px rgba(var(--v5-cyan-rgb), 0.3)",
? "0 0 8px rgba(var(--v5-green-rgb), 0.25)"
: "0 0 6px rgba(var(--v5-cyan-rgb), 0.2)",
fontFamily: "inherit",
}}
>
{copied ? <Check size={11} /> : <Copy size={11} />}
{copied ? <Check size={13} /> : <Copy size={13} />}
{copied ? "복사됨" : "복사"}
</button>
</div>
);
return (
<div style={{ padding: "1.4rem 1.6rem", maxWidth: 820 }}>
<div
className="wiz-step"
style={{
padding: "1.6rem 1.8rem",
maxWidth: 920,
animation: "wizSlideUp 0.35s cubic-bezier(0.16, 1, 0.3, 1)",
}}
>
<div
style={{
fontSize: "0.55rem",
fontSize: "0.72rem",
fontWeight: 700,
letterSpacing: "0.15em",
letterSpacing: "0.18em",
textTransform: "uppercase",
color: "var(--v5-cyan)",
marginBottom: 6,
marginBottom: 8,
fontFamily: "var(--v5-font-mono)",
}}
>
03 · ADMIN ACCOUNT
03 ·
</div>
<h2 style={{ fontSize: "1.15rem", fontWeight: 800, letterSpacing: "-0.02em", margin: 0, color: "var(--v5-text)" }}>
<h2 style={{ fontSize: "1.4rem", fontWeight: 800, letterSpacing: "-0.02em", margin: 0, color: "var(--v5-text)" }}>
</h2>
<div style={{ fontSize: "0.7rem", color: "var(--v5-text-sec)", marginTop: 4, maxWidth: 560 }}>
<div style={{ fontSize: "0.85rem", color: "var(--v5-text-sec)", marginTop: 6, maxWidth: 640, fontWeight: 500, lineHeight: 1.55 }}>
{" "}
<code style={{ fontFamily: "var(--v5-font-mono)", color: "var(--v5-primary)" }}>COMPANY_ADMIN</code>
<code style={{ fontFamily: "var(--v5-font-mono)", color: "var(--v5-primary)", fontSize: "0.82rem" }}>COMPANY_ADMIN</code>
. {" "}
<b style={{ color: "var(--v5-text)" }}> </b>, DB BCrypt .
</div>
<div
style={{
marginTop: "1.2rem",
marginTop: "1.4rem",
border: "1px solid var(--v5-border)",
borderRadius: 10,
borderRadius: 11,
background: "var(--v5-surface-solid)",
overflow: "hidden",
boxShadow: "0 0 12px rgba(var(--v5-cyan-rgb), 0.03)",
}}
>
<Row
label="USER_ID"
sub="자동 · 읽기전용"
trailing={<Lock size={10} color="var(--v5-text-muted)" />}
trailing={<Lock size={12} color="var(--v5-text-muted)" />}
>
<ReadBox mono>{userId}</ReadBox>
</Row>
@@ -149,21 +157,21 @@ export default function Step3Admin({
<Row label="INITIAL_PASSWORD" sub="무작위 12자" trailing={passwordActions}>
<div
style={{
padding: "0.45rem 0.6rem",
padding: "0.6rem 0.8rem",
background: "rgba(var(--v5-cyan-rgb), 0.05)",
border: "1px solid rgba(var(--v5-cyan-rgb), 0.25)",
borderRadius: 6,
border: "1px solid rgba(var(--v5-cyan-rgb), 0.28)",
borderRadius: 7,
fontFamily: "var(--v5-font-mono)",
fontSize: "0.82rem",
fontSize: "1rem",
fontWeight: 700,
color: "var(--v5-cyan)",
letterSpacing: "0.02em",
letterSpacing: "0.03em",
display: "flex",
alignItems: "center",
gap: 6,
gap: 8,
}}
>
<KeyRound size={13} />
<KeyRound size={16} />
<span style={{ flex: 1 }}>{visible ? pw : "•".repeat(pw.length || 12)}</span>
</div>
</Row>
@@ -171,30 +179,30 @@ export default function Step3Admin({
<Row label="USER_TYPE" sub="고정 · 수정 불가">
<span
style={{
padding: "0.25rem 0.55rem",
padding: "0.32rem 0.7rem",
background: "rgba(var(--v5-primary-rgb), 0.1)",
color: "var(--v5-primary)",
fontFamily: "var(--v5-font-mono)",
fontSize: "0.62rem",
fontSize: "0.78rem",
fontWeight: 700,
borderRadius: 999,
display: "inline-flex",
alignItems: "center",
gap: 4,
gap: 5,
}}
>
<Shield size={10} />
<Shield size={12} />
COMPANY_ADMIN
</span>
</Row>
<Row label="FORCE_PW_CHANGE" sub="권장" last>
<label style={{ display: "flex", alignItems: "center", gap: 8, cursor: "pointer" }}>
<label style={{ display: "flex", alignItems: "center", gap: 10, cursor: "pointer" }}>
<Switch
on={!!state.force_password_change}
onChange={() => setState({ force_password_change: !state.force_password_change })}
/>
<span style={{ fontSize: "0.7rem", color: "var(--v5-text)" }}>
<span style={{ fontSize: "0.85rem", color: "var(--v5-text)", fontWeight: 500 }}>
</span>
</label>
@@ -204,18 +212,18 @@ export default function Step3Admin({
{/* 경고 배너 */}
<div
style={{
marginTop: "1rem",
padding: "0.75rem 0.9rem",
marginTop: "1.15rem",
padding: "0.9rem 1.05rem",
background: "rgba(var(--v5-red-rgb), 0.04)",
border: "1px solid rgba(var(--v5-red-rgb), 0.25)",
borderRadius: 8,
borderRadius: 9,
display: "flex",
alignItems: "flex-start",
gap: 8,
gap: 10,
}}
>
<AlertTriangle size={14} color="var(--v5-red)" style={{ marginTop: 2, flexShrink: 0 }} />
<div style={{ fontSize: "0.68rem", color: "var(--v5-text)", lineHeight: 1.5 }}>
<AlertTriangle size={16} color="var(--v5-red)" style={{ marginTop: 2, flexShrink: 0 }} />
<div style={{ fontSize: "0.82rem", color: "var(--v5-text)", lineHeight: 1.55, fontWeight: 500 }}>
<b> .</b>
<span style={{ color: "var(--v5-text-sec)" }}>
{" "}
@@ -243,27 +251,28 @@ function Row({
return (
<div
style={{
padding: "0.9rem 1.1rem",
padding: "1.05rem 1.25rem",
display: "grid",
gridTemplateColumns: "130px 1fr auto",
gridTemplateColumns: "160px 1fr auto",
alignItems: "center",
gap: "1rem",
gap: "1.15rem",
borderBottom: last ? "none" : "1px solid var(--v5-border)",
}}
>
<div>
<div
style={{
fontSize: "0.52rem",
fontSize: "0.68rem",
letterSpacing: "0.1em",
textTransform: "uppercase",
color: "var(--v5-text-muted)",
fontFamily: "var(--v5-font-mono)",
fontWeight: 700,
}}
>
{label}
</div>
{sub && <div style={{ fontSize: "0.55rem", color: "var(--v5-text-muted)", marginTop: 2 }}>{sub}</div>}
{sub && <div style={{ fontSize: "0.72rem", color: "var(--v5-text-muted)", marginTop: 3, fontWeight: 500 }}>{sub}</div>}
</div>
<div>{children}</div>
<div>{trailing}</div>
@@ -284,10 +293,11 @@ function IconBtn({
<button
onClick={onClick}
title={title}
className="wiz-iconbtn"
style={{
width: 30,
height: 30,
borderRadius: 7,
width: 36,
height: 36,
borderRadius: 8,
border: "1px solid var(--v5-border)",
background: "var(--v5-surface-solid)",
color: "var(--v5-text-sec)",
@@ -306,12 +316,13 @@ function ReadBox({ children, mono }: { children: React.ReactNode; mono?: boolean
return (
<div
style={{
padding: "0.45rem 0.6rem",
padding: "0.6rem 0.75rem",
background: "var(--v5-bg-subtle)",
border: "1px solid var(--v5-border)",
borderRadius: 6,
borderRadius: 7,
fontFamily: mono ? "var(--v5-font-mono)" : "inherit",
fontSize: "0.75rem",
fontSize: "0.9rem",
fontWeight: 500,
color: "var(--v5-text)",
}}
>
@@ -325,14 +336,14 @@ function Switch({ on, onChange }: { on: boolean; onChange: () => void }) {
<div
onClick={onChange}
style={{
width: 34,
height: 18,
borderRadius: 10,
width: 42,
height: 22,
borderRadius: 12,
background: on ? "var(--v5-cyan)" : "var(--v5-bg-subtle)",
border: `1px solid ${on ? "var(--v5-cyan)" : "var(--v5-border)"}`,
position: "relative",
transition: "all 0.2s",
boxShadow: on ? "0 0 10px rgba(var(--v5-cyan-rgb), 0.3)" : "none",
transition: "all 0.25s cubic-bezier(0.4,0,0.2,1)",
boxShadow: on ? "0 0 6px rgba(var(--v5-cyan-rgb), 0.2)" : "none",
cursor: "pointer",
}}
>
@@ -340,13 +351,13 @@ function Switch({ on, onChange }: { on: boolean; onChange: () => void }) {
style={{
position: "absolute",
top: 1,
left: on ? 17 : 1,
width: 14,
height: 14,
left: on ? 21 : 1,
width: 18,
height: 18,
borderRadius: "50%",
background: "#fff",
transition: "left 0.2s",
boxShadow: "0 1px 2px rgba(0,0,0,0.2)",
transition: "left 0.25s cubic-bezier(0.4,0,0.2,1)",
boxShadow: "0 1px 3px rgba(0,0,0,0.25)",
}}
/>
</div>
@@ -1,12 +1,13 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { Check, X, AlertOctagon } from "lucide-react";
import { Check, X, AlertOctagon, Rocket, Sparkles, Terminal, Zap } from "lucide-react";
import { createCompany, getProvisioningStatus, CreateCompanyRequest } from "@/lib/api/provisioning";
/**
* Step 4 · 생성 진행 (시안 v2 포팅).
* 레이아웃: 좌측 (제목 + 큰 진행 + 단계 리스트 + 결과 배너) + 우측 (다크 로그 콘솔)
* Step 4 · 생성 진행 — 대대적 개편.
* 좌측: 중앙 pulsing orb + 진행 + 단계 타임라인 + 결과 배너
* 우측: 터미널 로그 (auto-scroll)
*
* 로직 (유지):
* - 마운트 즉시 POST /companies → jobId 취득 → 폴링 시작
@@ -43,13 +44,19 @@ export default function Step4Run({
const pollRef = useRef<NodeJS.Timeout | null>(null);
const startedRef = useRef(false);
const prevStepRef = useRef<string>("");
const logScrollRef = useRef<HTMLDivElement>(null);
// 로그 append 헬퍼
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;
@@ -98,7 +105,6 @@ export default function Step4Run({
if (cancelled) return;
setStatus(s);
// step 변화 감지 → 로그
if (s.currentStep && s.currentStep !== prevStepRef.current) {
const disp = DISPLAY_STEPS.find((d) => d.key === s.currentStep);
appendLog(`${s.currentStep}${disp ? ` · ${disp.label}` : ""}`);
@@ -155,36 +161,97 @@ export default function Step4Run({
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" ? "COMPLETED" : variant === "failed" ? "HALTED" : "IN PROGRESS";
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.4rem 1.6rem",
padding: "1.6rem 1.8rem",
display: "grid",
gridTemplateColumns: "1fr 320px",
gap: "1.6rem",
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.55rem",
fontSize: "0.72rem",
fontWeight: 700,
letterSpacing: "0.15em",
letterSpacing: "0.18em",
textTransform: "uppercase",
color: "var(--v5-cyan)",
marginBottom: 6,
color: accent,
marginBottom: 8,
fontFamily: "var(--v5-font-mono)",
}}
>
04 · PROVISIONING
04 ·
</div>
<h2
style={{
fontSize: "1.15rem",
fontSize: "1.4rem",
fontWeight: 800,
letterSpacing: "-0.02em",
margin: 0,
@@ -193,7 +260,7 @@ export default function Step4Run({
>
{variant === "success" ? "회사 생성 완료" : variant === "failed" ? "생성 실패" : "회사 생성 중"}
</h2>
<div style={{ fontSize: "0.7rem", color: "var(--v5-text-sec)", marginTop: 4 }}>
<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))" }}>
@@ -206,22 +273,134 @@ export default function Step4Run({
{variant === "running" && <> .</>}
</div>
{createError && (
<div style={{ marginTop: 6, fontSize: "0.66rem", color: "var(--v5-red)", fontFamily: "var(--v5-font-mono)" }}>
<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.2rem",
padding: "1.05rem 1.1rem",
marginTop: "1.3rem",
padding: "1.6rem 1.4rem 1.25rem",
border: "1px solid var(--v5-border)",
borderRadius: 10,
background: "var(--v5-surface-solid)",
boxShadow: variant === "running" ? "0 0 26px rgba(var(--v5-cyan-rgb), 0.1)" : "none",
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",
@@ -232,36 +411,53 @@ export default function Step4Run({
>
<div
style={{
fontSize: "0.56rem",
fontSize: "0.72rem",
fontWeight: 700,
letterSpacing: "0.12em",
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.3rem",
fontSize: "1.75rem",
fontWeight: 800,
letterSpacing: "-0.03em",
fontFamily: "var(--v5-font-mono)",
color: accent,
fontVariantNumeric: "tabular-nums",
}}
>
{progress}
<span style={{ fontSize: "0.75rem", color: "var(--v5-text-muted)" }}>%</span>
<span style={{ fontSize: "0.9rem", color: "var(--v5-text-muted)", marginLeft: 1 }}>%</span>
</div>
</div>
<div
style={{
height: 6,
borderRadius: 3,
height: 8,
borderRadius: 4,
background: "var(--v5-bg-subtle)",
overflow: "hidden",
position: "relative",
border: "1px solid var(--v5-border)",
}}
>
<div
@@ -270,12 +466,13 @@ export default function Step4Run({
width: `${progress}%`,
background:
variant === "success"
? "linear-gradient(90deg, rgb(var(--v5-green-rgb)), rgb(var(--v5-green-rgb)))"
? "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: variant === "running" ? "0 0 12px rgba(var(--v5-cyan-rgb), 0.5)" : "none",
transition: "width 0.4s",
boxShadow: `0 0 8px rgba(${accentRgb}, 0.35)`,
transition: "width 0.5s cubic-bezier(0.4, 0, 0.2, 1)",
position: "relative",
}}
/>
{variant === "running" && (
@@ -284,25 +481,26 @@ export default function Step4Run({
position: "absolute",
top: 0,
bottom: 0,
width: 40,
background: "linear-gradient(90deg, transparent, rgba(255,255,255,0.6), transparent)",
left: `${Math.max(0, progress - 8)}%`,
animation: "shimmer 1.5s ease-in-out infinite",
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.55rem",
marginTop: "0.7rem",
display: "flex",
justifyContent: "space-between",
fontFamily: "var(--v5-font-mono)",
fontSize: "0.55rem",
fontSize: "0.72rem",
color: "var(--v5-text-muted)",
fontWeight: 500,
}}
>
<span>PROV_ID · {jobId || "—"}</span>
<span> · {jobId || "—"}</span>
<span>
{elapsedSec}s
{variant === "running" && " · 진행 중"}
@@ -312,13 +510,13 @@ export default function Step4Run({
</div>
</div>
{/* 단계 리스트 */}
{/* 단계 타임라인 */}
<div
style={{
marginTop: "1rem",
padding: "0.4rem 1.1rem",
marginTop: "1.1rem",
padding: "0.5rem 1.2rem",
border: "1px solid var(--v5-border)",
borderRadius: 10,
borderRadius: 11,
background: "var(--v5-surface-solid)",
}}
>
@@ -338,41 +536,42 @@ export default function Step4Run({
{failed && (
<div
style={{
marginTop: "1rem",
padding: "0.85rem 1rem",
border: "1px solid rgba(var(--v5-red-rgb), 0.3)",
background: "rgba(var(--v5-red-rgb), 0.04)",
borderRadius: 10,
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.6rem",
fontSize: "0.75rem",
fontWeight: 700,
color: "var(--v5-red)",
letterSpacing: "0.12em",
textTransform: "uppercase",
marginBottom: 6,
marginBottom: 8,
display: "flex",
alignItems: "center",
gap: 6,
gap: 7,
fontFamily: "var(--v5-font-mono)",
}}
>
<AlertOctagon size={12} /> ERROR · {status?.failedStep || "—"}
<AlertOctagon size={14} /> · {status?.failedStep || "—"}
</div>
<div
style={{
fontFamily: "var(--v5-font-mono)",
fontSize: "0.65rem",
padding: "0.55rem 0.65rem",
fontSize: "0.82rem",
padding: "0.7rem 0.8rem",
background: "var(--v5-bg)",
borderRadius: 6,
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 || "—"}
@@ -384,46 +583,46 @@ export default function Step4Run({
{done && (
<div
style={{
marginTop: "1rem",
padding: "1rem 1.1rem",
border: "1px solid rgba(var(--v5-green-rgb), 0.3)",
background: "rgba(var(--v5-green-rgb), 0.04)",
borderRadius: 10,
boxShadow: "0 0 24px rgba(var(--v5-green-rgb), 0.1)",
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: "0.85rem",
gap: "1rem",
animation: "s4SuccessBurst 0.5s cubic-bezier(0.34, 1.56, 0.64, 1) both 0.2s",
}}
>
<div
style={{
width: 40,
height: 40,
borderRadius: 10,
background: "linear-gradient(135deg, rgb(var(--v5-green-rgb)), rgb(var(--v5-green-rgb)))",
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 14px rgba(var(--v5-green-rgb), 0.5)",
boxShadow: "0 0 12px rgba(var(--v5-green-rgb), 0.3)",
flexShrink: 0,
}}
>
<Check size={20} />
<Sparkles size={22} strokeWidth={1.75} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: "0.56rem",
fontSize: "0.72rem",
fontWeight: 700,
color: "rgb(var(--v5-green-rgb))",
letterSpacing: "0.15em",
textTransform: "uppercase",
fontFamily: "var(--v5-font-mono)",
letterSpacing: "0.08em",
}}
>
READY
</div>
<div style={{ fontSize: "0.85rem", fontWeight: 700, marginTop: 2, color: "var(--v5-text)" }}>
<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>
@@ -431,10 +630,11 @@ export default function Step4Run({
</div>
<div
style={{
fontSize: "0.6rem",
fontSize: "0.78rem",
color: "var(--v5-text-muted)",
marginTop: 2,
marginTop: 3,
fontFamily: "var(--v5-font-mono)",
fontWeight: 500,
}}
>
: {state.db_prefix}_admin / [Step 3 ]
@@ -444,54 +644,74 @@ export default function Step4Run({
)}
</div>
{/* 우측: 로그 콘솔 */}
{/* ── 우측: 터미널 로그 콘솔 ── */}
<div style={{ alignSelf: "start" }}>
<div
style={{
background: "#0a0a0c",
borderRadius: 10,
borderRadius: 11,
border: "1px solid var(--v5-border)",
overflow: "hidden",
boxShadow: "0 0 16px rgba(0, 0, 0, 0.25)",
}}
>
<div
style={{
padding: "0.55rem 0.7rem",
padding: "0.75rem 0.95rem",
borderBottom: "1px solid rgba(255,255,255,0.08)",
display: "flex",
alignItems: "center",
gap: 6,
gap: 8,
fontFamily: "var(--v5-font-mono)",
fontSize: "0.56rem",
letterSpacing: "0.12em",
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 6px ${accent}`,
boxShadow: `0 0 8px ${accent}`,
animation: variant === "running" ? "pulsedot 1.4s ease-in-out infinite" : "none",
}}
/>
PROVISIONING LOG
<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.55rem 0.7rem",
padding: "0.75rem 0.95rem",
fontFamily: "var(--v5-font-mono)",
fontSize: "0.58rem",
lineHeight: 1.6,
fontSize: "0.72rem",
lineHeight: 1.7,
color: "#d4d4dc",
maxHeight: 420,
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.join("\n")}
{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>
@@ -499,6 +719,13 @@ export default function Step4Run({
);
}
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,
@@ -526,20 +753,22 @@ function RunRow({
return (
<div
className="s4-step-row"
style={{
position: "relative",
display: "grid",
gridTemplateColumns: "32px 1fr 90px",
gap: "0.85rem",
padding: "0.7rem 0",
gridTemplateColumns: "40px 1fr 100px",
gap: "1rem",
padding: "0.85rem 0",
alignItems: "center",
animationDelay: `${idx * 60}ms`,
}}
>
{/* circle + connecting line */}
<div
style={{
position: "relative",
height: 32,
height: 38,
display: "flex",
justifyContent: "center",
}}
@@ -548,19 +777,19 @@ function RunRow({
<div
style={{
position: "absolute",
top: 26,
top: 32,
bottom: -18,
width: 2,
background: isDone ? "rgb(var(--v5-green-rgb))" : "var(--v5-border)",
left: 15,
transition: "background 0.3s",
left: 19,
transition: "background 0.4s ease",
}}
/>
)}
<div
style={{
width: 28,
height: 28,
width: 34,
height: 34,
borderRadius: "50%",
background: isFailed
? "var(--v5-red)"
@@ -573,26 +802,29 @@ function RunRow({
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "#fff",
color: isPending ? "var(--v5-text-muted)" : "#fff",
boxShadow: isActive
? "0 0 14px rgba(var(--v5-cyan-rgb), 0.5)"
? "0 0 10px rgba(var(--v5-cyan-rgb), 0.35)"
: isFailed
? "0 0 14px rgba(var(--v5-red-rgb), 0.4)"
: "none",
transition: "all 0.3s",
? "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={13} />}
{isFailed && <X size={13} />}
{isDone && <Check size={15} strokeWidth={2.5} />}
{isFailed && <X size={15} strokeWidth={2.5} />}
{isActive && (
<span
style={{
width: 10,
height: 10,
border: "2px solid rgba(255,255,255,0.35)",
width: 13,
height: 13,
border: "2.5px solid rgba(255,255,255,0.35)",
borderTopColor: "#fff",
borderRadius: "50%",
}}
@@ -602,7 +834,7 @@ function RunRow({
{isPending && (
<span
style={{
fontSize: "0.6rem",
fontSize: "0.78rem",
fontWeight: 700,
color: "var(--v5-text-muted)",
fontFamily: "var(--v5-font-mono)",
@@ -617,8 +849,9 @@ function RunRow({
<div>
<div
style={{
fontSize: "0.78rem",
fontSize: "0.92rem",
fontWeight: isActive ? 700 : 600,
letterSpacing: "-0.01em",
color: isFailed
? "var(--v5-red)"
: isActive
@@ -626,16 +859,27 @@ function RunRow({
: 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.6rem",
fontSize: "0.72rem",
color: "var(--v5-text-muted)",
fontFamily: "var(--v5-font-mono)",
marginTop: 2,
marginTop: 3,
fontWeight: 500,
}}
>
{step.sub.replace("%db%", dbName)}
@@ -645,8 +889,9 @@ function RunRow({
<div
style={{
textAlign: "right",
fontSize: "0.62rem",
fontSize: "0.76rem",
fontFamily: "var(--v5-font-mono)",
fontWeight: 600,
color: isFailed
? "var(--v5-red)"
: isDone
@@ -654,6 +899,7 @@ function RunRow({
: isActive
? "var(--v5-cyan)"
: "var(--v5-text-muted)",
transition: "color 0.3s ease",
}}
>
{isDone && "완료"}
@@ -29,19 +29,19 @@ export default function StepIndicator({ current }: { current: number }) {
flex: 1,
display: "flex",
alignItems: "center",
gap: "0.65rem",
padding: "0.75rem 1rem",
gap: "0.85rem",
padding: "0.95rem 1.2rem",
position: "relative",
background: active ? "rgba(var(--v5-cyan-rgb), 0.06)" : "transparent",
borderRight: i < WIZARD_STEPS.length - 1 ? "1px solid var(--v5-border)" : "none",
transition: "background 0.25s",
transition: "background 0.3s ease",
}}
>
<div
style={{
width: 26,
height: 26,
borderRadius: 7,
width: 34,
height: 34,
borderRadius: 9,
display: "flex",
alignItems: "center",
justifyContent: "center",
@@ -52,19 +52,20 @@ export default function StepIndicator({ current }: { current: number }) {
: "var(--v5-bg-subtle)",
color: done || active ? "#fff" : "var(--v5-text-muted)",
fontFamily: "var(--v5-font-mono)",
fontSize: "0.62rem",
fontSize: "0.82rem",
fontWeight: 700,
boxShadow: active ? "0 0 14px rgba(var(--v5-cyan-rgb), 0.5)" : "none",
transition: "all 0.25s",
boxShadow: active ? "0 0 10px rgba(var(--v5-cyan-rgb), 0.3)" : "none",
transition: "all 0.3s cubic-bezier(0.4,0,0.2,1)",
flexShrink: 0,
animation: active ? "wizPulseRing 2s ease-out infinite" : "none",
}}
>
{done ? <Check size={13} strokeWidth={2.5} /> : s.n}
{done ? <Check size={17} strokeWidth={2.5} /> : s.n}
</div>
<div style={{ minWidth: 0, flex: 1 }}>
<div
style={{
fontSize: "0.52rem",
fontSize: "0.65rem",
fontWeight: 700,
letterSpacing: "0.15em",
textTransform: "uppercase",
@@ -76,15 +77,16 @@ export default function StepIndicator({ current }: { current: number }) {
</div>
<div
style={{
fontSize: "0.75rem",
fontSize: "0.95rem",
fontWeight: active || done ? 700 : 500,
color: active
? "var(--v5-cyan)"
: done
? "var(--v5-text)"
: "var(--v5-text-sec)",
marginTop: 1,
marginTop: 2,
letterSpacing: "-0.01em",
transition: "color 0.3s ease",
}}
>
{s.label}
@@ -83,12 +83,12 @@ export default function Wizard({ onClose }: { onClose: () => void }) {
onClick={(e) => {
if (e.target === e.currentTarget) tryClose();
}}
className="wiz-overlay"
style={{
position: "fixed",
inset: 0,
zIndex: 100,
background: "rgba(6, 5, 14, 0.5)",
backdropFilter: "blur(2px)",
background: "rgba(6, 5, 14, 0.55)",
display: "flex",
alignItems: "center",
justifyContent: "center",
@@ -102,20 +102,101 @@ export default function Wizard({ onClose }: { onClose: () => void }) {
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(1180px, 100%)",
height: "min(820px, 100%)",
width: "min(1260px, 100%)",
height: "min(880px, 100%)",
display: "flex",
flexDirection: "column",
background: "var(--v5-bg)",
borderRadius: 12,
borderRadius: 14,
overflow: "hidden",
border: "1px solid var(--v5-border)",
boxShadow: "0 0 60px rgba(var(--v5-cyan-rgb), 0.14)",
fontFamily: "var(--v5-font-sans)",
boxShadow: "0 10px 40px rgba(0, 0, 0, 0.3), 0 0 30px rgba(var(--v5-cyan-rgb), 0.08)",
}}
>
{/* 브랜디드 헤더 */}
@@ -123,8 +204,8 @@ export default function Wizard({ onClose }: { onClose: () => void }) {
style={{
display: "flex",
alignItems: "center",
gap: "0.8rem",
padding: "0.85rem 1.1rem",
gap: "0.95rem",
padding: "1rem 1.3rem",
background: "var(--v5-surface-solid)",
borderBottom: "1px solid var(--v5-border)",
position: "relative",
@@ -132,39 +213,37 @@ export default function Wizard({ onClose }: { onClose: () => void }) {
>
<div
style={{
width: 30,
height: 30,
borderRadius: 9,
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 18px rgba(var(--v5-cyan-rgb), 0.4)",
boxShadow: "0 0 12px rgba(var(--v5-cyan-rgb), 0.3)",
flexShrink: 0,
}}
>
<Building2 size={16} strokeWidth={1.75} />
<Building2 size={20} strokeWidth={1.75} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: "0.55rem",
fontSize: "0.72rem",
fontWeight: 700,
letterSpacing: "0.18em",
letterSpacing: "0.02em",
color: "var(--v5-cyan)",
textTransform: "uppercase",
fontFamily: "var(--v5-font-mono)",
}}
>
INVION · PROVISIONING
</div>
<div
style={{
fontSize: "0.92rem",
fontSize: "1.15rem",
fontWeight: 800,
letterSpacing: "-0.02em",
marginTop: 1,
marginTop: 2,
color: "var(--v5-text)",
overflow: "hidden",
textOverflow: "ellipsis",
@@ -182,22 +261,23 @@ export default function Wizard({ onClose }: { onClose: () => void }) {
</div>
<div
style={{
fontSize: "0.58rem",
fontSize: "0.75rem",
color: "var(--v5-text-muted)",
fontFamily: "var(--v5-font-mono)",
letterSpacing: "0.12em",
letterSpacing: "0.02em",
flexShrink: 0,
fontWeight: 600,
}}
>
SUPER_ADMIN
</div>
<button
onClick={tryClose}
aria-label="닫기"
className="wiz-iconbtn"
style={{
width: 28,
height: 28,
borderRadius: 7,
width: 34,
height: 34,
borderRadius: 8,
border: "1px solid var(--v5-border)",
background: "transparent",
color: "var(--v5-text-sec)",
@@ -208,7 +288,7 @@ export default function Wizard({ onClose }: { onClose: () => void }) {
flexShrink: 0,
}}
>
<X size={14} />
<X size={16} />
</button>
{/* glow line */}
<div
@@ -246,12 +326,12 @@ export default function Wizard({ onClose }: { onClose: () => void }) {
{/* Footer (상황별) */}
<div
style={{
padding: "0.75rem 1.1rem",
padding: "0.9rem 1.3rem",
borderTop: "1px solid var(--v5-border)",
background: "var(--v5-surface-solid)",
display: "flex",
alignItems: "center",
gap: "0.5rem",
gap: "0.55rem",
}}
>
{step < 4 && (
@@ -259,10 +339,10 @@ export default function Wizard({ onClose }: { onClose: () => void }) {
<Btn variant="ghost" onClick={tryClose}>
</Btn>
<div style={{ flex: 1, fontSize: "0.6rem", color: "var(--v5-text-muted)", textAlign: "center" }}>
<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={11} strokeWidth={1.75} />} onClick={prev} disabled={step <= 1}>
<Btn variant="secondary" icon={<ArrowLeft size={13} strokeWidth={1.75} />} onClick={prev} disabled={step <= 1}>
</Btn>
{step < 3 ? (
@@ -270,13 +350,13 @@ export default function Wizard({ onClose }: { onClose: () => void }) {
variant="cyan"
onClick={next}
disabled={!canNext}
icon={<ArrowRight size={11} strokeWidth={1.75} />}
icon={<ArrowRight size={13} strokeWidth={1.75} />}
iconRight
>
</Btn>
) : (
<Btn variant="cyan" onClick={next} disabled={!canNext} icon={<Rocket size={11} strokeWidth={1.75} />}>
<Btn variant="cyan" onClick={next} disabled={!canNext} icon={<Rocket size={13} strokeWidth={1.75} />}>
</Btn>
)}
@@ -288,17 +368,18 @@ export default function Wizard({ onClose }: { onClose: () => void }) {
<div
style={{
flex: 1,
fontSize: "0.62rem",
color: "var(--v5-text-muted)",
fontSize: "0.76rem",
color: "rgb(var(--v5-green-rgb))",
fontFamily: "var(--v5-font-mono)",
fontWeight: 600,
}}
>
· AUDIT event
·
</div>
<Btn icon={<FileText size={11} strokeWidth={1.75} />}> </Btn>
<Btn icon={<FileText size={13} strokeWidth={1.75} />} disabled soon> </Btn>
<Btn
variant="cyan"
icon={<ArrowUpRight size={11} strokeWidth={1.75} />}
icon={<ArrowUpRight size={13} strokeWidth={1.75} />}
onClick={() => {
if (runDone.subdomain) {
window.open(`http://${runDone.subdomain}.invyone.com`, "_blank");
@@ -306,7 +387,7 @@ export default function Wizard({ onClose }: { onClose: () => void }) {
tryClose();
}}
>
</Btn>
</>
)}
@@ -316,21 +397,21 @@ export default function Wizard({ onClose }: { onClose: () => void }) {
<div
style={{
flex: 1,
fontSize: "0.62rem",
fontSize: "0.76rem",
color: "var(--v5-red)",
fontWeight: 600,
display: "inline-flex",
alignItems: "center",
gap: 5,
gap: 6,
}}
>
<AlertTriangle size={11} strokeWidth={1.75} />
<AlertTriangle size={14} strokeWidth={1.75} />
· DB
</div>
<Btn variant="danger" icon={<Trash2 size={11} strokeWidth={1.75} />}>
<Btn variant="danger" icon={<Trash2 size={13} strokeWidth={1.75} />} disabled soon>
(DB )
</Btn>
<Btn variant="cyan" icon={<RefreshCw size={11} strokeWidth={1.75} />} onClick={tryClose}>
<Btn variant="cyan" icon={<RefreshCw size={13} strokeWidth={1.75} />} onClick={tryClose}>
</Btn>
</>
@@ -341,17 +422,18 @@ export default function Wizard({ onClose }: { onClose: () => void }) {
<div
style={{
flex: 1,
fontSize: "0.62rem",
fontSize: "0.75rem",
color: "var(--v5-text-muted)",
display: "inline-flex",
alignItems: "center",
gap: 5,
gap: 6,
fontWeight: 500,
}}
>
<Info size={11} strokeWidth={1.75} />
<Info size={13} strokeWidth={1.75} />
.
</div>
<Btn variant="ghost" icon={<XOctagon size={11} strokeWidth={1.75} />} onClick={tryClose}>
<Btn variant="ghost" icon={<XOctagon size={13} strokeWidth={1.75} />} onClick={tryClose}>
</Btn>
<Btn variant="secondary" disabled>
@@ -372,6 +454,7 @@ function Btn({
variant = "secondary",
icon,
iconRight,
soon,
}: {
children: React.ReactNode;
onClick?: () => void;
@@ -379,6 +462,7 @@ function Btn({
variant?: "primary" | "secondary" | "ghost" | "cyan" | "danger";
icon?: React.ReactNode;
iconRight?: boolean;
soon?: boolean;
}) {
const styles: Record<string, React.CSSProperties> = {
primary: {
@@ -390,7 +474,7 @@ function Btn({
background: "var(--v5-cyan)",
color: "#fff",
borderColor: "var(--v5-cyan)",
boxShadow: disabled ? "none" : "0 0 18px rgba(var(--v5-cyan-rgb), 0.25)",
boxShadow: disabled ? "none" : "0 0 10px rgba(var(--v5-cyan-rgb), 0.15)",
},
secondary: {
background: "var(--v5-surface-solid)",
@@ -412,27 +496,48 @@ function Btn({
<button
onClick={disabled ? undefined : onClick}
disabled={disabled}
title={soon ? "준비 중인 기능입니다" : undefined}
className={`wiz-btn wiz-btn-${variant}`}
style={{
display: "inline-flex",
alignItems: "center",
gap: "0.35rem",
height: 30,
padding: "0 0.75rem",
borderRadius: 8,
fontSize: "0.7rem",
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",
transition: "all 0.2s cubic-bezier(0.4,0,0.2,1)",
opacity: disabled ? 0.45 : 1,
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>
);
}
@@ -27,23 +27,24 @@ export function Field({
children: React.ReactNode;
}) {
return (
<div style={{ gridColumn: full ? "1 / -1" : "auto", display: "flex", flexDirection: "column", gap: 4 }}>
<div style={{ gridColumn: full ? "1 / -1" : "auto", display: "flex", flexDirection: "column", gap: 5 }}>
<div
style={{
fontSize: "0.55rem",
fontSize: "0.7rem",
color: error ? "var(--v5-red)" : "var(--v5-text-muted)",
fontFamily: "var(--v5-font-mono)",
letterSpacing: "0.08em",
textTransform: "uppercase",
marginBottom: 1,
marginBottom: 2,
display: "flex",
alignItems: "center",
gap: 5,
gap: 6,
fontWeight: 600,
}}
>
<span>{label}</span>
{required && (
<span style={{ color: "var(--v5-red)", fontFamily: "sans-serif" }}></span>
<span style={{ color: "var(--v5-red)", fontFamily: "sans-serif", fontSize: "0.9rem" }}></span>
)}
{hint && (
<span
@@ -53,7 +54,7 @@ export function Field({
textTransform: "none",
letterSpacing: 0,
marginLeft: "auto",
fontSize: "0.58rem",
fontSize: "0.72rem",
fontWeight: 500,
}}
>
@@ -63,7 +64,7 @@ export function Field({
</div>
{children}
{error && (
<div style={{ fontSize: "0.58rem", color: "var(--v5-red)", fontFamily: "var(--v5-font-mono)" }}>{error}</div>
<div style={{ fontSize: "0.72rem", color: "var(--v5-red)", fontFamily: "var(--v5-font-mono)" }}>{error}</div>
)}
</div>
);
@@ -116,11 +117,11 @@ export function TextInput({
display: "flex",
alignItems: "stretch",
border: `1px solid ${border}`,
borderRadius: 6,
borderRadius: 7,
background: readOnly ? "var(--v5-bg-subtle)" : "var(--v5-surface-solid)",
overflow: "hidden",
boxShadow: glow,
transition: "all 0.15s",
transition: "all 0.18s ease",
}}
>
{prefix && (
@@ -128,12 +129,13 @@ export function TextInput({
style={{
display: "flex",
alignItems: "center",
padding: "0 0.5rem",
padding: "0 0.65rem",
background: "var(--v5-bg-subtle)",
borderRight: "1px solid var(--v5-border)",
fontFamily: "var(--v5-font-mono)",
fontSize: "0.62rem",
fontSize: "0.78rem",
color: "var(--v5-text-muted)",
fontWeight: 500,
}}
>
{prefix}
@@ -147,11 +149,11 @@ export function TextInput({
readOnly={readOnly}
style={{
flex: 1,
padding: size === "sm" ? "0.3rem 0.45rem" : "0.42rem 0.55rem",
padding: size === "sm" ? "0.4rem 0.55rem" : "0.55rem 0.7rem",
background: "transparent",
border: 0,
outline: "none",
fontSize: size === "sm" ? "0.65rem" : "0.7rem",
fontSize: size === "sm" ? "0.78rem" : "0.88rem",
color: "var(--v5-text)",
fontFamily: mono ? "var(--v5-font-mono)" : "inherit",
}}
@@ -161,12 +163,13 @@ export function TextInput({
style={{
display: "flex",
alignItems: "center",
padding: "0 0.5rem",
padding: "0 0.65rem",
background: "var(--v5-bg-subtle)",
borderLeft: "1px solid var(--v5-border)",
fontFamily: "var(--v5-font-mono)",
fontSize: "0.62rem",
fontSize: "0.78rem",
color: "var(--v5-text-muted)",
fontWeight: 500,
}}
>
{suffix}
@@ -178,7 +181,7 @@ export function TextInput({
export function CheckAvailBadge({ status, value }: { status: AvailStatus; value?: string }) {
if (!value || status === "idle")
return <span style={{ fontSize: "0.58rem", color: "var(--v5-text-muted)" }}> </span>;
return <span style={{ fontSize: "0.72rem", color: "var(--v5-text-muted)" }}> </span>;
if (status === "checking")
return (
<span
@@ -186,12 +189,13 @@ export function CheckAvailBadge({ status, value }: { status: AvailStatus; value?
display: "inline-flex",
alignItems: "center",
gap: 5,
fontSize: "0.58rem",
fontSize: "0.72rem",
color: "var(--v5-text-muted)",
fontFamily: "var(--v5-font-mono)",
fontWeight: 500,
}}
>
<Loader2 size={10} className="spin" />
<Loader2 size={12} className="spin" />
</span>
);
if (status === "available")
@@ -201,12 +205,12 @@ export function CheckAvailBadge({ status, value }: { status: AvailStatus; value?
display: "inline-flex",
alignItems: "center",
gap: 4,
fontSize: "0.58rem",
fontSize: "0.72rem",
color: "rgb(var(--v5-green-rgb))",
fontWeight: 600,
}}
>
<CheckCircle2 size={11} />
<CheckCircle2 size={13} />
</span>
);
if (status === "taken")
@@ -216,12 +220,12 @@ export function CheckAvailBadge({ status, value }: { status: AvailStatus; value?
display: "inline-flex",
alignItems: "center",
gap: 4,
fontSize: "0.58rem",
fontSize: "0.72rem",
color: "var(--v5-red)",
fontWeight: 600,
}}
>
<XCircle size={11} />
<XCircle size={13} />
</span>
);
return (
@@ -230,12 +234,12 @@ export function CheckAvailBadge({ status, value }: { status: AvailStatus; value?
display: "inline-flex",
alignItems: "center",
gap: 4,
fontSize: "0.58rem",
fontSize: "0.72rem",
color: "var(--v5-amber)",
fontWeight: 600,
}}
>
<XCircle size={11} />
<XCircle size={13} />
</span>
);
}
+4 -4
View File
@@ -17,11 +17,11 @@ const getApiBaseUrl = (): string => {
if (typeof window !== "undefined") {
const currentHost = window.location.hostname;
// 1. 테넌트 서브도메인 (*.invyone.com) 은 Next.js rewrite 우회 필수.
// NEXT_PUBLIC_API_URL=/api 쓰면 rewrite 가 Host 헤더를 invyone-backend-spring:8081 로
// 변조해서 서브도메인 파싱이 실패함. 직접 8083 으로 보내서 Host 헤더 보존.
// 1. 테넌트 서브도메인 (*.invyone.com) 은 NEXT_PUBLIC_API_URL rewrite 우회 필수.
// rewrite 가 Host 헤더를 변조하면 SubdomainResolverFilter 파싱 실패.
// 운영 Traefik 이 같은 호스트의 /api 를 backend 로 프록시 → 동일 호스트 상대 주소.
if (currentHost.endsWith(".invyone.com")) {
return `http://${currentHost}:8083/api`;
return `https://${currentHost}/api`;
}
// 2. 프로덕션 메인 도메인
+10
View File
@@ -61,6 +61,16 @@ spec:
configMapKeyRef:
name: invyone-config
key: FILE_UPLOAD_DIR
- name: CORS_ALLOWED_ORIGINS
valueFrom:
configMapKeyRef:
name: invyone-config
key: CORS_ALLOWED_ORIGINS
- name: TENANT_PROVISIONING_REQUIRE_SUPER_ADMIN
valueFrom:
configMapKeyRef:
name: invyone-config
key: TENANT_PROVISIONING_REQUIRE_SUPER_ADMIN
resources:
requests:
cpu: 250m
+4
View File
@@ -16,3 +16,7 @@ data:
CORS_ORIGIN: "https://solution.invyone.com"
NEXT_PUBLIC_API_URL: "https://solution.invyone.com/api"
NEXT_TELEMETRY_DISABLED: "1"
# 테넌트 서브도메인 멀티테넌시: 허용 CORS 오리진 (setAllowedOriginPatterns 매칭)
CORS_ALLOWED_ORIGINS: "https://solution.invyone.com,https://*.invyone.com,http://*.invyone.com:[*],https://*.invyone.com:[*]"
# 프로덕션: SUPER_ADMIN 만 회사 프로비저닝 허용 (개발 모드는 false)
TENANT_PROVISIONING_REQUIRE_SUPER_ADMIN: "true"