feat: 공지 팝업 시스템 구현 및 캘린더/어음/팝업관리 개선
- NoticePopupModal: 공지 팝업 컨테이너/actions 신규 구현 - AuthenticatedLayout에 공지 팝업 연동 - CalendarSection: 일정 타입 확장 및 UI 개선 - BillManagementClient: 기능 확장 - PopupManagement: popupDetailConfig 대폭 확장, 상세/폼 개선 - BoardForm/BoardManagement: 게시판 폼 개선 - LoginPage, logout, userStorage: 인증 관련 소폭 수정 - dashboard types 정비 - claudedocs: 공지팝업 구현, 캘린더 어음연동/일정타입, API changelog 문서 추가
This commit is contained in:
@@ -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)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user