This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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. 프로덕션 메인 도메인
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user