157 lines
5.1 KiB
TypeScript
157 lines
5.1 KiB
TypeScript
'use client';
|
|
|
|
import { useMemo, useState, useCallback, useEffect, useRef } from 'react';
|
|
import { Search, RotateCcw } from 'lucide-react';
|
|
import { FieldRenderer } from './fields/FieldRenderer';
|
|
import type { FieldConfig, SearchConfig } from '@/types/invyone-component';
|
|
|
|
const DEFAULT_CONFIG: SearchConfig = {
|
|
dateRangeEnabled: true,
|
|
showResetButton: true,
|
|
autoSearch: false,
|
|
layout: 'inline',
|
|
};
|
|
|
|
interface FcSearchProps {
|
|
fields: FieldConfig[];
|
|
config?: Partial<SearchConfig>;
|
|
onSearch?: (params: Record<string, any>) => void;
|
|
}
|
|
|
|
export function FcSearch({ fields, config: configOverride, onSearch }: FcSearchProps) {
|
|
const config = useMemo(() => ({ ...DEFAULT_CONFIG, ...configOverride }), [configOverride]);
|
|
|
|
// searchable 필드만 추출, order 순
|
|
const searchFields = useMemo(
|
|
() => fields
|
|
.filter((f) => f.searchable && !f.system)
|
|
.sort((a, b) => a.order - b.order),
|
|
[fields],
|
|
);
|
|
|
|
const [values, setValues] = useState<Record<string, any>>({});
|
|
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
|
|
|
const handleChange = useCallback((column: string, value: any) => {
|
|
setValues((prev) => {
|
|
const next = { ...prev };
|
|
if (value === undefined || value === '' || value === null) {
|
|
delete next[column];
|
|
} else {
|
|
next[column] = value;
|
|
}
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
// autoSearch: 값 변경 시 300ms 디바운스 후 자동 검색
|
|
useEffect(() => {
|
|
if (!config.autoSearch) return;
|
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
debounceRef.current = setTimeout(() => {
|
|
onSearch?.(buildSearchParams(values, searchFields));
|
|
}, 300);
|
|
return () => {
|
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
};
|
|
}, [values, config.autoSearch, onSearch, searchFields]);
|
|
|
|
const handleSubmit = useCallback((e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
onSearch?.(buildSearchParams(values, searchFields));
|
|
}, [values, searchFields, onSearch]);
|
|
|
|
const handleReset = useCallback(() => {
|
|
setValues({});
|
|
onSearch?.({});
|
|
}, [onSearch]);
|
|
|
|
if (searchFields.length === 0) return null;
|
|
|
|
const isInline = config.layout === 'inline';
|
|
|
|
return (
|
|
<form
|
|
onSubmit={handleSubmit}
|
|
className="fc-search rounded-md border border-[var(--v5-glass-border)] p-2
|
|
bg-[var(--v5-glass)] backdrop-blur-[20px]"
|
|
>
|
|
<div className={isInline
|
|
? 'flex flex-wrap items-end gap-2'
|
|
: 'grid grid-cols-1 gap-2'
|
|
}>
|
|
{searchFields.map((field) => (
|
|
<div
|
|
key={field.column}
|
|
className={isInline ? 'flex flex-col gap-0.5 min-w-[140px] max-w-[220px]' : 'space-y-0.5'}
|
|
>
|
|
<label className="text-[0.65rem] font-medium text-[var(--v5-text-muted)] whitespace-nowrap">
|
|
{field.label}
|
|
</label>
|
|
<FieldRenderer
|
|
field={field}
|
|
value={values[field.column]}
|
|
onChange={(v) => handleChange(field.column, v)}
|
|
mode="search"
|
|
/>
|
|
</div>
|
|
))}
|
|
|
|
{/* 버튼 */}
|
|
<div className="flex items-end gap-1 ml-auto">
|
|
<button
|
|
type="submit"
|
|
className="flex items-center gap-1 h-7 px-2.5 rounded text-xs font-medium text-white
|
|
bg-[var(--v5-primary)] hover:opacity-90 shadow-[var(--v5-glow-sm)] transition-all"
|
|
>
|
|
<Search className="h-3 w-3" />
|
|
검색
|
|
</button>
|
|
{config.showResetButton && (
|
|
<button
|
|
type="button"
|
|
onClick={handleReset}
|
|
className="flex items-center gap-1 h-7 px-2 rounded text-xs border border-[var(--v5-border)]
|
|
text-[var(--v5-text-sec)] bg-[var(--v5-surface)]
|
|
hover:border-[var(--v5-primary)] hover:text-[var(--v5-primary)] transition-colors"
|
|
>
|
|
<RotateCcw className="h-3 w-3" />
|
|
초기화
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</form>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 검색 값을 API 파라미터 형식으로 변환.
|
|
* date 범위: { order_date_from, order_date_to }
|
|
* number 범위: { amount_min, amount_max }
|
|
* select 다중: { status: ['확정','완료'] }
|
|
* text 부분 일치: { customer_name: '삼성' }
|
|
*/
|
|
function buildSearchParams(values: Record<string, any>, fields: FieldConfig[]): Record<string, any> {
|
|
const params: Record<string, any> = {};
|
|
|
|
for (const field of fields) {
|
|
const val = values[field.column];
|
|
if (val === undefined || val === null || val === '') continue;
|
|
|
|
if ((field.type === 'date' || field.type === 'datetime') && typeof val === 'object' && !Array.isArray(val)) {
|
|
// 범위: {from, to}
|
|
if (val.from) params[`${field.column}_from`] = val.from;
|
|
if (val.to) params[`${field.column}_to`] = val.to;
|
|
} else if (field.type === 'number' && typeof val === 'object' && !Array.isArray(val)) {
|
|
// 범위: {min, max}
|
|
if (val.min !== undefined) params[`${field.column}_min`] = val.min;
|
|
if (val.max !== undefined) params[`${field.column}_max`] = val.max;
|
|
} else {
|
|
params[field.column] = val;
|
|
}
|
|
}
|
|
|
|
return params;
|
|
}
|