402 lines
13 KiB
TypeScript
402 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
|
|
style={{
|
|
padding: "1.4rem 1.6rem",
|
|
display: "grid",
|
|
gridTemplateColumns: "1fr 320px",
|
|
gap: "1.6rem",
|
|
}}
|
|
>
|
|
{/* 왼쪽: 폼 */}
|
|
<div>
|
|
<div
|
|
style={{
|
|
fontSize: "0.55rem",
|
|
fontWeight: 700,
|
|
letterSpacing: "0.15em",
|
|
textTransform: "uppercase",
|
|
color: "var(--v5-cyan)",
|
|
marginBottom: 6,
|
|
fontFamily: "var(--v5-font-mono)",
|
|
}}
|
|
>
|
|
01 · BASIC
|
|
</div>
|
|
<h2 style={{ fontSize: "1.15rem", fontWeight: 800, letterSpacing: "-0.02em", margin: 0, color: "var(--v5-text)" }}>
|
|
회사 기본 정보
|
|
</h2>
|
|
<div style={{ fontSize: "0.7rem", color: "var(--v5-text-sec)", marginTop: 4 }}>
|
|
subdomain 을 입력하면 접속 URL 과 DB 이름이 자동 결정됩니다. 생성 후에는 변경 불가합니다.
|
|
</div>
|
|
|
|
{/* ── 식별자 ── */}
|
|
<div style={{ marginTop: "1.2rem" }}>
|
|
<SectionHead
|
|
icon={<LinkIcon size={11} strokeWidth={1.75} />}
|
|
label="식별자"
|
|
hint="생성 후 변경 불가"
|
|
/>
|
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.7rem 0.85rem" }}>
|
|
<Field
|
|
label="SUBDOMAIN"
|
|
required
|
|
hint={<CheckAvailBadge status={subStatus} value={state.subdomain} />}
|
|
>
|
|
<TextInput
|
|
value={state.subdomain || ""}
|
|
onChange={onSubChange}
|
|
placeholder="qnc"
|
|
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="qnc"
|
|
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="QNC"
|
|
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.2rem" }}>
|
|
<SectionHead icon={<Briefcase size={11} strokeWidth={1.75} />} label="법인 정보" />
|
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.7rem 0.85rem" }}>
|
|
<Field label="사업자번호">
|
|
<TextInput
|
|
value={state.business_registration_number || ""}
|
|
onChange={(v) => setState({ business_registration_number: v })}
|
|
placeholder="123-45-67890"
|
|
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-1234-5678"
|
|
mono
|
|
/>
|
|
</Field>
|
|
<Field label="담당자 이메일">
|
|
<TextInput
|
|
value={state.email || ""}
|
|
onChange={(v) => setState({ email: v })}
|
|
placeholder="admin@company.kr"
|
|
mono
|
|
/>
|
|
</Field>
|
|
<Field label="회사 주소" full>
|
|
<TextInput
|
|
value={state.address || ""}
|
|
onChange={(v) => setState({ address: v })}
|
|
placeholder="서울특별시 강남구 테헤란로 123"
|
|
/>
|
|
</Field>
|
|
<Field label="웹사이트" full>
|
|
<TextInput
|
|
value={state.website || ""}
|
|
onChange={(v) => setState({ website: v })}
|
|
placeholder="https://company.kr"
|
|
mono
|
|
/>
|
|
</Field>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 오른쪽: LIVE PREVIEW */}
|
|
<div style={{ position: "sticky", top: 0, alignSelf: "start" }}>
|
|
<div
|
|
style={{
|
|
padding: "0.95rem 1rem",
|
|
background: "var(--v5-surface-solid)",
|
|
border: "1px solid var(--v5-border)",
|
|
borderRadius: 10,
|
|
boxShadow: "0 0 24px rgba(var(--v5-cyan-rgb), 0.08)",
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
fontSize: "0.52rem",
|
|
fontWeight: 700,
|
|
letterSpacing: "0.18em",
|
|
textTransform: "uppercase",
|
|
color: "var(--v5-cyan)",
|
|
marginBottom: "0.65rem",
|
|
fontFamily: "var(--v5-font-mono)",
|
|
}}
|
|
>
|
|
LIVE PREVIEW
|
|
</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={11} 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.6rem 0.7rem",
|
|
background: "rgba(var(--v5-amber-rgb), 0.08)",
|
|
border: "1px solid rgba(var(--v5-amber-rgb), 0.3)",
|
|
borderRadius: 6,
|
|
fontSize: "0.62rem",
|
|
color: "var(--v5-amber)",
|
|
lineHeight: 1.5,
|
|
display: "flex",
|
|
alignItems: "flex-start",
|
|
gap: 6,
|
|
marginTop: "0.4rem",
|
|
}}
|
|
>
|
|
<AlertTriangle size={11} 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.58rem",
|
|
fontWeight: 700,
|
|
color: "var(--v5-text-sec)",
|
|
letterSpacing: "0.1em",
|
|
textTransform: "uppercase",
|
|
marginBottom: "0.55rem",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 6,
|
|
fontFamily: "var(--v5-font-mono)",
|
|
}}
|
|
>
|
|
{icon}
|
|
<span>{label}</span>
|
|
{hint && (
|
|
<span
|
|
style={{
|
|
color: "var(--v5-text-muted)",
|
|
fontWeight: 400,
|
|
textTransform: "none",
|
|
letterSpacing: 0,
|
|
}}
|
|
>
|
|
— {hint}
|
|
</span>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PreviewField({ label, children }: { label: string; children: React.ReactNode }) {
|
|
return (
|
|
<div style={{ marginBottom: "0.85rem" }}>
|
|
<div
|
|
style={{
|
|
fontSize: "0.52rem",
|
|
color: "var(--v5-text-muted)",
|
|
letterSpacing: "0.08em",
|
|
textTransform: "uppercase",
|
|
fontFamily: "var(--v5-font-mono)",
|
|
marginBottom: 3,
|
|
}}
|
|
>
|
|
{label}
|
|
</div>
|
|
<div
|
|
style={{
|
|
padding: "0.5rem 0.6rem",
|
|
background: "var(--v5-bg)",
|
|
border: "1px solid var(--v5-border)",
|
|
borderRadius: 6,
|
|
fontFamily: "var(--v5-font-mono)",
|
|
fontSize: "0.72rem",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 5,
|
|
}}
|
|
>
|
|
{children}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|