Files
invyone/frontend/components/fc/FcSearch.tsx
T
2026-04-10 13:33:37 +09:00

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;
}