Merge branch 'ycshin-node' of http://39.117.244.52:3000/kjs/ERP-node into mhkim-node

This commit is contained in:
kmh
2026-03-11 15:54:11 +09:00
12 changed files with 681 additions and 25 deletions

View File

@@ -458,6 +458,14 @@ select {
border-color: hsl(var(--destructive)) !important;
}
/* 채번 세그먼트 포커스 스타일 (shadcn Input과 동일한 3단 구조) */
.numbering-segment:focus-within {
box-shadow: 0 0 0 3px hsl(var(--ring) / 0.5);
outline: 2px solid hsl(var(--ring));
outline-offset: 2px;
}
/* 필수 입력 경고 문구 (입력 필드 아래, 레이아웃 영향 없음) */
.validation-error-msg-wrapper {
height: 0;

View File

@@ -288,6 +288,7 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
const [parentValue, setParentValue] = useState<CategoryValue | null>(null);
const [continuousAdd, setContinuousAdd] = useState(false);
const [editingValue, setEditingValue] = useState<CategoryValue | null>(null);
const [deletingValue, setDeletingValue] = useState<CategoryValue | null>(null);
@@ -512,21 +513,24 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
const response = await createCategoryValue(input);
if (response.success) {
toast.success("카테고리가 추가되었습니다");
// 폼 초기화 (모달은 닫지 않고 연속 입력)
setFormData((prev) => ({
...prev,
valueCode: "",
valueLabel: "",
description: "",
color: "",
}));
setTimeout(() => addNameRef.current?.focus(), 50);
// 기존 펼침 상태 유지하면서 데이터 새로고침
await loadTree(true);
// 부모 노드만 펼치기 (하위 추가 시)
if (parentValue) {
setExpandedNodes((prev) => new Set([...prev, parentValue.valueId]));
}
if (continuousAdd) {
setFormData((prev) => ({
...prev,
valueCode: "",
valueLabel: "",
description: "",
color: "",
}));
setTimeout(() => addNameRef.current?.focus(), 50);
} else {
setFormData({ valueCode: "", valueLabel: "", description: "", color: "", isActive: true });
setIsAddModalOpen(false);
}
} else {
toast.error(response.error || "추가 실패");
}
@@ -818,6 +822,19 @@ export const CategoryValueManagerTree: React.FC<CategoryValueManagerTreeProps> =
</Button>
</DialogFooter>
<div className="border-t px-4 py-3">
<div className="flex items-center gap-2">
<Checkbox
id="tree-continuous-add"
checked={continuousAdd}
onCheckedChange={(checked) => setContinuousAdd(checked as boolean)}
/>
<Label htmlFor="tree-continuous-add" className="cursor-pointer text-sm font-normal select-none">
( )
</Label>
</div>
</div>
</DialogContent>
</Dialog>

View File

@@ -629,7 +629,7 @@ export const UnifiedSelect = forwardRef<HTMLDivElement, UnifiedSelectProps>((pro
): SelectOption[] => {
const result: SelectOption[] = [];
for (const item of items) {
const prefix = depth > 0 ? " ".repeat(depth) + "└ " : "";
const prefix = depth > 0 ? "\u00A0\u00A0\u00A0".repeat(depth) + "└ " : "";
result.push({
value: String(item.valueId), // valueId를 value로 사용 (채번 매핑과 일치)
label: prefix + item.valueLabel,

View File

@@ -909,10 +909,10 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
const templateSuffix = templateParts.length > 1 ? templateParts.slice(1).join("") : "";
return (
<div className="flex h-full items-center rounded-md border">
<div className="numbering-segment border-input flex h-full items-center rounded-md border outline-none transition-[color,box-shadow]">
{/* 고정 접두어 */}
{templatePrefix && (
<span className="text-muted-foreground bg-muted flex h-full items-center px-2 text-sm">
<span className="text-muted-foreground bg-muted flex h-full items-center rounded-l-[5px] px-2 text-sm">
{templatePrefix}
</span>
)}
@@ -945,13 +945,13 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
}
}}
placeholder="입력"
className="h-full min-w-[60px] flex-1 bg-transparent px-2 text-sm focus-visible:outline-none"
className="h-full min-w-[60px] flex-1 border-0 bg-transparent px-2 text-sm ring-0"
disabled={disabled || isGeneratingNumbering}
style={inputTextStyle}
style={{ ...inputTextStyle, outline: 'none' }}
/>
{/* 고정 접미어 */}
{templateSuffix && (
<span className="text-muted-foreground bg-muted flex h-full items-center px-2 text-sm">
<span className="text-muted-foreground bg-muted flex h-full items-center rounded-r-[5px] px-2 text-sm">
{templateSuffix}
</span>
)}

View File

@@ -901,7 +901,7 @@ export const V2Select = forwardRef<HTMLDivElement, V2SelectProps>((props, ref) =
): SelectOption[] => {
const result: SelectOption[] = [];
for (const item of items) {
const prefix = depth > 0 ? " ".repeat(depth) + "└ " : "";
const prefix = depth > 0 ? "\u00A0\u00A0\u00A0".repeat(depth) + "└ " : "";
result.push({
value: item.valueCode, // 🔧 valueCode를 value로 사용
label: prefix + item.valueLabel,

View File

@@ -98,6 +98,16 @@ export function useDialogAutoValidation(contentEl: HTMLElement | null) {
return el instanceof HTMLSelectElement && el.hasAttribute("aria-hidden");
}
// 복합 입력 필드(채번 세그먼트 등)의 시각적 테두리 컨테이너 탐지
// input 자체에 border가 없고 부모가 border를 가진 경우 부모를 반환
function findBorderContainer(input: TargetEl): HTMLElement {
const parent = input.parentElement;
if (parent && parent.classList.contains("border")) {
return parent;
}
return input;
}
function isEmpty(input: TargetEl): boolean {
if (input instanceof HTMLButtonElement) {
// Radix Select: data-placeholder 속성이 자식 span에 있으면 미선택 상태
@@ -120,20 +130,24 @@ export function useDialogAutoValidation(contentEl: HTMLElement | null) {
}
function markError(input: TargetEl) {
input.setAttribute(ERROR_ATTR, "true");
const container = findBorderContainer(input);
container.setAttribute(ERROR_ATTR, "true");
errorFields.add(input);
showErrorMsg(input);
}
function clearError(input: TargetEl) {
input.removeAttribute(ERROR_ATTR);
const container = findBorderContainer(input);
container.removeAttribute(ERROR_ATTR);
errorFields.delete(input);
removeErrorMsg(input);
}
// 빈 필수 필드 아래에 경고 문구 삽입 (레이아웃 영향 없는 zero-height wrapper)
// 복합 입력(채번 세그먼트 등)은 border 컨테이너 바깥에 삽입
function showErrorMsg(input: TargetEl) {
if (input.parentElement?.querySelector(`.${MSG_WRAPPER_CLASS}`)) return;
const container = findBorderContainer(input);
if (container.parentElement?.querySelector(`.${MSG_WRAPPER_CLASS}`)) return;
const wrapper = document.createElement("div");
wrapper.className = MSG_WRAPPER_CLASS;
@@ -142,17 +156,19 @@ export function useDialogAutoValidation(contentEl: HTMLElement | null) {
msg.textContent = "필수 입력 항목입니다";
wrapper.appendChild(msg);
input.insertAdjacentElement("afterend", wrapper);
container.insertAdjacentElement("afterend", wrapper);
}
function removeErrorMsg(input: TargetEl) {
const wrapper = input.parentElement?.querySelector(`.${MSG_WRAPPER_CLASS}`);
const container = findBorderContainer(input);
const wrapper = container.parentElement?.querySelector(`.${MSG_WRAPPER_CLASS}`);
if (wrapper) wrapper.remove();
}
function highlightField(input: TargetEl) {
input.setAttribute(HIGHLIGHT_ATTR, "true");
input.addEventListener("animationend", () => input.removeAttribute(HIGHLIGHT_ATTR), { once: true });
const container = findBorderContainer(input);
container.setAttribute(HIGHLIGHT_ATTR, "true");
container.addEventListener("animationend", () => container.removeAttribute(HIGHLIGHT_ATTR), { once: true });
if (input instanceof HTMLButtonElement) {
input.click();