검색 필터기능 수정사항
This commit is contained in:
@@ -41,11 +41,12 @@ import {
|
||||
import { tableTypeApi } from "@/lib/api/screen";
|
||||
import { commonCodeApi } from "@/lib/api/commonCode";
|
||||
import { getCurrentUser, UserInfo } from "@/lib/api/client";
|
||||
import { DataTableComponent, DataTableColumn, DataTableFilter, AttachedFileInfo } from "@/types/screen";
|
||||
import { DataTableComponent, DataTableColumn, DataTableFilter } from "@/types/screen-legacy-backup";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { downloadFile, getLinkedFiles, getFilePreviewUrl, getDirectFileUrl } from "@/lib/api/file";
|
||||
import { toast } from "sonner";
|
||||
import { FileUpload } from "@/components/screen/widgets/FileUpload";
|
||||
import { AdvancedSearchFilters } from "./filters/AdvancedSearchFilters";
|
||||
|
||||
// 파일 데이터 타입 정의 (AttachedFileInfo와 호환)
|
||||
interface FileInfo {
|
||||
@@ -332,7 +333,6 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||
|
||||
// 검색 가능한 컬럼만 필터링
|
||||
const visibleColumns = component.columns?.filter((col: DataTableColumn) => col.visible) || [];
|
||||
const searchFilters = component.filters || [];
|
||||
|
||||
// 컬럼의 실제 웹 타입 정보 찾기
|
||||
const getColumnWebType = useCallback(
|
||||
@@ -525,6 +525,11 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||
}
|
||||
}, [component.tableName]);
|
||||
|
||||
// 실제 사용할 필터 (설정된 필터만 사용, 자동 생성 안함)
|
||||
const searchFilters = useMemo(() => {
|
||||
return component.filters || [];
|
||||
}, [component.filters]);
|
||||
|
||||
// 초기 데이터 로드
|
||||
useEffect(() => {
|
||||
loadData(1, searchValues);
|
||||
@@ -1480,115 +1485,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
// 검색 필터 렌더링
|
||||
const renderSearchFilter = (filter: DataTableFilter) => {
|
||||
const value = searchValues[filter.columnName] || "";
|
||||
|
||||
switch (filter.widgetType) {
|
||||
case "text":
|
||||
case "email":
|
||||
case "tel":
|
||||
return (
|
||||
<Input
|
||||
key={filter.columnName}
|
||||
placeholder={`${filter.label} 검색...`}
|
||||
value={value}
|
||||
onChange={(e) => handleSearchValueChange(filter.columnName, e.target.value)}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
handleSearch();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
case "number":
|
||||
case "decimal":
|
||||
return (
|
||||
<Input
|
||||
key={filter.columnName}
|
||||
type="number"
|
||||
placeholder={`${filter.label} 입력...`}
|
||||
value={value}
|
||||
onChange={(e) => handleSearchValueChange(filter.columnName, e.target.value)}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
handleSearch();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
case "date":
|
||||
return (
|
||||
<Input
|
||||
key={filter.columnName}
|
||||
type="date"
|
||||
value={value}
|
||||
onChange={(e) => handleSearchValueChange(filter.columnName, e.target.value)}
|
||||
/>
|
||||
);
|
||||
|
||||
case "datetime":
|
||||
return (
|
||||
<Input
|
||||
key={filter.columnName}
|
||||
type="datetime-local"
|
||||
value={value}
|
||||
onChange={(e) => handleSearchValueChange(filter.columnName, e.target.value)}
|
||||
/>
|
||||
);
|
||||
|
||||
case "select":
|
||||
// TODO: 선택 옵션은 추후 구현
|
||||
return (
|
||||
<Select
|
||||
key={filter.columnName}
|
||||
value={value}
|
||||
onValueChange={(newValue) => handleSearchValueChange(filter.columnName, newValue)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={`${filter.label} 선택...`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">전체</SelectItem>
|
||||
{/* TODO: 동적 옵션 로드 */}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
|
||||
case "code":
|
||||
// 코드 타입은 텍스트 검색으로 처리 (코드명으로 검색 가능)
|
||||
return (
|
||||
<Input
|
||||
key={filter.columnName}
|
||||
placeholder={`${filter.label} 검색... (코드명 또는 코드값)`}
|
||||
value={value}
|
||||
onChange={(e) => handleSearchValueChange(filter.columnName, e.target.value)}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
handleSearch();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<Input
|
||||
key={filter.columnName}
|
||||
placeholder={`${filter.label} 검색...`}
|
||||
value={value}
|
||||
onChange={(e) => handleSearchValueChange(filter.columnName, e.target.value)}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
handleSearch();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
// 기존 renderSearchFilter 함수는 AdvancedSearchFilters 컴포넌트로 대체됨
|
||||
|
||||
// 파일 다운로드
|
||||
const handleDownloadFile = useCallback(async (fileInfo: FileInfo) => {
|
||||
@@ -1847,31 +1744,22 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 검색 필터 */}
|
||||
{searchFilters.length > 0 && (
|
||||
{/* 검색 필터 - 항상 표시 (컬럼 정보 기반 자동 생성) */}
|
||||
{tableColumns && tableColumns.length > 0 && (
|
||||
<>
|
||||
<Separator className="my-2" />
|
||||
<div className="space-y-3">
|
||||
<div className="text-muted-foreground flex items-center gap-2 text-sm">
|
||||
<Search className="h-3 w-3" />
|
||||
검색 필터
|
||||
</div>
|
||||
<div
|
||||
className="grid gap-3"
|
||||
style={{
|
||||
gridTemplateColumns: searchFilters
|
||||
.map((filter: DataTableFilter) => `${filter.gridColumns || 3}fr`)
|
||||
.join(" "),
|
||||
}}
|
||||
>
|
||||
{searchFilters.map((filter: DataTableFilter) => (
|
||||
<div key={filter.columnName} className="space-y-1">
|
||||
<label className="text-muted-foreground text-xs font-medium">{filter.label}</label>
|
||||
{renderSearchFilter(filter)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<AdvancedSearchFilters
|
||||
filters={searchFilters.length > 0 ? searchFilters : []}
|
||||
searchValues={searchValues}
|
||||
onSearchValueChange={handleSearchValueChange}
|
||||
onSearch={handleSearch}
|
||||
onClearFilters={() => {
|
||||
setSearchValues({});
|
||||
loadData(1, {});
|
||||
}}
|
||||
tableColumns={tableColumns}
|
||||
tableName={component.tableName}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
427
frontend/components/screen/filters/AdvancedSearchFilters.tsx
Normal file
427
frontend/components/screen/filters/AdvancedSearchFilters.tsx
Normal file
@@ -0,0 +1,427 @@
|
||||
import React, { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Search, X } from "lucide-react";
|
||||
import { ModernDatePicker } from "./ModernDatePicker";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { commonCodeApi } from "@/lib/api/commonCode";
|
||||
import { EntityReferenceAPI } from "@/lib/api/entityReference";
|
||||
import type { DataTableFilter } from "@/types/screen-legacy-backup";
|
||||
import type { CodeInfo } from "@/types/commonCode";
|
||||
|
||||
interface AdvancedSearchFiltersProps {
|
||||
filters: DataTableFilter[];
|
||||
searchValues: Record<string, any>;
|
||||
onSearchValueChange: (columnName: string, value: any) => void;
|
||||
onSearch: () => void;
|
||||
onClearFilters: () => void;
|
||||
className?: string;
|
||||
tableColumns?: any[]; // 테이블 컬럼 정보
|
||||
tableName?: string; // 테이블명
|
||||
}
|
||||
|
||||
interface DateRangeValue {
|
||||
from?: Date;
|
||||
to?: Date;
|
||||
}
|
||||
|
||||
interface CodeOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface EntityOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const AdvancedSearchFilters: React.FC<AdvancedSearchFiltersProps> = ({
|
||||
filters,
|
||||
searchValues,
|
||||
onSearchValueChange,
|
||||
onSearch,
|
||||
onClearFilters,
|
||||
className = "",
|
||||
tableColumns = [],
|
||||
tableName = "",
|
||||
}) => {
|
||||
// 코드 옵션 캐시
|
||||
const [codeOptions, setCodeOptions] = useState<Record<string, CodeOption[]>>({});
|
||||
// 엔티티 옵션 캐시
|
||||
const [entityOptions, setEntityOptions] = useState<Record<string, EntityOption[]>>({});
|
||||
// 로딩 상태
|
||||
const [loadingStates, setLoadingStates] = useState<Record<string, boolean>>({});
|
||||
|
||||
// 자동 필터 생성 (설정된 필터가 없을 때 테이블 컬럼 기반으로 생성)
|
||||
const autoGeneratedFilters = useMemo(() => {
|
||||
if (filters.length > 0 || !tableColumns || tableColumns.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 필터 가능한 웹타입들
|
||||
const filterableWebTypes = ["text", "email", "tel", "number", "decimal", "date", "datetime", "code", "entity"];
|
||||
|
||||
return tableColumns
|
||||
.filter((col) => {
|
||||
const webType = col.webType || col.web_type;
|
||||
return filterableWebTypes.includes(webType) && col.isVisible !== false;
|
||||
})
|
||||
.slice(0, 6) // 최대 6개까지만 자동 생성
|
||||
.map((col) => ({
|
||||
columnName: col.columnName || col.column_name,
|
||||
widgetType: col.webType || col.web_type,
|
||||
label: col.displayName || col.column_label || col.columnName || col.column_name,
|
||||
gridColumns: 3,
|
||||
codeCategory: col.codeCategory || col.code_category,
|
||||
referenceTable: col.referenceTable || col.reference_table,
|
||||
referenceColumn: col.referenceColumn || col.reference_column,
|
||||
displayColumn: col.displayColumn || col.display_column,
|
||||
}));
|
||||
}, [filters, tableColumns]);
|
||||
|
||||
// 실제 사용할 필터 (설정된 필터가 있으면 우선, 없으면 자동 생성)
|
||||
const effectiveFilters = useMemo(() => {
|
||||
return filters.length > 0 ? filters : autoGeneratedFilters;
|
||||
}, [filters, autoGeneratedFilters]);
|
||||
|
||||
// 코드 데이터 로드
|
||||
const loadCodeOptions = useCallback(
|
||||
async (codeCategory: string) => {
|
||||
if (codeOptions[codeCategory] || loadingStates[codeCategory]) return;
|
||||
|
||||
setLoadingStates((prev) => ({ ...prev, [codeCategory]: true }));
|
||||
|
||||
try {
|
||||
const response = await EntityReferenceAPI.getCodeReferenceData(codeCategory, { limit: 1000 });
|
||||
const options = response.options.map((option) => ({
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
}));
|
||||
|
||||
setCodeOptions((prev) => ({ ...prev, [codeCategory]: options }));
|
||||
} catch (error) {
|
||||
console.error(`코드 카테고리 ${codeCategory} 로드 실패:`, error);
|
||||
setCodeOptions((prev) => ({ ...prev, [codeCategory]: [] }));
|
||||
} finally {
|
||||
setLoadingStates((prev) => ({ ...prev, [codeCategory]: false }));
|
||||
}
|
||||
},
|
||||
[codeOptions, loadingStates],
|
||||
);
|
||||
|
||||
// 엔티티 데이터 로드
|
||||
const loadEntityOptions = useCallback(
|
||||
async (tableName: string, columnName: string) => {
|
||||
const key = `${tableName}.${columnName}`;
|
||||
if (entityOptions[key] || loadingStates[key]) return;
|
||||
|
||||
setLoadingStates((prev) => ({ ...prev, [key]: true }));
|
||||
|
||||
try {
|
||||
const response = await EntityReferenceAPI.getEntityReferenceData(tableName, columnName, { limit: 1000 });
|
||||
const options = response.options.map((option) => ({
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
}));
|
||||
|
||||
setEntityOptions((prev) => ({ ...prev, [key]: options }));
|
||||
} catch (error) {
|
||||
console.error(`엔티티 ${tableName}.${columnName} 로드 실패:`, error);
|
||||
setEntityOptions((prev) => ({ ...prev, [key]: [] }));
|
||||
} finally {
|
||||
setLoadingStates((prev) => ({ ...prev, [key]: false }));
|
||||
}
|
||||
},
|
||||
[entityOptions, loadingStates],
|
||||
);
|
||||
|
||||
// 즉시 검색을 위한 onChange 핸들러
|
||||
const handleChange = useCallback(
|
||||
(columnName: string, newValue: any) => {
|
||||
onSearchValueChange(columnName, newValue);
|
||||
// 즉시 검색 실행 (디바운싱 제거)
|
||||
onSearch();
|
||||
},
|
||||
[onSearchValueChange, onSearch],
|
||||
);
|
||||
|
||||
// 텍스트 입력용 핸들러 (Enter 키 또는 blur 시에만 검색)
|
||||
const handleTextChange = useCallback(
|
||||
(columnName: string, newValue: string) => {
|
||||
onSearchValueChange(columnName, newValue);
|
||||
// 텍스트는 즉시 검색하지 않고 상태만 업데이트
|
||||
},
|
||||
[onSearchValueChange],
|
||||
);
|
||||
|
||||
// Enter 키 또는 blur 시 검색 실행
|
||||
const handleTextSearch = useCallback(() => {
|
||||
onSearch();
|
||||
}, [onSearch]);
|
||||
|
||||
// 필터별 렌더링 함수
|
||||
const renderFilter = (filter: DataTableFilter) => {
|
||||
const value = searchValues[filter.columnName] || "";
|
||||
|
||||
switch (filter.widgetType) {
|
||||
case "text":
|
||||
case "email":
|
||||
case "tel":
|
||||
return (
|
||||
<Input
|
||||
key={filter.columnName}
|
||||
placeholder={`${filter.label} 검색...`}
|
||||
value={value}
|
||||
onChange={(e) => handleTextChange(filter.columnName, e.target.value)}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
handleTextSearch();
|
||||
}
|
||||
}}
|
||||
onBlur={handleTextSearch}
|
||||
/>
|
||||
);
|
||||
|
||||
case "number":
|
||||
case "decimal":
|
||||
// 숫자 필터 모드에 따라 다른 UI 렌더링
|
||||
if (filter.numberFilterMode === "exact") {
|
||||
// 정확한 값 검색
|
||||
return (
|
||||
<Input
|
||||
key={filter.columnName}
|
||||
type="number"
|
||||
placeholder={`${filter.label} 입력...`}
|
||||
value={value || ""}
|
||||
onChange={(e) => handleChange(filter.columnName, e.target.value)}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
// 범위 검색 (기본값)
|
||||
return (
|
||||
<div key={filter.columnName} className="flex space-x-2">
|
||||
<Input
|
||||
type="number"
|
||||
placeholder={`최소값`}
|
||||
value={value?.min || ""}
|
||||
onChange={(e) =>
|
||||
handleChange(filter.columnName, {
|
||||
...value,
|
||||
min: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder={`최대값`}
|
||||
value={value?.max || ""}
|
||||
onChange={(e) =>
|
||||
handleChange(filter.columnName, {
|
||||
...value,
|
||||
max: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case "date":
|
||||
case "datetime":
|
||||
return (
|
||||
<ModernDatePicker
|
||||
key={filter.columnName}
|
||||
label={filter.label}
|
||||
value={value || {}}
|
||||
onChange={(newValue) => handleChange(filter.columnName, newValue)}
|
||||
includeTime={filter.widgetType === "datetime"}
|
||||
/>
|
||||
);
|
||||
|
||||
case "code":
|
||||
console.log("🔍 코드 필터 렌더링:", {
|
||||
columnName: filter.columnName,
|
||||
codeCategory: filter.codeCategory,
|
||||
options: codeOptions[filter.codeCategory || ""],
|
||||
loading: loadingStates[filter.codeCategory || ""],
|
||||
});
|
||||
return (
|
||||
<CodeFilter
|
||||
key={filter.columnName}
|
||||
filter={filter}
|
||||
value={value}
|
||||
onChange={(newValue) => handleChange(filter.columnName, newValue)}
|
||||
options={codeOptions[filter.codeCategory || ""] || []}
|
||||
loading={loadingStates[filter.codeCategory || ""]}
|
||||
onLoadOptions={() => filter.codeCategory && loadCodeOptions(filter.codeCategory)}
|
||||
/>
|
||||
);
|
||||
|
||||
case "entity":
|
||||
return (
|
||||
<EntityFilter
|
||||
key={filter.columnName}
|
||||
filter={filter}
|
||||
value={value}
|
||||
onChange={(newValue) => handleChange(filter.columnName, newValue)}
|
||||
options={entityOptions[`${filter.referenceTable}.${filter.columnName}`] || []}
|
||||
loading={loadingStates[`${filter.referenceTable}.${filter.columnName}`]}
|
||||
onLoadOptions={() => filter.referenceTable && loadEntityOptions(filter.referenceTable, filter.columnName)}
|
||||
/>
|
||||
);
|
||||
|
||||
case "select":
|
||||
case "dropdown":
|
||||
return (
|
||||
<Select
|
||||
key={filter.columnName}
|
||||
value={value}
|
||||
onValueChange={(newValue) => onSearchValueChange(filter.columnName, newValue)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={`${filter.label} 선택...`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__ALL__">전체</SelectItem>
|
||||
{/* TODO: 동적 옵션 로드 */}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<Input
|
||||
key={filter.columnName}
|
||||
placeholder={`${filter.label} 검색...`}
|
||||
value={value}
|
||||
onChange={(e) => handleTextChange(filter.columnName, e.target.value)}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
handleTextSearch();
|
||||
}
|
||||
}}
|
||||
onBlur={handleTextSearch}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// 활성 필터 개수 계산
|
||||
const activeFiltersCount = Object.values(searchValues).filter((value) => {
|
||||
if (typeof value === "string") return value.trim() !== "";
|
||||
if (typeof value === "object" && value !== null) {
|
||||
return Object.values(value).some((v) => v !== "" && v !== null && v !== undefined);
|
||||
}
|
||||
return value !== null && value !== undefined && value !== "";
|
||||
}).length;
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-4", className)}>
|
||||
{/* 필터 헤더 */}
|
||||
<div className="text-muted-foreground flex items-center gap-2 text-sm">
|
||||
<Search className="h-3 w-3" />
|
||||
검색 필터
|
||||
{autoGeneratedFilters.length > 0 && <span className="text-xs text-blue-600">(자동 생성)</span>}
|
||||
</div>
|
||||
|
||||
{/* 필터 그리드 - 적절한 너비로 조정 */}
|
||||
{effectiveFilters.length > 0 && (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{effectiveFilters.map((filter: DataTableFilter) => {
|
||||
// 필터 개수에 따라 적절한 너비 계산
|
||||
const getFilterWidth = () => {
|
||||
const filterCount = effectiveFilters.length;
|
||||
if (filterCount === 1) return "w-80"; // 1개일 때는 적당한 크기
|
||||
if (filterCount === 2) return "w-64"; // 2개일 때는 조금 작게
|
||||
if (filterCount === 3) return "w-52"; // 3개일 때는 더 작게
|
||||
return "w-48"; // 4개 이상일 때는 가장 작게
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={filter.columnName} className={`space-y-1 ${getFilterWidth()}`}>
|
||||
<label className="text-muted-foreground text-xs font-medium">{filter.label}</label>
|
||||
{renderFilter(filter)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 필터 상태 및 초기화 버튼 */}
|
||||
{activeFiltersCount > 0 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-muted-foreground text-sm">{activeFiltersCount}개 필터 적용 중</div>
|
||||
<Button variant="outline" size="sm" onClick={onClearFilters} className="gap-2">
|
||||
<X className="h-3 w-3" />
|
||||
필터 초기화
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 코드 필터 컴포넌트
|
||||
const CodeFilter: React.FC<{
|
||||
filter: DataTableFilter;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
options: CodeOption[];
|
||||
loading: boolean;
|
||||
onLoadOptions: () => void;
|
||||
}> = ({ filter, value, onChange, options, loading, onLoadOptions }) => {
|
||||
useEffect(() => {
|
||||
if (filter.codeCategory && options.length === 0 && !loading) {
|
||||
onLoadOptions();
|
||||
}
|
||||
}, [filter.codeCategory, options.length, loading, onLoadOptions]);
|
||||
|
||||
return (
|
||||
<Select value={value} onValueChange={onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={loading ? "로딩 중..." : `${filter.label} 선택...`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__ALL__">전체</SelectItem>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
|
||||
// 엔티티 필터 컴포넌트
|
||||
const EntityFilter: React.FC<{
|
||||
filter: DataTableFilter;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
options: EntityOption[];
|
||||
loading: boolean;
|
||||
onLoadOptions: () => void;
|
||||
}> = ({ filter, value, onChange, options, loading, onLoadOptions }) => {
|
||||
useEffect(() => {
|
||||
if (filter.referenceTable && options.length === 0 && !loading) {
|
||||
onLoadOptions();
|
||||
}
|
||||
}, [filter.referenceTable, options.length, loading, onLoadOptions]);
|
||||
|
||||
return (
|
||||
<Select value={value} onValueChange={onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={loading ? "로딩 중..." : `${filter.label} 선택...`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__ALL__">전체</SelectItem>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
215
frontend/components/screen/filters/ModernDatePicker.tsx
Normal file
215
frontend/components/screen/filters/ModernDatePicker.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { CalendarIcon, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import {
|
||||
format,
|
||||
addMonths,
|
||||
subMonths,
|
||||
startOfMonth,
|
||||
endOfMonth,
|
||||
eachDayOfInterval,
|
||||
isSameMonth,
|
||||
isSameDay,
|
||||
isToday,
|
||||
} from "date-fns";
|
||||
import { ko } from "date-fns/locale";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface DateRangeValue {
|
||||
from?: Date;
|
||||
to?: Date;
|
||||
}
|
||||
|
||||
interface ModernDatePickerProps {
|
||||
label: string;
|
||||
value: DateRangeValue;
|
||||
onChange: (value: DateRangeValue) => void;
|
||||
includeTime?: boolean;
|
||||
}
|
||||
|
||||
export const ModernDatePicker: React.FC<ModernDatePickerProps> = ({ label, value, onChange, includeTime = false }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [currentMonth, setCurrentMonth] = useState(new Date());
|
||||
const [selectingType, setSelectingType] = useState<"from" | "to">("from");
|
||||
|
||||
const formatDate = (date: Date | undefined) => {
|
||||
if (!date) return "";
|
||||
if (includeTime) {
|
||||
return format(date, "yyyy-MM-dd HH:mm", { locale: ko });
|
||||
}
|
||||
return format(date, "yyyy-MM-dd", { locale: ko });
|
||||
};
|
||||
|
||||
const displayValue = () => {
|
||||
if (value?.from && value?.to) {
|
||||
return `${formatDate(value.from)} ~ ${formatDate(value.to)}`;
|
||||
}
|
||||
if (value?.from) {
|
||||
return `${formatDate(value.from)} ~`;
|
||||
}
|
||||
if (value?.to) {
|
||||
return `~ ${formatDate(value.to)}`;
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
const handleDateClick = (date: Date) => {
|
||||
if (selectingType === "from") {
|
||||
const newValue = { ...value, from: date };
|
||||
onChange(newValue);
|
||||
setSelectingType("to");
|
||||
} else {
|
||||
const newValue = { ...value, to: date };
|
||||
onChange(newValue);
|
||||
setSelectingType("from");
|
||||
}
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
onChange({});
|
||||
setSelectingType("from");
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
setIsOpen(false);
|
||||
setSelectingType("from");
|
||||
// 날짜는 이미 선택 시점에 onChange가 호출되므로 중복 호출 제거
|
||||
};
|
||||
|
||||
const monthStart = startOfMonth(currentMonth);
|
||||
const monthEnd = endOfMonth(currentMonth);
|
||||
const days = eachDayOfInterval({ start: monthStart, end: monthEnd });
|
||||
|
||||
// 달력 시작을 월요일로 맞추기 위해 앞의 빈 칸들 계산
|
||||
const startDate = new Date(monthStart);
|
||||
const dayOfWeek = startDate.getDay();
|
||||
const paddingDays = dayOfWeek === 0 ? 6 : dayOfWeek - 1; // 일요일(0)이면 6개, 나머지는 -1
|
||||
|
||||
const allDays = [...Array(paddingDays).fill(null), ...days];
|
||||
|
||||
const isInRange = (date: Date) => {
|
||||
if (!value.from || !value.to) return false;
|
||||
return date >= value.from && date <= value.to;
|
||||
};
|
||||
|
||||
const isRangeStart = (date: Date) => {
|
||||
return value.from && isSameDay(date, value.from);
|
||||
};
|
||||
|
||||
const isRangeEnd = (date: Date) => {
|
||||
return value.to && isSameDay(date, value.to);
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"w-full justify-start text-left font-normal",
|
||||
!value?.from && !value?.to && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{displayValue() || `${label} 기간 선택...`}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<div className="p-4">
|
||||
{/* 헤더 */}
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium">기간 선택</h3>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{selectingType === "from" ? "시작일을 선택하세요" : "종료일을 선택하세요"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 월 네비게이션 */}
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<Button variant="ghost" size="sm" onClick={() => setCurrentMonth(subMonths(currentMonth, 1))}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="text-sm font-medium">{format(currentMonth, "yyyy년 MM월", { locale: ko })}</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => setCurrentMonth(addMonths(currentMonth, 1))}>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 요일 헤더 */}
|
||||
<div className="mb-2 grid grid-cols-7 gap-1">
|
||||
{["월", "화", "수", "목", "금", "토", "일"].map((day) => (
|
||||
<div key={day} className="text-muted-foreground p-2 text-center text-xs font-medium">
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 날짜 그리드 */}
|
||||
<div className="mb-4 grid grid-cols-7 gap-1">
|
||||
{allDays.map((date, index) => {
|
||||
if (!date) {
|
||||
return <div key={index} className="p-2" />;
|
||||
}
|
||||
|
||||
const isCurrentMonth = isSameMonth(date, currentMonth);
|
||||
const isSelected = isRangeStart(date) || isRangeEnd(date);
|
||||
const isInRangeDate = isInRange(date);
|
||||
const isTodayDate = isToday(date);
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={date.toISOString()}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-8 w-8 p-0 text-xs",
|
||||
!isCurrentMonth && "text-muted-foreground opacity-50",
|
||||
isSelected && "bg-primary text-primary-foreground hover:bg-primary",
|
||||
isInRangeDate && !isSelected && "bg-muted",
|
||||
isTodayDate && !isSelected && "border-primary border",
|
||||
selectingType === "from" && "hover:bg-primary/20",
|
||||
selectingType === "to" && "hover:bg-secondary/20",
|
||||
)}
|
||||
onClick={() => handleDateClick(date)}
|
||||
disabled={!isCurrentMonth}
|
||||
>
|
||||
{format(date, "d")}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 선택된 범위 표시 */}
|
||||
{(value.from || value.to) && (
|
||||
<div className="bg-muted mb-4 rounded-md p-2">
|
||||
<div className="text-muted-foreground mb-1 text-xs">선택된 기간</div>
|
||||
<div className="text-sm">
|
||||
{value.from && <span className="font-medium">시작: {formatDate(value.from)}</span>}
|
||||
{value.from && value.to && <span className="mx-2">~</span>}
|
||||
{value.to && <span className="font-medium">종료: {formatDate(value.to)}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 액션 버튼 */}
|
||||
<div className="flex justify-between">
|
||||
<Button variant="outline" size="sm" onClick={handleClear}>
|
||||
초기화
|
||||
</Button>
|
||||
<div className="space-x-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setIsOpen(false)}>
|
||||
취소
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleConfirm}>
|
||||
확인
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
@@ -320,18 +320,8 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
||||
columnsCount: table.columns.length,
|
||||
});
|
||||
|
||||
// 테이블의 모든 컬럼을 기본 설정으로 추가
|
||||
const defaultColumns: DataTableColumn[] = table.columns.map((col, index) => ({
|
||||
id: generateComponentId(),
|
||||
columnName: col.columnName,
|
||||
label: col.columnLabel || col.columnName,
|
||||
widgetType: getWidgetTypeFromColumn(col),
|
||||
gridColumns: 2, // 기본 2칸
|
||||
visible: index < 6, // 처음 6개만 기본으로 표시
|
||||
filterable: isFilterableWebType(getWidgetTypeFromColumn(col)),
|
||||
sortable: true,
|
||||
searchable: ["text", "email", "tel"].includes(getWidgetTypeFromColumn(col)),
|
||||
}));
|
||||
// 테이블 변경 시 컬럼을 자동으로 추가하지 않음 (사용자가 수동으로 추가해야 함)
|
||||
const defaultColumns: DataTableColumn[] = [];
|
||||
|
||||
console.log("✅ 생성된 컬럼 설정:", {
|
||||
defaultColumnsCount: defaultColumns.length,
|
||||
@@ -785,6 +775,11 @@ const DataTableConfigPanelComponent: React.FC<DataTableConfigPanelProps> = ({
|
||||
widgetType,
|
||||
label: targetColumn.columnLabel || targetColumn.columnName,
|
||||
gridColumns: 3,
|
||||
// 웹타입별 추가 정보 설정
|
||||
codeCategory: targetColumn.codeCategory,
|
||||
referenceTable: targetColumn.referenceTable,
|
||||
referenceColumn: targetColumn.referenceColumn,
|
||||
displayColumn: targetColumn.displayColumn,
|
||||
};
|
||||
|
||||
console.log("➕ 필터 추가 시작:", {
|
||||
|
||||
@@ -121,25 +121,9 @@ export const DataTableTemplate: React.FC<DataTableTemplateProps> = ({
|
||||
className = "",
|
||||
isPreview = true,
|
||||
}) => {
|
||||
// 미리보기용 기본 컬럼 데이터
|
||||
// 설정된 컬럼만 사용 (자동 생성 안함)
|
||||
const defaultColumns = React.useMemo(() => {
|
||||
if (columns.length > 0) return columns;
|
||||
|
||||
return [
|
||||
{ id: "id", label: "ID", type: "number", visible: true, sortable: true, filterable: false, width: 80 },
|
||||
{ id: "name", label: "이름", type: "text", visible: true, sortable: true, filterable: true, width: 150 },
|
||||
{ id: "email", label: "이메일", type: "email", visible: true, sortable: true, filterable: true, width: 200 },
|
||||
{ id: "status", label: "상태", type: "select", visible: true, sortable: true, filterable: true, width: 100 },
|
||||
{
|
||||
id: "created_date",
|
||||
label: "생성일",
|
||||
type: "date",
|
||||
visible: true,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
width: 120,
|
||||
},
|
||||
];
|
||||
return columns || [];
|
||||
}, [columns]);
|
||||
|
||||
// 미리보기용 샘플 데이터
|
||||
|
||||
Reference in New Issue
Block a user