Filter

Sleeper·2026년 4월 21일
import { ReactElement, createElement, useCallback, useEffect, useRef, useState } from "react";
import "../ui/SpecGridFilterNSort.css";
import { FilterInfo, FilterNSortPopupProps, SortInfo, SpecColumn } from "typings/types";

// ===== 다국어 메시지 =====
const messages = {
    ko: {
        sort: "정렬",
        sortAsc: "오름차순 정렬",
        sortDesc: "내림차순 정렬",
        sortReset: "정렬 안함",
        hideColumn: "열 숨기기",
        hideColumnDisabledTooltip: "마지막 컬럼은 숨길 수 없습니다",
        freeze: "고정",
        unfreeze: "고정 해제",
        filter: "필터",
        searchPlaceholder: "검색",
        btnFilter: "필터",
        btnClear: "지우기",
        showFilterIcon: "필터 표시",
        method_contains: "포함",
        method_notcontains: "포함하지 않음",
        method_startwith: "시작 문자",
        method_endwith: "끝 문자",
        method_equal: "같음",
        method_nequal: "같지 않음"
    },
    en: {
        sort: "Sort",
        sortAsc: "Sort Ascending",
        sortDesc: "Sort Descending",
        sortReset: "Clear Sort",
        hideColumn: "Hide Column",
        hideColumnDisabledTooltip: "Cannot hide the last visible column",
        freeze: "Freeze",
        unfreeze: "Unfreeze",
        filter: "Filter",
        searchPlaceholder: "Search",
        btnFilter: "Apply",
        btnClear: "Clear",
        showFilterIcon: "Show filter",
        method_contains: "Contains",
        method_notcontains: "Not contains",
        method_startwith: "Starts with",
        method_endwith: "Ends with",
        method_equal: "Equal",
        method_nequal: "Not equal"
    }
};

// ===== 언어 감지 =====
function detectLang(): "ko" | "en" {
    try {
        const w = window as any;
        const mxLocale =
            w.mx?.session?.getConfig?.("locale.code") ||
            w.mx?.session?.getUserLocale?.() ||
            w.mx?.session?.locale?.code;

        if (mxLocale && typeof mxLocale === "string") {
            return mxLocale.toLowerCase().startsWith("ko") ? "ko" : "en";
        }
    } catch (e) {
        console.warn("[FilterPopup] Mendix locale detect failed", e);
    }

    const browserLang = navigator.language || "ko";
    return browserLang.toLowerCase().startsWith("ko") ? "ko" : "en";
}

// ===== 확장 props =====
interface ExtendedProps extends FilterNSortPopupProps {
    onToggleFrozen?: (columnName: string) => void;
    frozenColumnName?: string;
    visibleColumnCount?: number;
    showFilterIcons?: boolean;
    onToggleFilterIcons?: (value: boolean) => void;
}

