- NoticePopupModal: 공지 팝업 컨테이너/actions 신규 구현 - AuthenticatedLayout에 공지 팝업 연동 - CalendarSection: 일정 타입 확장 및 UI 개선 - BillManagementClient: 기능 확장 - PopupManagement: popupDetailConfig 대폭 확장, 상세/폼 개선 - BoardForm/BoardManagement: 게시판 폼 개선 - LoginPage, logout, userStorage: 인증 관련 소폭 수정 - dashboard types 정비 - claudedocs: 공지팝업 구현, 캘린더 어음연동/일정타입, API changelog 문서 추가
325 lines
9.7 KiB
TypeScript
325 lines
9.7 KiB
TypeScript
/**
|
|
* 팝업관리 상세 페이지 설정
|
|
* IntegratedDetailTemplate V2 마이그레이션
|
|
*/
|
|
|
|
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, 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 = [
|
|
{ value: 'all', label: '전사' },
|
|
{ value: 'department', label: '부서별' },
|
|
];
|
|
|
|
// ===== 상태 옵션 =====
|
|
const STATUS_OPTIONS = [
|
|
{ value: 'inactive', label: '사용안함' },
|
|
{ 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: 'custom',
|
|
required: true,
|
|
validation: [
|
|
{
|
|
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',
|
|
label: '시작일',
|
|
type: 'date',
|
|
required: true,
|
|
validation: [
|
|
{ type: 'required', message: '시작일을 선택해주세요.' },
|
|
],
|
|
},
|
|
{
|
|
key: 'endDate',
|
|
label: '종료일',
|
|
type: 'date',
|
|
required: true,
|
|
validation: [
|
|
{ type: 'required', message: '종료일을 선택해주세요.' },
|
|
{
|
|
type: 'custom',
|
|
message: '종료일은 시작일 이후여야 합니다.',
|
|
validate: (value, formData) => {
|
|
const startDate = formData.startDate as string;
|
|
const endDate = value as string;
|
|
if (!startDate || !endDate) return true;
|
|
return endDate >= startDate;
|
|
},
|
|
},
|
|
],
|
|
},
|
|
{
|
|
key: 'title',
|
|
label: '제목',
|
|
type: 'text',
|
|
required: true,
|
|
placeholder: '제목을 입력해주세요',
|
|
gridSpan: 2,
|
|
validation: [
|
|
{ type: 'required', message: '제목을 입력해주세요.' },
|
|
],
|
|
},
|
|
{
|
|
key: 'content',
|
|
label: '내용',
|
|
type: 'custom',
|
|
required: true,
|
|
gridSpan: 2,
|
|
validation: [
|
|
{
|
|
type: 'custom',
|
|
message: '내용을 입력해주세요.',
|
|
validate: (value) => {
|
|
const content = value as string;
|
|
return !!content && content.trim() !== '' && content !== '<p></p>';
|
|
},
|
|
},
|
|
],
|
|
renderField: ({ value, onChange, mode, disabled }) => {
|
|
if (mode === 'view') {
|
|
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) || '') },
|
|
});
|
|
}
|
|
return createElement(RichTextEditor, {
|
|
value: (value as string) || '',
|
|
onChange: onChange,
|
|
placeholder: '내용을 입력해주세요',
|
|
minHeight: '200px',
|
|
disabled: disabled,
|
|
});
|
|
},
|
|
formatValue: (value) => {
|
|
if (!value) return '-';
|
|
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) },
|
|
});
|
|
},
|
|
},
|
|
{
|
|
key: 'status',
|
|
label: '상태',
|
|
type: 'radio',
|
|
options: STATUS_OPTIONS,
|
|
defaultValue: 'inactive',
|
|
},
|
|
{
|
|
key: 'author',
|
|
label: '작성자',
|
|
type: 'text',
|
|
disabled: true,
|
|
hideInForm: false,
|
|
},
|
|
{
|
|
key: 'createdAt',
|
|
label: '등록일시',
|
|
type: 'text',
|
|
disabled: true,
|
|
hideInForm: false,
|
|
},
|
|
];
|
|
|
|
// ===== 섹션 정의 =====
|
|
export const popupSections: SectionDefinition[] = [
|
|
{
|
|
id: 'basicInfo',
|
|
title: '팝업 정보',
|
|
description: '팝업의 기본 정보를 입력해주세요',
|
|
fields: ['target', 'startDate', 'endDate', 'title', 'content', 'status', 'author', 'createdAt'],
|
|
},
|
|
];
|
|
|
|
// ===== 설정 =====
|
|
export const popupDetailConfig: DetailConfig<Popup> = {
|
|
title: '팝업관리',
|
|
description: '팝업 목록을 관리합니다',
|
|
icon: Megaphone,
|
|
basePath: '/ko/settings/popup-management',
|
|
fields: popupFields,
|
|
sections: popupSections,
|
|
gridColumns: 2,
|
|
actions: {
|
|
submitLabel: '저장',
|
|
cancelLabel: '취소',
|
|
showDelete: true,
|
|
deleteLabel: '삭제',
|
|
showEdit: true,
|
|
editLabel: '수정',
|
|
showBack: true,
|
|
backLabel: '목록',
|
|
deleteConfirmMessage: {
|
|
title: '팝업 삭제',
|
|
description: '이 팝업을 삭제하시겠습니까? 삭제된 데이터는 복구할 수 없습니다.',
|
|
},
|
|
},
|
|
transformInitialData: (data: Popup) => ({
|
|
target: encodeTargetValue(data.target, data.targetId),
|
|
startDate: data.startDate || '',
|
|
endDate: data.endDate || '',
|
|
title: data.title || '',
|
|
content: data.content || '',
|
|
status: data.status || 'inactive',
|
|
author: data.author || '',
|
|
createdAt: data.createdAt || '',
|
|
}),
|
|
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)
|
|
)
|
|
)
|
|
)
|
|
);
|
|
}
|