feat: 공지 팝업 시스템 구현 및 캘린더/어음/팝업관리 개선

- NoticePopupModal: 공지 팝업 컨테이너/actions 신규 구현
- AuthenticatedLayout에 공지 팝업 연동
- CalendarSection: 일정 타입 확장 및 UI 개선
- BillManagementClient: 기능 확장
- PopupManagement: popupDetailConfig 대폭 확장, 상세/폼 개선
- BoardForm/BoardManagement: 게시판 폼 개선
- LoginPage, logout, userStorage: 인증 관련 소폭 수정
- dashboard types 정비
- claudedocs: 공지팝업 구현, 캘린더 어음연동/일정타입, API changelog 문서 추가
This commit is contained in:
유병철
2026-03-10 15:16:41 +09:00
parent 7bd4bd38da
commit 397eb2c19c
23 changed files with 1004 additions and 79 deletions

View File

@@ -7,8 +7,10 @@ import { Megaphone } from 'lucide-react';
import type { DetailConfig, FieldDefinition, SectionDefinition } from '@/components/templates/IntegratedDetailTemplate/types';
import type { Popup, PopupFormData, PopupTarget, PopupStatus } from './types';
import { RichTextEditor } from '@/components/board/RichTextEditor';
import { createElement } from 'react';
import { createElement, useState, useEffect } from 'react';
import { sanitizeHTML } from '@/lib/sanitize';
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select';
import { getDepartmentList } from './actions';
// ===== 대상 옵션 =====
const TARGET_OPTIONS = [
@@ -22,18 +24,76 @@ const STATUS_OPTIONS = [
{ value: 'active', label: '사용함' },
];
/**
* target 값 인코딩/디코딩 헬퍼
* 'all' → target_type: all, target_id: null
* 'department:13' → target_type: department, target_id: 13
*/
export function encodeTargetValue(targetType: string, departmentId?: number | null): string {
if (targetType === 'department' && departmentId) {
return `department:${departmentId}`;
}
return targetType;
}
export function decodeTargetValue(value: string): { targetType: PopupTarget; departmentId: number | null } {
if (value.startsWith('department:')) {
const id = parseInt(value.split(':')[1]);
return { targetType: 'department', departmentId: isNaN(id) ? null : id };
}
if (value === 'department') {
return { targetType: 'department', departmentId: null };
}
return { targetType: 'all', departmentId: null };
}
// ===== 필드 정의 =====
export const popupFields: FieldDefinition[] = [
{
key: 'target',
label: '대상',
type: 'select',
type: 'custom',
required: true,
options: TARGET_OPTIONS,
placeholder: '대상을 선택해주세요',
validation: [
{ type: 'required', message: '대상을 선택해주세요.' },
{
type: 'custom',
message: '대상을 선택해주세요.',
validate: (value) => !!value && value !== '',
},
{
type: 'custom',
message: '부서를 선택해주세요.',
validate: (value) => {
const str = value as string;
if (str === 'department') return false; // 부서 미선택
return true;
},
},
],
renderField: ({ value, onChange, mode, disabled }) => {
const strValue = (value as string) || 'all';
const { targetType, departmentId } = decodeTargetValue(strValue);
if (mode === 'view') {
// view 모드에서는 formatValue로 처리
return null;
}
// Edit/Create 모드: 대상 타입 Select + 조건부 부서 Select
return createElement(TargetSelectorField, {
targetType,
departmentId,
onChange,
disabled: !!disabled,
});
},
formatValue: (value) => {
// view 모드에서 표시할 텍스트 — 실제 부서명은 PopupDetailClientV2에서 처리
const strValue = (value as string) || 'all';
if (strValue === 'all') return '전사';
if (strValue.startsWith('department:')) return '부서별'; // 부서명은 아래서 덮어씌움
return '부서별';
},
},
{
key: 'startDate',
@@ -92,13 +152,11 @@ export const popupFields: FieldDefinition[] = [
],
renderField: ({ value, onChange, mode, disabled }) => {
if (mode === 'view') {
// View 모드: HTML 렌더링
return createElement('div', {
className: 'border border-gray-200 rounded-md p-4 bg-gray-50 min-h-[100px] prose prose-sm max-w-none',
dangerouslySetInnerHTML: { __html: sanitizeHTML((value as string) || '') },
});
}
// Edit/Create 모드: RichTextEditor
return createElement(RichTextEditor, {
value: (value as string) || '',
onChange: onChange,
@@ -172,7 +230,7 @@ export const popupDetailConfig: DetailConfig<Popup> = {
},
},
transformInitialData: (data: Popup) => ({
target: data.target || 'all',
target: encodeTargetValue(data.target, data.targetId),
startDate: data.startDate || '',
endDate: data.endDate || '',
title: data.title || '',
@@ -181,12 +239,86 @@ export const popupDetailConfig: DetailConfig<Popup> = {
author: data.author || '',
createdAt: data.createdAt || '',
}),
transformSubmitData: (formData): Partial<PopupFormData> => ({
target: formData.target as PopupTarget,
title: formData.title as string,
content: formData.content as string,
status: formData.status as PopupStatus,
startDate: formData.startDate as string,
endDate: formData.endDate as string,
}),
transformSubmitData: (formData): Partial<PopupFormData> => {
const { targetType, departmentId } = decodeTargetValue(formData.target as string);
return {
target: targetType,
targetDepartmentId: departmentId ? String(departmentId) : undefined,
title: formData.title as string,
content: formData.content as string,
status: formData.status as PopupStatus,
startDate: formData.startDate as string,
endDate: formData.endDate as string,
};
},
};
// ===== 대상 선택 필드 컴포넌트 =====
interface TargetSelectorFieldProps {
targetType: string;
departmentId: number | null;
onChange: (value: unknown) => void;
disabled: boolean;
}
function TargetSelectorField({ targetType, departmentId, onChange, disabled }: TargetSelectorFieldProps) {
const [departments, setDepartments] = useState<{ id: number; name: string }[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (targetType === 'department' && departments.length === 0) {
setLoading(true);
getDepartmentList()
.then((list: { id: number; name: string }[]) => setDepartments(list))
.finally(() => setLoading(false));
}
}, [targetType]);
const handleTypeChange = (newType: string) => {
if (newType === 'all') {
onChange('all');
} else {
onChange('department');
}
};
const handleDepartmentChange = (deptId: string) => {
onChange(`department:${deptId}`);
};
return createElement('div', { className: 'space-y-2' },
// 대상 타입 Select
createElement(Select, {
value: targetType,
onValueChange: handleTypeChange,
disabled,
},
createElement(SelectTrigger, null,
createElement(SelectValue, { placeholder: '대상을 선택해주세요' })
),
createElement(SelectContent, null,
TARGET_OPTIONS.map(opt =>
createElement(SelectItem, { key: opt.value, value: opt.value }, opt.label)
)
)
),
// 부서별 선택 시 부서 Select 추가
targetType === 'department' && createElement(Select, {
value: departmentId ? String(departmentId) : undefined,
onValueChange: handleDepartmentChange,
disabled: disabled || loading,
},
createElement(SelectTrigger, null,
createElement(SelectValue, {
placeholder: loading ? '부서 목록 로딩 중...' : '부서를 선택해주세요',
})
),
createElement(SelectContent, null,
departments.map((dept: { id: number; name: string }) =>
createElement(SelectItem, { key: dept.id, value: String(dept.id) }, dept.name)
)
)
)
);
}