Files
invyone/frontend/components/admin/provisioning/wizard/fields.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

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