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

408 lines
13 KiB
TypeScript

"use client";
import { useEffect, useRef, useState } from "react";
import { Link as LinkIcon, Briefcase, AlertTriangle, Database } from "lucide-react";
import { checkAvailability } from "@/lib/api/provisioning";
import {
Field,
TextInput,
CheckAvailBadge,
AvailStatus,
availToInputStatus,
} from "./fields";
/**
* Step 1 · 회사 기본 정보 (시안 v2 포팅).
* 좌측: 식별자 섹션 + 법인정보 섹션 (둘 다 기본 노출)
* 우측: LIVE PREVIEW 카드 (접속 URL / DB 이름 / 회사 코드 / 주의)
*
* 로직 (유지):
* - subdomain 입력 시 db_prefix / company_code 자동 제안 (사용자 수정 전까지)
* - debounce 350ms 로 백엔드 /check 호출
* - valid: 3개 식별자 available + company_name 존재
*/
export default function Step1Basic({
state,
setState,
onValidChange,
}: {
state: Record<string, any>;
setState: (patch: Record<string, any>) => void;
onValidChange: (valid: boolean) => void;
}) {
const [subStatus, setSubStatus] = useState<AvailStatus>("idle");
const [prefStatus, setPrefStatus] = useState<AvailStatus>("idle");
const [codeStatus, setCodeStatus] = useState<AvailStatus>("idle");
const timerRef = useRef<NodeJS.Timeout | null>(null);
function onSubChange(v: string) {
const sub = v.toLowerCase().replace(/[^a-z0-9-]/g, "");
const patch: Record<string, any> = { subdomain: sub };
if (!state.db_prefix_manual) {
patch.db_prefix = sub.replace(/-/g, "_");
}
if (!state.company_code_manual) {
patch.company_code = sub.toUpperCase().replace(/-/g, "_");
}
setState(patch);
}
function onDbPrefixChange(v: string) {
setState({ db_prefix: v.toLowerCase().replace(/[^a-z0-9_]/g, ""), db_prefix_manual: true });
}
function onCompanyCodeChange(v: string) {
setState({ company_code: v.toUpperCase().replace(/[^A-Z0-9_]/g, ""), company_code_manual: true });
}
useEffect(() => {
if (timerRef.current) clearTimeout(timerRef.current);
setSubStatus(state.subdomain ? "checking" : "idle");
setPrefStatus(state.db_prefix ? "checking" : "idle");
setCodeStatus(state.company_code ? "checking" : "idle");
const payload = {
subdomain: state.subdomain,
dbPrefix: state.db_prefix,
companyCode: state.company_code,
};
if (!payload.subdomain && !payload.dbPrefix && !payload.companyCode) return;
timerRef.current = setTimeout(async () => {
try {
const r: any = await checkAvailability(payload);
if (payload.subdomain === state.subdomain) {
const sub = r?.subdomain;
setSubStatus(
!sub ? "idle" : !sub.valid_format || sub.reserved ? "invalid" : sub.available ? "available" : "taken",
);
}
if (payload.dbPrefix === state.db_prefix) {
const pref = r?.db_prefix;
setPrefStatus(!pref ? "idle" : !pref.valid_format ? "invalid" : pref.available ? "available" : "taken");
}
if (payload.companyCode === state.company_code) {
const cc = r?.company_code;
setCodeStatus(!cc ? "idle" : !cc.valid_format ? "invalid" : cc.available ? "available" : "taken");
}
} catch {
setSubStatus("idle");
setPrefStatus("idle");
setCodeStatus("idle");
}
}, 350);
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, [state.subdomain, state.db_prefix, state.company_code]);
useEffect(() => {
const valid =
subStatus === "available" &&
prefStatus === "available" &&
codeStatus === "available" &&
!!state.company_name;
onValidChange(valid);
}, [subStatus, prefStatus, codeStatus, state.company_name, onValidChange]);
return (
<div
className="wiz-step"
style={{
padding: "1.6rem 1.8rem",
display: "grid",
gridTemplateColumns: "1fr 360px",
gap: "1.8rem",
animation: "wizSlideUp 0.35s cubic-bezier(0.16, 1, 0.3, 1)",
}}
>
{/* 왼쪽: 폼 */}
<div>
<div
style={{
fontSize: "0.72rem",
fontWeight: 700,
letterSpacing: "0.18em",
textTransform: "uppercase",
color: "var(--v5-cyan)",
marginBottom: 8,
fontFamily: "var(--v5-font-mono)",
}}
>
01 ·
</div>
<h2 style={{ fontSize: "1.4rem", fontWeight: 800, letterSpacing: "-0.02em", margin: 0, color: "var(--v5-text)" }}>
</h2>
<div style={{ fontSize: "0.85rem", color: "var(--v5-text-sec)", marginTop: 6, fontWeight: 500 }}>
subdomain URL DB . .
</div>
{/* ── 식별자 ── */}
<div style={{ marginTop: "1.4rem" }}>
<SectionHead
icon={<LinkIcon size={13} strokeWidth={1.75} />}
label="식별자"
hint="생성 후 변경 불가"
/>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.9rem 1rem" }}>
<Field
label="SUBDOMAIN"
required
hint={<CheckAvailBadge status={subStatus} value={state.subdomain} />}
>
<TextInput
value={state.subdomain || ""}
onChange={onSubChange}
placeholder="영문 소문자 · 숫자 · 하이픈"
suffix=".invyone.com"
mono
status={availToInputStatus(subStatus)}
/>
</Field>
<Field
label="DB_PREFIX"
required
hint={<CheckAvailBadge status={prefStatus} value={state.db_prefix} />}
>
<TextInput
value={state.db_prefix || ""}
onChange={onDbPrefixChange}
placeholder="영문 소문자 · 숫자 · 밑줄"
suffix="_vexplor"
mono
status={availToInputStatus(prefStatus)}
/>
</Field>
<Field
label="COMPANY_CODE"
required
hint={<CheckAvailBadge status={codeStatus} value={state.company_code} />}
>
<TextInput
value={state.company_code || ""}
onChange={onCompanyCodeChange}
placeholder="영문 대문자 · 숫자 · 밑줄"
mono
status={availToInputStatus(codeStatus)}
/>
</Field>
<Field label="회사명 (표시)" required>
<TextInput
value={state.company_name || ""}
onChange={(v) => setState({ company_name: v })}
placeholder="화면·문서에 표시될 회사 이름"
/>
</Field>
</div>
</div>
{/* ── 법인 정보 ── */}
<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="xxx-xx-xxxxx"
mono
/>
</Field>
<Field label="대표자명">
<TextInput
value={state.representative_name || ""}
onChange={(v) => setState({ representative_name: v })}
placeholder="대표자 성함"
/>
</Field>
<Field label="대표 연락처">
<TextInput
value={state.representative_phone || ""}
onChange={(v) => setState({ representative_phone: v })}
placeholder="02-0000-0000"
mono
/>
</Field>
<Field label="담당자 이메일">
<TextInput
value={state.email || ""}
onChange={(v) => setState({ email: v })}
placeholder="admin@example.com"
mono
/>
</Field>
<Field label="회사 주소" full>
<TextInput
value={state.address || ""}
onChange={(v) => setState({ address: v })}
placeholder="도로명 주소"
/>
</Field>
<Field label="웹사이트" full>
<TextInput
value={state.website || ""}
onChange={(v) => setState({ website: v })}
placeholder="https://"
mono
/>
</Field>
</div>
</div>
</div>
{/* 오른쪽: LIVE PREVIEW */}
<div style={{ position: "sticky", top: 0, alignSelf: "start" }}>
<div
style={{
padding: "1.1rem 1.2rem",
background: "var(--v5-surface-solid)",
border: "1px solid var(--v5-border)",
borderRadius: 11,
boxShadow: "0 0 14px rgba(var(--v5-cyan-rgb), 0.05)",
}}
>
<div
style={{
fontSize: "0.68rem",
fontWeight: 700,
letterSpacing: "0.2em",
textTransform: "uppercase",
color: "var(--v5-cyan)",
marginBottom: "0.85rem",
fontFamily: "var(--v5-font-mono)",
}}
>
</div>
<PreviewField label="접속 URL">
<span style={{ color: "var(--v5-text-muted)" }}>https://</span>
<span style={{ color: "var(--v5-cyan)", fontWeight: 700 }}>
{state.subdomain || "___"}
</span>
<span style={{ color: "var(--v5-text-sec)" }}>.invyone.com</span>
<span style={{ flex: 1 }} />
<CheckAvailBadge status={subStatus} value={state.subdomain} />
</PreviewField>
<PreviewField label="DB 이름">
<Database size={13} color="var(--v5-text-muted)" />
<span style={{ color: "var(--v5-primary)", fontWeight: 700 }}>
{state.db_prefix || "___"}
</span>
<span style={{ color: "var(--v5-text-sec)" }}>_vexplor</span>
</PreviewField>
<PreviewField label="회사 코드">
{state.company_code ? (
<span style={{ color: "var(--v5-text)" }}>{state.company_code}</span>
) : (
<span style={{ color: "var(--v5-text-muted)" }}>___</span>
)}
</PreviewField>
<div
style={{
padding: "0.75rem 0.85rem",
background: "rgba(var(--v5-amber-rgb), 0.08)",
border: "1px solid rgba(var(--v5-amber-rgb), 0.3)",
borderRadius: 7,
fontSize: "0.78rem",
color: "var(--v5-amber)",
lineHeight: 1.5,
display: "flex",
alignItems: "flex-start",
gap: 7,
marginTop: "0.55rem",
fontWeight: 500,
}}
>
<AlertTriangle size={14} strokeWidth={1.75} style={{ marginTop: 2, flexShrink: 0 }} />
<div>
<b></b> subdomain db_prefix .
</div>
</div>
</div>
</div>
</div>
);
}
function SectionHead({
icon,
label,
hint,
}: {
icon: React.ReactNode;
label: string;
hint?: string;
}) {
return (
<div
style={{
fontSize: "0.75rem",
fontWeight: 700,
color: "var(--v5-text-sec)",
letterSpacing: "0.1em",
textTransform: "uppercase",
marginBottom: "0.75rem",
display: "flex",
alignItems: "center",
gap: 7,
fontFamily: "var(--v5-font-mono)",
}}
>
{icon}
<span>{label}</span>
{hint && (
<span
style={{
color: "var(--v5-text-muted)",
fontWeight: 400,
textTransform: "none",
letterSpacing: 0,
fontSize: "0.72rem",
}}
>
{hint}
</span>
)}
</div>
);
}
function PreviewField({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div style={{ marginBottom: "1rem" }}>
<div
style={{
fontSize: "0.68rem",
color: "var(--v5-text-muted)",
letterSpacing: "0.08em",
textTransform: "uppercase",
fontFamily: "var(--v5-font-mono)",
marginBottom: 5,
fontWeight: 600,
}}
>
{label}
</div>
<div
style={{
padding: "0.6rem 0.75rem",
background: "var(--v5-bg)",
border: "1px solid var(--v5-border)",
borderRadius: 7,
fontFamily: "var(--v5-font-mono)",
fontSize: "0.85rem",
fontWeight: 500,
display: "flex",
alignItems: "center",
gap: 6,
}}
>
{children}
</div>
</div>
);
}