0e895a90fa
백엔드: - V018 soft-delete (deleted_at 컬럼) + 휴지통/복구 흐름 - V019 미사용 컬럼 cleanup (V1 슬림 스코프) - DepartmentService.updateDepartment 에 parent_dept_code 사이클 가드 (자기 자신/자손을 부모로 지정 시도 차단) - DepartmentController, mapper 갱신 프론트: - 부서관리 페이지(deptMngList) UX 리디자인 - 트리 노드 ⋮ 컨텍스트 메뉴 (하위 추가, 다른 부서 아래로 이동, 정렬 4단계, 삭제) - 헤더 breadcrumb 으로 부서 위치 상시 표시 - 폼의 상위부서 row 제거 (트리 ⋮ 로 진입점 일원화) - 빈 상태 placeholder + X 닫기 동작 - 토글 버튼 토스 스타일 (아이콘 + 툴팁, 일정한 위치) - 부서유형 row 좁은 화면 가로 오버플로 fix - DepartmentPicker 신규 재사용 컴포넌트 (자손 자동 exclude, 사이클 차단) - 회사관리/프로비저닝 폼 개선 (Step1Basic, fields, CompanyTable, AdminPageRenderer) - companyList/[companyCode]/departments 구버전 페이지 삭제 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
419 lines
14 KiB
TypeScript
419 lines
14 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;
|
|
// 우선순위: reserved > valid_format > available
|
|
// 백엔드 isValidSubdomain 이 reserved 도 false 로 잡아내므로 reserved 를 먼저 검사해야
|
|
// "예약어" 케이스가 "형식 오류" 로 묻히지 않는다.
|
|
setSubStatus(
|
|
!sub
|
|
? "idle"
|
|
: sub.reserved
|
|
? "reserved"
|
|
: !sub.valid_format
|
|
? "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="_invyone"
|
|
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)" }}>_invyone</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>
|
|
);
|
|
}
|