export function FilterNSortPopup(props: ExtendedProps): ReactElement {
    // 다국어
    const lang = detectLang();
    const t = messages[lang];

    const popupRef = useRef<HTMLDivElement>(null);

    const options = [
        { label: t.method_contains, value: "contains" },
        { label: t.method_notcontains, value: "notcontains" },
        { label: t.method_startwith, value: "startwith" },
        { label: t.method_endwith, value: "endwith" },
        { label: t.method_equal, value: "equal" },
        { label: t.method_nequal, value: "nequal" }
    ];

    const [columnName, setColumnName] = useState<string>("");
    const [columns, setColumns] = useState<SpecColumn[]>(props.columns);
    const [sortable, setSortable] = useState<boolean>(false);
    const [filterable, setFilterable] = useState<boolean>(false);
    const [sortInfo, setSortInfo] = useState<SortInfo[]>([]);
    const [filterInfo, setFilterInfo] = useState<FilterInfo[]>([]);

    const [currSortDirection, setCurrSortDirection] = useState<string>("");
    const [currFilterMethod, setCurrFilterMethod] = useState<string>(options[0].value);
    const [currFilterSearchWord, setCurrFilterSearchWord] = useState<string>("");
    const [isFrozen, setIsFrozen] = useState<boolean>(false);

    // 마지막 컬럼 체크
    const isLastVisibleColumn = (props.visibleColumnCount ?? 0) <= 1;

    // 리스너를 ref에 저장 (stale closure 방지)
    const outsideClickListenerRef = useRef<((e: any) => void) | null>(null);
    const contextMenuListenerRef = useRef<((e: any) => void) | null>(null);

    useEffect(() => {
        if (props.columns.length > 0) setColumns(props.columns);
    }, [props.columns]);

    useEffect(() => {
        setSortInfo(props.sortInfo || []);
    }, [props.sortInfo]);

    useEffect(() => {
        setFilterInfo(props.filterInfo || []);
    }, [props.filterInfo]);

    const showPopup = (target: any, isshow: Boolean) => {
        if (!target) return;
        if (isshow) {
            target.style.opacity = "0";
            target.style.display = "block";
        } else {
            target.style.opacity = "0";
            target.style.display = "none";
        }
    };

    // ===== 매 렌더링마다 ref.current 갱신 (최신 state 캡처) =====
    contextMenuListenerRef.current = (event: any) => {
        const grid = document.querySelector(`.${props.gridName}`);
        if (!grid || !grid.contains(event.target)) return;

        let thElement: HTMLElement | null = event.target;
        while (thElement && thElement.nodeName !== "TH" && thElement !== grid) {
            thElement = thElement.parentElement;
        }
        if (!thElement || thElement.nodeName !== "TH" || !props.usePopup) return;

        const newColumnname = thElement.getAttribute("data-column-name");
        if (!newColumnname || newColumnname === "_checked" || newColumnname === "_number") return;

        event.preventDefault();
        event.stopPropagation();

        const found = props.columns.find((e: SpecColumn) => e.name === newColumnname);
        if (!found) return;

        setColumnName(newColumnname);
        setSortable(found.sortable);
        setFilterable(found.filterable);

        const foundsort = sortInfo.find((e: SortInfo) => e.name === newColumnname);
        setCurrSortDirection(foundsort ? foundsort.dir : "");

        const foundFilter = filterInfo.find((e: FilterInfo) => e.name === newColumnname);
        if (foundFilter) {
            setCurrFilterMethod(foundFilter.method);
            setCurrFilterSearchWord(foundFilter.search);
        } else {
            setCurrFilterMethod(options[0].value);
            setCurrFilterSearchWord("");
        }

        setIsFrozen(props.frozenColumnName === newColumnname);

        const target = popupRef.current;
        if (!target) return;

        showPopup(target, true);

        requestAnimationFrame(() => {
            if (!target) return;
            const thRect = thElement!.getBoundingClientRect();
            const panelRect = target.getBoundingClientRect();

            let x = thRect.left + thRect.width / 2 - panelRect.width / 2;
            let y = thRect.bottom;

            const viewportWidth = window.innerWidth;
            if (x + panelRect.width > viewportWidth - 10) {
                x = viewportWidth - panelRect.width - 10;
            }
            if (x < 10) x = 10;

            const viewportHeight = window.innerHeight;
            if (y + panelRect.height > viewportHeight - 10) {
                y = thRect.top - panelRect.height;
            }

            target.style.left = x + "px";
            target.style.top = y + "px";
            target.style.opacity = "1";
        });
    };

    outsideClickListenerRef.current = (event: any) => {
        if (!popupRef.current) return;
        const isVisible = popupRef.current.style.display === "block";
        if (!isVisible) return;
        if (!popupRef.current.contains(event.target)) {
            showPopup(popupRef.current, false);
            setColumnName("");
        }
    };

    // ===== 마운트 시 1번만 등록 =====
    useEffect(() => {
        const ctxHandler = (e: any) => contextMenuListenerRef.current?.(e);
        const clickHandler = (e: any) => outsideClickListenerRef.current?.(e);

        document.addEventListener("contextmenu", ctxHandler, true);
        document.addEventListener("click", clickHandler, true);

        return () => {
            document.removeEventListener("contextmenu", ctxHandler, true);
            document.removeEventListener("click", clickHandler, true);

            if (popupRef.current) {
                popupRef.current.style.display = "none";
                popupRef.current.style.opacity = "0";
            }
        };
    }, []);

    // ===== ESC 키로 닫기 =====
    useEffect(() => {
        const escListener = (e: KeyboardEvent) => {
            if (e.key === "Escape" && popupRef.current?.style.display === "block") {
                showPopup(popupRef.current, false);
                setColumnName("");
            }
        };
        document.addEventListener("keydown", escListener);
        return () => document.removeEventListener("keydown", escListener);
    }, []);

    // ===== 스크롤/리사이즈 시 닫기 =====
    useEffect(() => {
        const scrollListener = () => {
            if (popupRef.current?.style.display === "block") {
                showPopup(popupRef.current, false);
                setColumnName("");
            }
        };
        document.addEventListener("scroll", scrollListener, true);
        window.addEventListener("resize", scrollListener);

        return () => {
            document.removeEventListener("scroll", scrollListener, true);
            window.removeEventListener("resize", scrollListener);
        };
    }, []);

    // ===== 그리드 visibility 체크 =====
    useEffect(() => {
        const visibilityChecker = setInterval(() => {
            if (popupRef.current?.style.display === "block") {
                const grid = document.querySelector(`.${props.gridName}`);
                if (!grid || !(grid as any).checkVisibility?.()) {
                    showPopup(popupRef.current, false);
                    setColumnName("");
                }
            }
        }, 500);

        return () => clearInterval(visibilityChecker);
    }, [props.gridName]);

    // ===== 콜백들 =====
    const cbClickSortAsc = useCallback(() => {
        const found = columns.find((e: SpecColumn) => e.name === columnName);
        if (!found || !props.onChangeSort) return;
        setCurrSortDirection("asc");
        props.onChangeSort({
            name: columnName,
            dir: "asc",
            multiple: found.sortMultiple
        });
        showPopup(popupRef.current, false);
    }, [props, columns, columnName]);

    const cbClickSortDesc = useCallback(() => {
        const found = columns.find((e: SpecColumn) => e.name === columnName);
        if (!found || !props.onChangeSort) return;
        setCurrSortDirection("desc");
        props.onChangeSort({
            name: columnName,
            dir: "desc",
            multiple: found.sortMultiple
        });
        showPopup(popupRef.current, false);
    }, [props, columns, columnName]);

    const cbClickSortReset = useCallback(() => {
        const found = columns.find((e: SpecColumn) => e.name === columnName);
        if (!found || !props.onChangeSort) return;
        setCurrSortDirection("clear");
        props.onChangeSort({
            name: columnName,
            dir: "clear",
            multiple: found.sortMultiple
        });
        showPopup(popupRef.current, false);
    }, [props, columns, columnName]);

    const cbClickHideColumn = useCallback(() => {
        if (isLastVisibleColumn) return;
        const found = columns.find((e: SpecColumn) => e.name === columnName);
        if (!found || !props.onChangeHideColumn) return;
        props.onChangeHideColumn(columnName);
        showPopup(popupRef.current, false);
    }, [props, columns, columnName, isLastVisibleColumn]);

    const cbClickToggleFrozen = useCallback(() => {
        if (!props.onToggleFrozen) return;
        props.onToggleFrozen(columnName);
        showPopup(popupRef.current, false);
    }, [props, columnName]);

    const handleChangeFilterSelectbox = useCallback((event: any) => {
        setCurrFilterMethod(event.target.value);
    }, []);

    const handleChangeFilterSearchWord = useCallback((event: any) => {
        setCurrFilterSearchWord(event.target.value);
    }, []);

    const handleConfirmFilter = useCallback(() => {
        const found = columns.find((e: SpecColumn) => e.name === columnName);
        if (!found || !props.onChangeFilter) return;

        props.onChangeFilter({
            name: columnName,
            search: currFilterSearchWord,
            method: currFilterMethod,
            multiple: found.filterMultiple
        });
        showPopup(popupRef.current, false);
    }, [props, columns, columnName, currFilterSearchWord, currFilterMethod]);

    const handleClearFilter = useCallback(() => {
        const found = columns.find((e: SpecColumn) => e.name === columnName);
        if (!found || !props.onChangeFilter) return;

        props.onChangeFilter({
            name: columnName,
            search: currFilterSearchWord,
            method: "clear",
            multiple: found.filterMultiple
        });
        setCurrFilterSearchWord("");
        showPopup(popupRef.current, false);
    }, [props, columns, columnName, currFilterSearchWord]);

    const handleFilterInputKeyDown = useCallback(
        (event: any) => {
            if (event.key === "Enter") {
                event.preventDefault();
                handleConfirmFilter();
            }
        },
        [handleConfirmFilter]
    );

    if (!props.usePopup) return <div />;

    return (
        <div className={"sg-grid-header-control"} id="filterPanel" ref={popupRef}>
            {/* {sortable && (
                <section className="sg-section">
                    <h4 className="sg-h4">{t.sort}</h4>
                    <ul className={"sg-sort-list"}>
                        <li>
                            <button
                                className={"sg-sort-button" + (currSortDirection === "asc" ? " sg-sort-selected" : "")}
                                onClick={cbClickSortAsc}
                            >
                                <span className={"sg-icon"}>▲</span>
                                {t.sortAsc}
                            </button>
                        </li>
                        <li>
                            <button
                                className={"sg-sort-button" + (currSortDirection === "desc" ? " sg-sort-selected" : "")}
                                onClick={cbClickSortDesc}
                            >
                                <span className={"sg-icon"}>▼</span>
                                {t.sortDesc}
                            </button>
                        </li>
                        <li>
                            <button
                                className={
                                    "sg-sort-button" +
                                    (currSortDirection === "" || currSortDirection === "clear"
                                        ? " sg-sort-selected"
                                        : "")
                                }
                                onClick={cbClickSortReset}
                            >
                                <span className={"sg-icon"}>⇅</span>
                                {t.sortReset}
                            </button>
                        </li>
                        <li>
                            <button
                                className={"sg-sort-button" + (isLastVisibleColumn ? " sg-sort-disabled" : "")}
                                onClick={cbClickHideColumn}
                                disabled={isLastVisibleColumn}
                                title={isLastVisibleColumn ? t.hideColumnDisabledTooltip : ""}
                            >
                                <span className={"sg-icon"}>⊘</span>
                                {t.hideColumn}
                            </button>
                        </li>
                        <li>
                            <button
                                className={"sg-sort-button" + (isFrozen ? " sg-sort-selected" : "")}
                                onClick={cbClickToggleFrozen}
                            >
                                <span className={"sg-icon"}>📌</span>
                                {isFrozen ? t.unfreeze : t.freeze}
                            </button>
                        </li>
                    </ul>
                </section>
            )} */}
            {filterable && (
                <section className="sg-section">
                    <h4 className="sg-h4">{t.filter}</h4>
                    <div className={"sg-filter-condition"}>
                        <select
                            className={"sg-filter-method"}
                            value={currFilterMethod}
                            onChange={handleChangeFilterSelectbox}
                        >
                            {options.map(e => (
                                <option key={e.value} value={e.value}>
                                    {e.label}
                                </option>
                            ))}
                        </select>
                        <input
                            type="text"
                            className={"sg-filter-input"}
                            placeholder={t.searchPlaceholder}
                            value={currFilterSearchWord}
                            onChange={handleChangeFilterSearchWord}
                            onKeyDown={handleFilterInputKeyDown}
                        />
                    </div>

                    {/* 필터 표시 토글 */}
                    <div className="sg-filter-toggle">
                        <span className="sg-filter-toggle-label">{t.showFilterIcon}</span>
                        <label className="sg-switch">
                            <input
                                type="checkbox"
                                checked={props.showFilterIcons ?? true}
                                onChange={e => props.onToggleFilterIcons?.(e.target.checked)}
                            />
                            <span className="sg-slider"></span>
                        </label>
                    </div>
                </section>
            )}
            {filterable && (
                <section className={"sg-button-section"}>
                    <div className={"sg-button-row"}>
                        <button className={"sg-btn-clear"} onClick={handleClearFilter}>
                            {t.btnClear}
                        </button>
                        <button className={"sg-btn-filter"} onClick={handleConfirmFilter}>
                            {t.btnFilter}
                        </button>
                    </div>
                </section>
            )}
        </div>
    );
}

0개의 댓글