동기화

This commit is contained in:
GA Pro
2026-05-14 01:21:27 +09:00
parent acea2afce5
commit 958fe6b980
379 changed files with 21786 additions and 423 deletions
@@ -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>
);
}
+37 -17
View File
@@ -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>
+106 -31
View File
@@ -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>
);
}