Files
invyone/frontend/components/admin/provisioning/wizard/Step1Basic.tsx
T
johngreen 0e895a90fa feat(부서관리): V1 슬림 스코프 + 트리 컨텍스트 메뉴 UX 리디자인
백엔드:
- 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>
2026-05-08 08:34:23 +09:00

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>
);
}