ERP-node/frontend/lib/hooks/useDialogAutoValidation.ts

206 lines
6.7 KiB
TypeScript

"use client";
import { useEffect } from "react";
import { useTabStore } from "@/stores/tabStore";
import { toast } from "sonner";
const HIGHLIGHT_ATTR = "data-validation-highlight";
const ERROR_ATTR = "data-validation-error";
type TargetEl = HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement | HTMLButtonElement;
/**
* 모달 필수 입력 검증 훅 (클릭 인터셉트 방식)
*
* 저장/수정/확인 버튼 클릭 시 빈 필수 필드가 있으면:
* 1. 클릭 이벤트 차단
* 2. 첫 번째 빈 필드로 포커스 이동 + 하이라이트
* 3. 빈 필드에 빨간 테두리 유지 (값 입력 시 해제)
* 4. 토스트 알림 표시
*
* 설계: docs/ycshin-node/필수입력항목_자동검증_설계.md
*/
export function useDialogAutoValidation(contentEl: HTMLElement | null) {
const mode = useTabStore((s) => s.mode);
useEffect(() => {
if (mode !== "user") return;
const el = contentEl;
if (!el) return;
const errorFields = new Set<TargetEl>();
let activated = false; // 첫 저장 시도 이후 true
function findRequiredFields(): Map<TargetEl, string> {
const fields = new Map<TargetEl, string>();
if (!el) return fields;
el.querySelectorAll("label").forEach((label) => {
const hasRequiredMark = Array.from(label.querySelectorAll("span")).some(
(span) => span.textContent?.trim() === "*",
);
if (!hasRequiredMark) return;
const forId = label.getAttribute("for") || (label as HTMLLabelElement).htmlFor;
let target: TargetEl | null = null;
if (forId) {
try {
const found = el!.querySelector(`#${CSS.escape(forId)}`);
if (isFormElement(found)) {
target = found;
} else if (found) {
const inner = found.querySelector("input, textarea, select");
if (isFormElement(inner) && !isHiddenRadixSelect(inner)) {
target = inner;
}
// 숨겨진 Radix select이거나 폼 요소가 없으면 → 트리거 버튼 탐색
if (!target) {
const btn = found.querySelector('button[role="combobox"], button[data-slot="select-trigger"]');
if (btn instanceof HTMLButtonElement) target = btn;
}
}
} catch {
/* invalid id */
}
}
if (!target) {
const parent = label.closest('[class*="space-y"]') || label.parentElement;
if (parent) {
const inner = parent.querySelector("input, textarea, select");
if (isFormElement(inner) && !isHiddenRadixSelect(inner)) {
target = inner;
}
if (!target) {
const btn = parent.querySelector('button[role="combobox"], button[data-slot="select-trigger"]');
if (btn instanceof HTMLButtonElement) target = btn;
}
}
}
if (target) {
const labelText = label.textContent?.replace(/\*/g, "").trim() || "";
fields.set(target, labelText);
}
});
return fields;
}
function isFormElement(el: Element | null): el is HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement {
return el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement || el instanceof HTMLSelectElement;
}
function isHiddenRadixSelect(el: Element): boolean {
return el instanceof HTMLSelectElement && el.hasAttribute("aria-hidden");
}
function isEmpty(input: TargetEl): boolean {
if (input instanceof HTMLButtonElement) {
// Radix Select: data-placeholder 속성이 자식 span에 있으면 미선택 상태
return !!input.querySelector("[data-placeholder]");
}
return input.value.trim() === "";
}
function isSaveButton(target: HTMLElement): boolean {
const btn = target.closest("button");
if (!btn) return false;
const actionType = btn.getAttribute("data-action-type");
if (actionType === "save" || actionType === "submit") return true;
const variant = btn.getAttribute("data-variant");
if (variant === "default") return true;
return false;
}
function markError(input: TargetEl) {
input.setAttribute(ERROR_ATTR, "true");
errorFields.add(input);
}
function clearError(input: TargetEl) {
input.removeAttribute(ERROR_ATTR);
errorFields.delete(input);
}
function highlightField(input: TargetEl) {
input.setAttribute(HIGHLIGHT_ATTR, "true");
input.addEventListener("animationend", () => input.removeAttribute(HIGHLIGHT_ATTR), { once: true });
if (input instanceof HTMLButtonElement) {
input.click();
} else {
input.focus();
}
}
// 첫 저장 시도 이후: 빈 필드 → 에러 유지/재적용, 값 있으면 해제
function syncErrors() {
if (!activated) return;
const fields = findRequiredFields();
for (const [input] of fields) {
if (isEmpty(input)) {
markError(input);
} else {
clearError(input);
}
}
}
function handleClick(e: Event) {
const target = e.target as HTMLElement;
if (!isSaveButton(target)) return;
const fields = findRequiredFields();
if (fields.size === 0) return;
let firstEmpty: TargetEl | null = null;
let firstEmptyLabel = "";
for (const [input, label] of fields) {
if (isEmpty(input)) {
markError(input);
if (!firstEmpty) {
firstEmpty = input;
firstEmptyLabel = label;
}
} else {
clearError(input);
}
}
if (!firstEmpty) return;
activated = true;
e.stopPropagation();
e.preventDefault();
highlightField(firstEmpty);
toast.error(`${firstEmptyLabel} 항목을 입력해주세요`);
}
// V2Select는 input/change 이벤트가 없으므로 DOM 변경 감지로 에러 동기화
const observer = new MutationObserver(syncErrors);
observer.observe(el, { childList: true, subtree: true, attributes: true, attributeFilter: ["data-placeholder"] });
el.addEventListener("click", handleClick, true);
el.addEventListener("input", syncErrors);
el.addEventListener("change", syncErrors);
return () => {
el.removeEventListener("click", handleClick, true);
el.removeEventListener("input", syncErrors);
el.removeEventListener("change", syncErrors);
observer.disconnect();
el.querySelectorAll(`[${HIGHLIGHT_ATTR}]`).forEach((node) => node.removeAttribute(HIGHLIGHT_ATTR));
el.querySelectorAll(`[${ERROR_ATTR}]`).forEach((node) => node.removeAttribute(ERROR_ATTR));
};
}, [mode, contentEl]);
}