동기화
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
import { Steps, Typography } from 'antd';
|
||||
import { IconCheck, IconX, IconClock } from '@tabler/icons-react';
|
||||
import { ApprovalRequestStep } from '@/api/approval';
|
||||
import { COLOR_SUCCESS, COLOR_ERROR, COLOR_PRIMARY, GRAY } from '@/theme/tokens';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface Props {
|
||||
steps: ApprovalRequestStep[];
|
||||
currentStep: number;
|
||||
}
|
||||
|
||||
function stepIcon(status: string) {
|
||||
if (status === 'APPROVED') return <IconCheck size={14} style={{ color: COLOR_SUCCESS }} />;
|
||||
if (status === 'REJECTED') return <IconX size={14} style={{ color: COLOR_ERROR }} />;
|
||||
return <IconClock size={14} style={{ color: COLOR_PRIMARY }} />;
|
||||
}
|
||||
|
||||
function stepStatus(status: string): 'finish' | 'error' | 'process' | 'wait' {
|
||||
if (status === 'APPROVED') return 'finish';
|
||||
if (status === 'REJECTED') return 'error';
|
||||
if (status === 'IN_PROGRESS') return 'process';
|
||||
return 'wait';
|
||||
}
|
||||
|
||||
/**
|
||||
* 결재선 진행 시각화 컴포넌트.
|
||||
* Steps 수평 렌더링 + 단계별 상태 색상.
|
||||
*/
|
||||
export default function ApprovalProgress({ steps, currentStep }: Props) {
|
||||
const items = steps.map((s) => ({
|
||||
title: (
|
||||
<Text style={{ fontSize: 12, fontWeight: 600, color: GRAY[700] }}>
|
||||
{s.approverName}
|
||||
</Text>
|
||||
),
|
||||
description: (
|
||||
<div style={{ fontSize: 11, color: GRAY[500] }}>
|
||||
{s.comment && <div style={{ marginTop: 2, color: GRAY[600] }}>{s.comment}</div>}
|
||||
{s.processedAt && (
|
||||
<div style={{ marginTop: 2 }}>
|
||||
{s.processedAt.slice(0, 10)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
icon: stepIcon(s.status),
|
||||
status: stepStatus(s.status),
|
||||
}));
|
||||
|
||||
return (
|
||||
<Steps
|
||||
size="small"
|
||||
current={currentStep - 1}
|
||||
items={items}
|
||||
style={{ marginTop: 8 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { useState } from 'react';
|
||||
import { Button, List, Progress, Space, Typography, Upload, message } from 'antd';
|
||||
import { IconFile, IconTrash, IconUpload } from '@tabler/icons-react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { attachmentApi, AttachmentMeta } from '@/api/attachment';
|
||||
import { GRAY, RADIUS, SHADOW, COLOR_ERROR } from '@/theme/tokens';
|
||||
|
||||
const { Text } = Typography;
|
||||
const { Dragger } = Upload;
|
||||
|
||||
interface Props {
|
||||
refType: string;
|
||||
refId: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 공용 첨부파일 업로드/목록/삭제 컴포넌트.
|
||||
* refType + refId 로 연결. Drag+Drop 지원.
|
||||
*/
|
||||
export default function AttachmentUpload({ refType, refId }: Props) {
|
||||
const qc = useQueryClient();
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
|
||||
const { data: files = [], isLoading } = useQuery({
|
||||
queryKey: ['attachments', refType, refId],
|
||||
queryFn: () => attachmentApi.list(refType, refId),
|
||||
enabled: !!refId,
|
||||
});
|
||||
|
||||
const handleUpload = async (file: File) => {
|
||||
setUploading(true);
|
||||
setProgress(0);
|
||||
try {
|
||||
await attachmentApi.upload(refType, refId, file, setProgress);
|
||||
message.success(`${file.name} 업로드 완료`);
|
||||
qc.invalidateQueries({ queryKey: ['attachments', refType, refId] });
|
||||
} catch {
|
||||
message.error('업로드 실패');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
setProgress(0);
|
||||
}
|
||||
return false; // prevent antd auto-upload
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number, name: string) => {
|
||||
try {
|
||||
await attachmentApi.delete(id);
|
||||
message.success(`${name} 삭제 완료`);
|
||||
qc.invalidateQueries({ queryKey: ['attachments', refType, refId] });
|
||||
} catch {
|
||||
message.error('삭제 실패');
|
||||
}
|
||||
};
|
||||
|
||||
const fmtSize = (bytes: number) => {
|
||||
if (bytes < 1024) return `${bytes}B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)}MB`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Dragger
|
||||
multiple={false}
|
||||
showUploadList={false}
|
||||
beforeUpload={handleUpload}
|
||||
disabled={uploading}
|
||||
style={{
|
||||
borderRadius: RADIUS.md,
|
||||
borderColor: GRAY[200],
|
||||
background: GRAY[50],
|
||||
padding: '12px 0',
|
||||
}}
|
||||
>
|
||||
<p style={{ margin: 0, color: GRAY[500], fontSize: 13 }}>
|
||||
<IconUpload size={20} style={{ verticalAlign: 'middle', marginRight: 8 }} />
|
||||
파일을 드래그하거나 클릭하여 업로드 (최대 50MB)
|
||||
</p>
|
||||
</Dragger>
|
||||
|
||||
{uploading && (
|
||||
<Progress percent={progress} status="active" style={{ marginTop: 8 }} />
|
||||
)}
|
||||
|
||||
{!isLoading && files.length > 0 && (
|
||||
<List
|
||||
size="small"
|
||||
style={{
|
||||
marginTop: 12,
|
||||
background: '#fff',
|
||||
border: `1px solid ${GRAY[100]}`,
|
||||
borderRadius: RADIUS.md,
|
||||
boxShadow: SHADOW.sm,
|
||||
}}
|
||||
dataSource={files}
|
||||
renderItem={(f: AttachmentMeta) => (
|
||||
<List.Item
|
||||
actions={[
|
||||
<Button
|
||||
key="del"
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<IconTrash size={14} />}
|
||||
style={{ color: COLOR_ERROR }}
|
||||
onClick={() => handleDelete(f.attachmentId, f.originalName)}
|
||||
/>,
|
||||
]}
|
||||
>
|
||||
<Space>
|
||||
<IconFile size={14} style={{ color: GRAY[400] }} />
|
||||
<Text style={{ fontSize: 13 }}>{f.originalName}</Text>
|
||||
<Text style={{ fontSize: 12, color: GRAY[400] }}>({fmtSize(f.fileSize)})</Text>
|
||||
</Space>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { Badge, Button, Dropdown, Empty, Space, Typography } from 'antd';
|
||||
import { IconBell } from '@tabler/icons-react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { notificationApi, UserNotificationRow } from '@/api/notification';
|
||||
import { GRAY, COLOR_PRIMARY, RADIUS, SHADOW } from '@/theme/tokens';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
/**
|
||||
* MainLayout 우측 상단 알림 벨.
|
||||
* 미읽음 카운트 빨간 dot + Dropdown 최근 10개 리스트.
|
||||
*/
|
||||
export default function NotificationBell() {
|
||||
const qc = useQueryClient();
|
||||
|
||||
const { data: count = 0 } = useQuery({
|
||||
queryKey: ['notifications', 'unread-count'],
|
||||
queryFn: notificationApi.unreadCount,
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
const { data: page } = useQuery({
|
||||
queryKey: ['notifications', 'list'],
|
||||
queryFn: () => notificationApi.list({ pageSize: 10 }),
|
||||
});
|
||||
|
||||
const notifications = page?.list ?? [];
|
||||
|
||||
const handleRead = async (id: number) => {
|
||||
await notificationApi.markRead(id);
|
||||
qc.invalidateQueries({ queryKey: ['notifications'] });
|
||||
};
|
||||
|
||||
const handleReadAll = async () => {
|
||||
await notificationApi.markAllRead();
|
||||
qc.invalidateQueries({ queryKey: ['notifications'] });
|
||||
};
|
||||
|
||||
const overlay = (
|
||||
<div style={{
|
||||
width: 340,
|
||||
background: '#fff',
|
||||
borderRadius: RADIUS.lg,
|
||||
boxShadow: SHADOW.lg,
|
||||
border: `1px solid ${GRAY[100]}`,
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: '12px 16px',
|
||||
borderBottom: `1px solid ${GRAY[100]}`,
|
||||
}}>
|
||||
<Text style={{ fontSize: 14, fontWeight: 700, color: GRAY[800] }}>알림</Text>
|
||||
{count > 0 && (
|
||||
<Button type="link" size="small" onClick={handleReadAll}
|
||||
style={{ fontSize: 12, padding: 0 }}>
|
||||
모두 읽음
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{notifications.length === 0 ? (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="알림이 없습니다"
|
||||
style={{ padding: '24px 0' }} />
|
||||
) : (
|
||||
<div style={{ maxHeight: 360, overflowY: 'auto' }}>
|
||||
{notifications.map((n: UserNotificationRow) => (
|
||||
<div
|
||||
key={n.notificationId}
|
||||
onClick={() => !n.isRead && handleRead(n.notificationId)}
|
||||
style={{
|
||||
padding: '10px 16px',
|
||||
cursor: n.isRead ? 'default' : 'pointer',
|
||||
background: n.isRead ? '#fff' : '#EBF3FF',
|
||||
borderBottom: `1px solid ${GRAY[100]}`,
|
||||
}}
|
||||
>
|
||||
<Space direction="vertical" size={2} style={{ width: '100%' }}>
|
||||
<Text style={{
|
||||
fontSize: 13,
|
||||
fontWeight: n.isRead ? 400 : 600,
|
||||
color: GRAY[800],
|
||||
}}>
|
||||
{n.title}
|
||||
</Text>
|
||||
<Text style={{ fontSize: 12, color: GRAY[500] }}>{n.content}</Text>
|
||||
<Text style={{ fontSize: 11, color: GRAY[400] }}>
|
||||
{n.createdAt?.slice(0, 16)}
|
||||
</Text>
|
||||
</Space>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
dropdownRender={() => overlay}
|
||||
placement="bottomRight"
|
||||
trigger={['click']}
|
||||
>
|
||||
<Badge count={count} size="small" offset={[-3, 3]}>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<IconBell size={18} />}
|
||||
style={{
|
||||
color: GRAY[600],
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}
|
||||
/>
|
||||
</Badge>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,15 @@
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import { Skeleton } from 'antd';
|
||||
import { AgGridReact } from '@ag-grid-community/react';
|
||||
import { ModuleRegistry } from '@ag-grid-community/core';
|
||||
import { ClientSideRowModelModule } from '@ag-grid-community/client-side-row-model';
|
||||
import type { ColDef, GridReadyEvent, GridApi } from '@ag-grid-community/core';
|
||||
import type { ColDef, GridReadyEvent, GridApi, RowClassParams, RowStyle } from '@ag-grid-community/core';
|
||||
import '@ag-grid-community/styles/ag-grid.css';
|
||||
import '@ag-grid-community/styles/ag-theme-quartz.css';
|
||||
import { GRAY } from '@/theme/tokens';
|
||||
|
||||
ModuleRegistry.registerModules([ClientSideRowModelModule]);
|
||||
|
||||
/**
|
||||
* AG Grid 한국어 로케일 (자주 쓰이는 항목 위주).
|
||||
* 전체 키 목록은 AG Grid 공식 문서 참조.
|
||||
*/
|
||||
const AG_GRID_LOCALE_KO: Record<string, string> = {
|
||||
page: '페이지', more: '더보기', to: '~', of: '/', next: '다음', last: '마지막',
|
||||
first: '처음', previous: '이전', loadingOoo: '불러오는 중...',
|
||||
@@ -33,13 +31,15 @@ interface Props<T> {
|
||||
/** 셀 편집 가능 시 콜백 */
|
||||
onCellChanged?: (row: T, field: string, newValue: unknown) => void;
|
||||
rowKey?: keyof T;
|
||||
/** 행별 스타일 콜백 (만료 임박 강조 등) */
|
||||
getRowStyle?: (params: RowClassParams<T>) => RowStyle | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* AG Grid Community 클라이언트사이드 래퍼.
|
||||
* - 100건 이상 / 셀 편집 / 합계행이 필요한 화면용
|
||||
* - 서버사이드는 별도 SSRM 모듈 필요 (Community 한정 → enterprise만 지원)
|
||||
* 여기서는 현실적인 절충안으로 클라이언트사이드 + 외부 페이징 사용
|
||||
* - ag-theme-quartz 적용
|
||||
* - 헤더 폰트/높이 tokens 와 동기화
|
||||
* - 로딩 시 Skeleton 표시
|
||||
*/
|
||||
export default function DataGrid<T extends Record<string, unknown>>({
|
||||
rows,
|
||||
@@ -49,15 +49,17 @@ export default function DataGrid<T extends Record<string, unknown>>({
|
||||
pinnedBottomRow,
|
||||
onCellChanged,
|
||||
rowKey,
|
||||
getRowStyle: getRowStyleProp,
|
||||
}: Props<T>) {
|
||||
const apiRef = useRef<GridApi<T> | null>(null);
|
||||
|
||||
const defaultColDef = useMemo<ColDef>(
|
||||
() => ({
|
||||
sortable: true,
|
||||
filter: true,
|
||||
resizable: true,
|
||||
sortable: true,
|
||||
filter: true,
|
||||
resizable: true,
|
||||
suppressMovable: false,
|
||||
cellStyle: { display: 'flex', alignItems: 'center' },
|
||||
}),
|
||||
[],
|
||||
);
|
||||
@@ -69,10 +71,18 @@ export default function DataGrid<T extends Record<string, unknown>>({
|
||||
useEffect(() => {
|
||||
if (apiRef.current) {
|
||||
if (loading) apiRef.current.showLoadingOverlay();
|
||||
else apiRef.current.hideOverlay();
|
||||
else apiRef.current.hideOverlay();
|
||||
}
|
||||
}, [loading]);
|
||||
|
||||
if (loading && !rows?.length) {
|
||||
return (
|
||||
<div style={{ padding: 24, background: '#fff', borderRadius: 16, border: `1px solid ${GRAY[100]}` }}>
|
||||
<Skeleton active paragraph={{ rows: 8 }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ag-theme-quartz" style={{ height, width: '100%' }}>
|
||||
<AgGridReact<T>
|
||||
@@ -80,15 +90,18 @@ export default function DataGrid<T extends Record<string, unknown>>({
|
||||
columnDefs={columns}
|
||||
defaultColDef={defaultColDef}
|
||||
animateRows
|
||||
rowHeight={36}
|
||||
headerHeight={38}
|
||||
rowHeight={56}
|
||||
headerHeight={44}
|
||||
localeText={AG_GRID_LOCALE_KO}
|
||||
rowSelection="single"
|
||||
getRowId={rowKey ? (p) => String(p.data[rowKey]) : undefined}
|
||||
pinnedBottomRowData={pinnedBottomRow ? [pinnedBottomRow as T] : undefined}
|
||||
getRowStyle={(params) => {
|
||||
if (params.node.rowPinned) {
|
||||
return { background: '#fafafa', fontWeight: 500 };
|
||||
return { background: '#f0f4ff', fontWeight: 600 };
|
||||
}
|
||||
if (getRowStyleProp) {
|
||||
return getRowStyleProp(params as RowClassParams<T>);
|
||||
}
|
||||
return undefined;
|
||||
}}
|
||||
@@ -98,8 +111,15 @@ export default function DataGrid<T extends Record<string, unknown>>({
|
||||
}
|
||||
}}
|
||||
onGridReady={onGridReady}
|
||||
loadingOverlayComponent={() => <span>불러오는 중...</span>}
|
||||
noRowsOverlayComponent={() => <span>데이터가 없습니다</span>}
|
||||
loadingOverlayComponent={() => (
|
||||
<div style={{ padding: 24, color: GRAY[500], fontSize: 13 }}>불러오는 중...</div>
|
||||
)}
|
||||
noRowsOverlayComponent={() => (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', padding: 40, color: GRAY[400] }}>
|
||||
<div style={{ fontSize: 32, marginBottom: 8 }}>📭</div>
|
||||
<div style={{ fontSize: 13 }}>데이터가 없습니다</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Component, type ReactNode, type ErrorInfo } from 'react';
|
||||
import { Button, Result } from 'antd';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export default class ErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
console.error('[ErrorBoundary] 페이지 오류:', error, info.componentStack);
|
||||
}
|
||||
|
||||
handleRetry = () => {
|
||||
this.setState({ hasError: false, error: null });
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<Result
|
||||
status="error"
|
||||
title="페이지 오류"
|
||||
subTitle={this.state.error?.message ?? '알 수 없는 오류가 발생했습니다.'}
|
||||
extra={
|
||||
<Button type="primary" onClick={this.handleRetry}>
|
||||
다시 시도
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { PageContainer as ProPageContainer } from '@ant-design/pro-components';
|
||||
import { Typography } from 'antd';
|
||||
import { GRAY } from '@/theme/tokens';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
@@ -9,22 +13,33 @@ interface Props {
|
||||
}
|
||||
|
||||
/**
|
||||
* Ant Design Pro 의 PageContainer 래퍼.
|
||||
* - 자동 Breadcrumb / Title / Tabs / Footer 슬롯 제공
|
||||
* - extra 는 우측 상단 액션 영역
|
||||
* 모든 페이지 공통 래퍼.
|
||||
* - 타이틀 + 설명 + 우측 액션 버튼 슬롯
|
||||
* - breadcrumb 자동 표시 (ProPageContainer 기본)
|
||||
* - 배경 / 간격 일관성
|
||||
*/
|
||||
export default function PageContainer({ title, description, extra, children }: Props) {
|
||||
return (
|
||||
<ProPageContainer
|
||||
header={{
|
||||
title,
|
||||
title: (
|
||||
<span style={{ fontSize: 24, fontWeight: 700, color: GRAY[800], letterSpacing: -0.5 }}>
|
||||
{title}
|
||||
</span>
|
||||
),
|
||||
breadcrumb: {},
|
||||
ghost: false,
|
||||
ghost: true,
|
||||
extra,
|
||||
style: {
|
||||
paddingBottom: description ? 8 : 0,
|
||||
},
|
||||
}}
|
||||
content={
|
||||
description ? <span style={{ color: '#666', fontSize: 13 }}>{description}</span> : undefined
|
||||
description ? (
|
||||
<Text style={{ fontSize: 14, color: GRAY[500] }}>{description}</Text>
|
||||
) : undefined
|
||||
}
|
||||
style={{ minHeight: 'calc(100vh - 56px)' }}
|
||||
>
|
||||
{children}
|
||||
</ProPageContainer>
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import { Button, Col, DatePicker, Form, Input, Row, Space } from 'antd';
|
||||
import { useState } from 'react';
|
||||
import { Button, Col, DatePicker, Form, Input, Row, Space, Typography } from 'antd';
|
||||
import { IconChevronDown, IconChevronUp, IconSearch, IconX } from '@tabler/icons-react';
|
||||
import dayjs from 'dayjs';
|
||||
import CodeSelect from './CodeSelect';
|
||||
import { GRAY, COLOR_PRIMARY } from '@/theme/tokens';
|
||||
|
||||
const { Text } = Typography;
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
export interface SearchCondition {
|
||||
type: 'text' | 'code' | 'dateRange' | 'month';
|
||||
name: string;
|
||||
label: string;
|
||||
groupCode?: string; // type=code 일 때
|
||||
groupCode?: string; // type=code 일 때
|
||||
placeholder?: string;
|
||||
span?: number; // 24그리드 기준 너비 (default 6)
|
||||
span?: number; // 24그리드 기준 너비 (default 6)
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -18,14 +22,25 @@ interface Props {
|
||||
initialValues?: Record<string, unknown>;
|
||||
onSearch: (values: Record<string, unknown>) => void;
|
||||
onReset?: () => void;
|
||||
/** 초기에 접힘 상태로 시작할지 (default: false = 펼침) */
|
||||
defaultCollapsed?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 조건 배열만 넘기면 자동 렌더링되는 검색 폼.
|
||||
* dateRange → startDate/endDate 로 분해, month → settleMonth(yyyyMM)
|
||||
* - 펼침/접힘 토글 지원
|
||||
* - dateRange → startDate/endDate 분해
|
||||
* - month → settleMonth (yyyyMM)
|
||||
*/
|
||||
export default function SearchForm({ conditions, initialValues, onSearch, onReset }: Props) {
|
||||
const [form] = Form.useForm();
|
||||
export default function SearchForm({
|
||||
conditions,
|
||||
initialValues,
|
||||
onSearch,
|
||||
onReset,
|
||||
defaultCollapsed = false,
|
||||
}: Props) {
|
||||
const [form] = Form.useForm();
|
||||
const [collapsed, setCollapsed] = useState(defaultCollapsed);
|
||||
|
||||
const handleSearch = (raw: Record<string, unknown>) => {
|
||||
const out: Record<string, unknown> = {};
|
||||
@@ -50,35 +65,95 @@ export default function SearchForm({ conditions, initialValues, onSearch, onRese
|
||||
};
|
||||
|
||||
return (
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={initialValues}
|
||||
onFinish={handleSearch}
|
||||
<div
|
||||
style={{
|
||||
background: 'linear-gradient(180deg, #FAFBFC 0%, #F5F7FA 100%)',
|
||||
padding: '14px 16px 6px',
|
||||
background: '#ffffff',
|
||||
border: `1px solid ${GRAY[100]}`,
|
||||
borderRadius: 16,
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.04), 0 4px 16px rgba(0,0,0,0.04)',
|
||||
marginBottom: 16,
|
||||
borderRadius: 8,
|
||||
border: '1px solid #EEF1F5',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Row gutter={16}>
|
||||
{conditions.map((c) => (
|
||||
<Col span={c.span ?? 6} key={c.name}>
|
||||
<Form.Item name={c.name} label={c.label}>
|
||||
{renderField(c)}
|
||||
</Form.Item>
|
||||
</Col>
|
||||
))}
|
||||
<Col span={6} style={{ display: 'flex', alignItems: 'flex-end', paddingBottom: 24 }}>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit">검색</Button>
|
||||
<Button onClick={handleReset}>초기화</Button>
|
||||
</Space>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
{/* 헤더 — 클릭해서 펼침/접힘 */}
|
||||
<div
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '10px 16px',
|
||||
cursor: 'pointer',
|
||||
background: GRAY[50],
|
||||
borderBottom: collapsed ? 'none' : `1px solid ${GRAY[100]}`,
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
<Space size={6} align="center">
|
||||
<IconSearch size={14} style={{ color: COLOR_PRIMARY }} />
|
||||
<Text style={{ fontSize: 13, fontWeight: 600, color: GRAY[700] }}>검색 조건</Text>
|
||||
{collapsed && (
|
||||
<Text style={{ fontSize: 12, color: GRAY[400] }}>
|
||||
(클릭하여 펼치기)
|
||||
</Text>
|
||||
)}
|
||||
</Space>
|
||||
{collapsed
|
||||
? <IconChevronDown size={16} style={{ color: GRAY[400] }} />
|
||||
: <IconChevronUp size={16} style={{ color: GRAY[400] }} />
|
||||
}
|
||||
</div>
|
||||
|
||||
{/* 폼 본체 */}
|
||||
{!collapsed && (
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={initialValues}
|
||||
onFinish={handleSearch}
|
||||
style={{ padding: '14px 16px 4px' }}
|
||||
>
|
||||
<Row gutter={[16, 0]}>
|
||||
{conditions.map((c) => (
|
||||
<Col span={c.span ?? 6} key={c.name}>
|
||||
<Form.Item
|
||||
name={c.name}
|
||||
label={
|
||||
<Text style={{ fontSize: 12, fontWeight: 500, color: GRAY[600] }}>
|
||||
{c.label}
|
||||
</Text>
|
||||
}
|
||||
style={{ marginBottom: 14 }}
|
||||
>
|
||||
{renderField(c)}
|
||||
</Form.Item>
|
||||
</Col>
|
||||
))}
|
||||
|
||||
{/* 버튼 컬럼 */}
|
||||
<Col span={6} style={{ display: 'flex', alignItems: 'flex-end', paddingBottom: 14 }}>
|
||||
<Space>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
icon={<IconSearch size={14} />}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 4 }}
|
||||
>
|
||||
검색
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleReset}
|
||||
icon={<IconX size={14} />}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 4 }}
|
||||
>
|
||||
초기화
|
||||
</Button>
|
||||
</Space>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user