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

@@ -12,7 +12,7 @@ import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetai
import type { DetailMode } from '@/components/templates/IntegratedDetailTemplate/types';
import type { Popup, PopupFormData } from './types';
import { getPopupById, createPopup, updatePopup, deletePopup } from './actions';
import { popupDetailConfig } from './popupDetailConfig';
import { popupDetailConfig, decodeTargetValue } from './popupDetailConfig';
import { toast } from 'sonner';
interface PopupDetailClientV2Props {
@@ -20,11 +20,14 @@ interface PopupDetailClientV2Props {
initialMode?: DetailMode;
}
// 현재 로그인 사용자 정보 (실제로는 auth context에서 가져옴)
const CURRENT_USER = {
id: 'user1',
name: '홍길동',
};
// 로그인 사용자 이름을 가져오는 헬퍼
function getLoggedInUserName(): string {
if (typeof window === 'undefined') return '';
try {
const userDataStr = localStorage.getItem('user');
return userDataStr ? JSON.parse(userDataStr).name || '' : '';
} catch { return ''; }
}
export function PopupDetailClientV2({ popupId, initialMode }: PopupDetailClientV2Props) {
const router = useRouter();
@@ -99,8 +102,10 @@ export function PopupDetailClientV2({ popupId, initialMode }: PopupDetailClientV
const handleSubmit = useCallback(
async (formData: Record<string, unknown>) => {
try {
const { targetType, departmentId } = decodeTargetValue((formData.target as string) || 'all');
const popupFormData: PopupFormData = {
target: (formData.target as PopupFormData['target']) || 'all',
target: targetType,
targetDepartmentId: departmentId ? String(departmentId) : undefined,
title: formData.title as string,
content: formData.content as string,
status: (formData.status as PopupFormData['status']) || 'inactive',
@@ -167,7 +172,7 @@ export function PopupDetailClientV2({ popupId, initialMode }: PopupDetailClientV
? ({
target: 'all',
status: 'inactive',
author: CURRENT_USER.name,
author: getLoggedInUserName(),
createdAt: format(new Date(), 'yyyy-MM-dd HH:mm'),
startDate: format(new Date(), 'yyyy-MM-dd'),
endDate: format(new Date(), 'yyyy-MM-dd'),

View File

@@ -51,11 +51,14 @@ interface PopupFormProps {
initialData?: Popup;
}
// 현재 로그인 사용자 정보 (실제로는 auth context에서 가져옴)
const CURRENT_USER = {
id: 'user1',
name: '홍길동',
};
// 로그인 사용자 이름을 가져오는 헬퍼
function getLoggedInUserName(): string {
if (typeof window === 'undefined') return '';
try {
const userDataStr = localStorage.getItem('user');
return userDataStr ? JSON.parse(userDataStr).name || '' : '';
} catch { return ''; }
}
export function PopupForm({ mode, initialData }: PopupFormProps) {
const router = useRouter();
@@ -268,7 +271,7 @@ export function PopupForm({ mode, initialData }: PopupFormProps) {
<div className="space-y-2">
<Label></Label>
<Input
value={initialData?.author || CURRENT_USER.name}
value={initialData?.author || getLoggedInUserName()}
disabled
className="bg-gray-50"
/>

View File

@@ -97,6 +97,19 @@ export async function deletePopup(id: string): Promise<ActionResult> {
});
}
/**
* 부서 목록 조회 (팝업 대상 선택용)
*/
export async function getDepartmentList(): Promise<{ id: number; name: string }[]> {
const result = await executeServerAction({
url: buildApiUrl('/api/v1/departments'),
transform: (data: { data: { id: number; name: string }[] }) =>
data.data.map((d) => ({ id: d.id, name: d.name })),
errorMessage: '부서 목록 조회에 실패했습니다.',
});
return result.data || [];
}
/**
* 팝업 일괄 삭제
*/

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)
)
)
)
);
}

View File

@@ -12,6 +12,7 @@ export type PopupStatus = 'active' | 'inactive';
export interface Popup {
id: string;
target: PopupTarget;
targetId?: number | null; // 부서 ID (대상이 department인 경우)
targetName?: string; // 부서명 (대상이 department인 경우)
title: string;
content: string;

View File

@@ -48,6 +48,7 @@ export function transformApiToFrontend(apiData: PopupApiData): Popup {
return {
id: String(apiData.id),
target: apiData.target_type as PopupTarget,
targetId: apiData.target_id,
targetName: apiData.target_type === 'department'
? apiData.department?.name
: undefined,