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>
281 lines
7.1 KiB
TypeScript
281 lines
7.1 KiB
TypeScript
"use client";
|
|
|
|
import { Loader2, CheckCircle2, XCircle } from "lucide-react";
|
|
|
|
/**
|
|
* 마법사 전용 공용 입력 컴포넌트 (시안 v2 포팅).
|
|
* - Field: label + hint + child input wrapper
|
|
* - TextInput: 텍스트 입력 (mono, prefix/suffix, status 테두리 글로우)
|
|
* - CheckAvailBadge: subdomain/db_prefix 실시간 검증 인디케이터
|
|
*/
|
|
|
|
export type AvailStatus = "idle" | "checking" | "available" | "taken" | "reserved" | "invalid";
|
|
|
|
export function Field({
|
|
label,
|
|
hint,
|
|
required,
|
|
full,
|
|
error,
|
|
children,
|
|
}: {
|
|
label: string;
|
|
hint?: React.ReactNode;
|
|
required?: boolean;
|
|
full?: boolean;
|
|
error?: string;
|
|
children: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<div style={{ gridColumn: full ? "1 / -1" : "auto", display: "flex", flexDirection: "column", gap: 5 }}>
|
|
<div
|
|
style={{
|
|
fontSize: "0.75rem",
|
|
color: error ? "var(--v5-red)" : "var(--v5-text-muted)",
|
|
fontFamily: "var(--v5-font-mono)",
|
|
letterSpacing: "0.08em",
|
|
textTransform: "uppercase",
|
|
marginBottom: 3,
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 6,
|
|
fontWeight: 600,
|
|
}}
|
|
>
|
|
<span>{label}</span>
|
|
{required && (
|
|
<span style={{ color: "var(--v5-red)", fontFamily: "sans-serif", fontSize: "0.9rem" }}>•</span>
|
|
)}
|
|
{hint && (
|
|
<span
|
|
style={{
|
|
color: "var(--v5-text-muted)",
|
|
fontFamily: "inherit",
|
|
textTransform: "none",
|
|
letterSpacing: 0,
|
|
marginLeft: "auto",
|
|
fontSize: "0.72rem",
|
|
fontWeight: 500,
|
|
}}
|
|
>
|
|
{hint}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{children}
|
|
{error && (
|
|
<div style={{ fontSize: "0.72rem", color: "var(--v5-red)", fontFamily: "var(--v5-font-mono)" }}>{error}</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
type TextInputStatus = "ok" | "err" | "warn";
|
|
|
|
export function TextInput({
|
|
value,
|
|
onChange,
|
|
placeholder,
|
|
mono,
|
|
prefix,
|
|
suffix,
|
|
readOnly,
|
|
type = "text",
|
|
status,
|
|
size = "md",
|
|
}: {
|
|
value: string;
|
|
onChange?: (v: string) => void;
|
|
placeholder?: string;
|
|
mono?: boolean;
|
|
prefix?: React.ReactNode;
|
|
suffix?: React.ReactNode;
|
|
readOnly?: boolean;
|
|
type?: string;
|
|
status?: TextInputStatus;
|
|
size?: "sm" | "md";
|
|
}) {
|
|
const border =
|
|
status === "ok"
|
|
? "rgb(var(--v5-green-rgb))"
|
|
: status === "err"
|
|
? "var(--v5-red)"
|
|
: status === "warn"
|
|
? "var(--v5-amber)"
|
|
: "var(--v5-border)";
|
|
const glow =
|
|
status === "ok"
|
|
? "0 0 0 3px rgba(var(--v5-green-rgb), 0.12)"
|
|
: status === "err"
|
|
? "0 0 0 3px rgba(var(--v5-red-rgb), 0.15)"
|
|
: status === "warn"
|
|
? "0 0 0 3px rgba(var(--v5-amber-rgb), 0.12)"
|
|
: "none";
|
|
return (
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "stretch",
|
|
border: `1px solid ${border}`,
|
|
borderRadius: 7,
|
|
background: readOnly ? "var(--v5-bg-subtle)" : "var(--v5-surface-solid)",
|
|
overflow: "hidden",
|
|
boxShadow: glow,
|
|
transition: "all 0.18s ease",
|
|
}}
|
|
>
|
|
{prefix && (
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
padding: "0 0.65rem",
|
|
background: "var(--v5-bg-subtle)",
|
|
borderRight: "1px solid var(--v5-border)",
|
|
fontFamily: "var(--v5-font-mono)",
|
|
fontSize: "0.78rem",
|
|
color: "var(--v5-text-muted)",
|
|
fontWeight: 500,
|
|
}}
|
|
>
|
|
{prefix}
|
|
</div>
|
|
)}
|
|
<input
|
|
type={type}
|
|
value={value || ""}
|
|
onChange={(e) => onChange?.(e.target.value)}
|
|
placeholder={placeholder}
|
|
readOnly={readOnly}
|
|
style={{
|
|
flex: 1,
|
|
padding: size === "sm" ? "0.4rem 0.55rem" : "0.55rem 0.7rem",
|
|
background: "transparent",
|
|
border: 0,
|
|
outline: "none",
|
|
fontSize: size === "sm" ? "0.78rem" : "0.88rem",
|
|
color: "var(--v5-text)",
|
|
fontFamily: mono ? "var(--v5-font-mono)" : "inherit",
|
|
}}
|
|
/>
|
|
{suffix && (
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
padding: "0 0.65rem",
|
|
background: "var(--v5-bg-subtle)",
|
|
borderLeft: "1px solid var(--v5-border)",
|
|
fontFamily: "var(--v5-font-mono)",
|
|
fontSize: "0.78rem",
|
|
color: "var(--v5-text-muted)",
|
|
fontWeight: 500,
|
|
}}
|
|
>
|
|
{suffix}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function CheckAvailBadge({ status, value }: { status: AvailStatus; value?: string }) {
|
|
if (!value || status === "idle")
|
|
return <span style={{ fontSize: "0.72rem", color: "var(--v5-text-muted)" }}>입력 후 자동 확인</span>;
|
|
if (status === "checking")
|
|
return (
|
|
<span
|
|
style={{
|
|
display: "inline-flex",
|
|
alignItems: "center",
|
|
gap: 5,
|
|
fontSize: "0.72rem",
|
|
color: "var(--v5-text-muted)",
|
|
fontFamily: "var(--v5-font-mono)",
|
|
fontWeight: 500,
|
|
}}
|
|
>
|
|
<Loader2 size={12} className="spin" /> 중복 확인 중…
|
|
</span>
|
|
);
|
|
if (status === "available")
|
|
return (
|
|
<span
|
|
style={{
|
|
display: "inline-flex",
|
|
alignItems: "center",
|
|
gap: 4,
|
|
fontSize: "0.72rem",
|
|
color: "rgb(var(--v5-green-rgb))",
|
|
fontWeight: 600,
|
|
}}
|
|
>
|
|
<CheckCircle2 size={13} /> 사용 가능
|
|
</span>
|
|
);
|
|
if (status === "taken")
|
|
return (
|
|
<span
|
|
style={{
|
|
display: "inline-flex",
|
|
alignItems: "center",
|
|
gap: 4,
|
|
fontSize: "0.72rem",
|
|
color: "var(--v5-red)",
|
|
fontWeight: 600,
|
|
}}
|
|
>
|
|
<XCircle size={13} /> 이미 사용 중
|
|
</span>
|
|
);
|
|
if (status === "reserved")
|
|
return (
|
|
<span
|
|
style={{
|
|
display: "inline-flex",
|
|
alignItems: "center",
|
|
gap: 4,
|
|
fontSize: "0.72rem",
|
|
color: "var(--v5-red)",
|
|
fontWeight: 600,
|
|
}}
|
|
>
|
|
<XCircle size={13} /> 예약어 (사용 불가)
|
|
</span>
|
|
);
|
|
return (
|
|
<span
|
|
style={{
|
|
display: "inline-flex",
|
|
alignItems: "center",
|
|
gap: 4,
|
|
fontSize: "0.72rem",
|
|
color: "var(--v5-amber)",
|
|
fontWeight: 600,
|
|
}}
|
|
>
|
|
<XCircle size={13} /> 형식 오류
|
|
</span>
|
|
);
|
|
}
|
|
|
|
/** TextInput 의 status prop 과 매핑 */
|
|
export function availToInputStatus(a: AvailStatus): TextInputStatus | undefined {
|
|
if (a === "available") return "ok";
|
|
if (a === "taken" || a === "reserved" || a === "invalid") return "err";
|
|
return undefined;
|
|
}
|
|
|
|
export function genPassword(length = 12): string {
|
|
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789@#$";
|
|
const arr = new Uint32Array(length);
|
|
if (typeof window !== "undefined" && window.crypto) {
|
|
window.crypto.getRandomValues(arr);
|
|
}
|
|
let out = "";
|
|
for (let i = 0; i < length; i++) {
|
|
out += chars[arr[i] % chars.length];
|
|
}
|
|
return out;
|
|
}
|