feat(WEB): 동적 게시판, 파트너 관리, 공지 팝업 모달 추가

- 동적 게시판 시스템 구현 (/boards/[boardCode])
- 파트너 관리 페이지 및 폼 추가
- 공지 팝업 모달 컴포넌트 (NoticePopupModal)
  - localStorage 기반 1일간 숨김 기능
  - 테스트 페이지 (/test/popup)
- IntegratedListTemplateV2 개선
- 기타 버그 수정 및 타입 개선

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
byeongcheolryu
2025-12-30 21:56:01 +09:00
parent 7b917fcbcd
commit f8dbc6b2ae
43 changed files with 6395 additions and 113 deletions

View File

@@ -16,6 +16,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Checkbox } from '@/components/ui/checkbox';
import { ClipboardList, ArrowLeft, Save } from 'lucide-react';
import type { Board, BoardFormData, BoardTarget, BoardStatus } from './types';
import { BOARD_TARGETS, BOARD_STATUS_LABELS } from './types';
@@ -29,6 +30,14 @@ const MOCK_DEPARTMENTS = [
{ id: 5, name: '마케팅팀' },
];
// TODO: API에서 권한 목록 가져오기
const MOCK_PERMISSIONS = [
{ code: 'admin', name: '관리자' },
{ code: 'manager', name: '매니저' },
{ code: 'staff', name: '직원' },
{ code: 'guest', name: '게스트' },
];
interface BoardFormProps {
mode: 'create' | 'edit';
board?: Board;
@@ -56,6 +65,7 @@ export function BoardForm({ mode, board, onSubmit }: BoardFormProps) {
const [formData, setFormData] = useState<BoardFormData>({
target: 'all',
targetName: '',
permissions: [],
boardName: '',
status: 'active',
});
@@ -66,6 +76,7 @@ export function BoardForm({ mode, board, onSubmit }: BoardFormProps) {
setFormData({
target: board.target,
targetName: board.targetName || '',
permissions: board.permissions || [],
boardName: board.boardName,
status: board.status,
});
@@ -89,7 +100,18 @@ export function BoardForm({ mode, board, onSubmit }: BoardFormProps) {
setFormData(prev => ({
...prev,
target: value,
targetName: value === 'all' ? '' : prev.targetName,
targetName: value === 'department' ? prev.targetName : '',
permissions: value === 'permission' ? prev.permissions : [],
}));
};
// 권한 체크박스 핸들러
const handlePermissionChange = (code: string, checked: boolean) => {
setFormData(prev => ({
...prev,
permissions: checked
? [...(prev.permissions || []), code]
: (prev.permissions || []).filter(p => p !== code),
}));
};
@@ -151,6 +173,25 @@ export function BoardForm({ mode, board, onSubmit }: BoardFormProps) {
</SelectContent>
</Select>
)}
{formData.target === 'permission' && (
<div className="flex-1 flex flex-wrap gap-4 items-center border rounded-md px-3 py-2">
{MOCK_PERMISSIONS.map((perm) => (
<div key={perm.code} className="flex items-center space-x-2">
<Checkbox
id={`perm-${perm.code}`}
checked={(formData.permissions || []).includes(perm.code)}
onCheckedChange={(checked) => handlePermissionChange(perm.code, checked as boolean)}
/>
<Label
htmlFor={`perm-${perm.code}`}
className="font-normal cursor-pointer text-sm"
>
{perm.name}
</Label>
</div>
))}
</div>
)}
</div>
</div>

View File

@@ -28,6 +28,9 @@ interface ApiResponse<T> {
function transformApiToFrontend(apiData: BoardApiData): Board {
const extraSettings = apiData.extra_settings || {};
// permissions 추출 (read 권한 기준으로 사용)
const permissions = extraSettings.permissions?.read || [];
return {
id: String(apiData.id),
boardCode: apiData.board_code,
@@ -35,6 +38,7 @@ function transformApiToFrontend(apiData: BoardApiData): Board {
target: extraSettings.target || 'all',
targetId: extraSettings.target_id,
targetName: extraSettings.target_name,
permissions: permissions.length > 0 ? permissions : undefined,
boardName: apiData.name,
description: apiData.description || undefined,
status: apiData.is_active ? 'active' : 'inactive',
@@ -50,14 +54,26 @@ function transformApiToFrontend(apiData: BoardApiData): Board {
* 프론트엔드 데이터 → API 요청 형식 변환
*/
function transformFrontendToApi(data: BoardFormData & { boardCode?: string; description?: string }, isUpdate = false): Record<string, unknown> {
// extra_settings 구성
const extraSettings: Record<string, unknown> = {
target: data.target,
target_name: data.target === 'department' ? data.targetName : null,
};
// 권한 대상인 경우 permissions 추가
if (data.target === 'permission' && data.permissions && data.permissions.length > 0) {
extraSettings.permissions = {
read: data.permissions,
write: data.permissions,
manage: data.permissions,
};
}
const result: Record<string, unknown> = {
name: data.boardName,
description: data.description || null,
is_active: data.status === 'active',
extra_settings: {
target: data.target,
target_name: data.target === 'department' ? data.targetName : null,
},
extra_settings: extraSettings,
};
// 생성 시에만 board_code 전송 (수정 시에는 코드 변경 불가)

View File

@@ -1,8 +1,8 @@
// 게시판 상태 타입
export type BoardStatus = 'active' | 'inactive';
// 대상 타입 (전사, 부서)
export type BoardTarget = 'all' | 'department';
// 대상 타입 (전사, 부서, 권한)
export type BoardTarget = 'all' | 'department' | 'permission';
// 게시판 타입 (프론트엔드용)
export interface Board {
@@ -12,6 +12,7 @@ export interface Board {
target: BoardTarget;
targetId?: number;
targetName?: string; // 부서명 (target이 department일 때)
permissions?: string[]; // 권한 코드 목록 (target이 permission일 때)
boardName: string;
description?: string;
status: BoardStatus;
@@ -69,6 +70,7 @@ export interface BoardApiData {
export interface BoardFormData {
target: BoardTarget;
targetName?: string;
permissions?: string[]; // 권한 코드 목록 (target이 permission일 때)
boardName: string;
status: BoardStatus;
}
@@ -89,10 +91,12 @@ export const BOARD_STATUS_COLORS: Record<BoardStatus, string> = {
export const BOARD_TARGET_LABELS: Record<BoardTarget, string> = {
all: '전사',
department: '부서',
permission: '권한',
};
// 대상 옵션
export const BOARD_TARGETS = [
{ value: 'all' as BoardTarget, label: '전사' },
{ value: 'department' as BoardTarget, label: '부서' },
{ value: 'permission' as BoardTarget, label: '권한' },
];

View File

@@ -0,0 +1,371 @@
/**
* 동적 게시판 Server Actions
* 일반 게시판 게시글 API 호출 (/api/v1/boards/{code}/posts)
*/
'use server';
import { serverFetch } from '@/lib/api/fetch-wrapper';
import type {
PostApiData,
PostPaginationResponse,
ApiResponse,
PostFilters,
CommentApiData,
CommentsApiResponse,
} from '@/components/customer-center/shared/types';
/**
* 게시글 목록 조회
*/
export async function getDynamicBoardPosts(
boardCode: string,
filters?: PostFilters
): Promise<{ success: boolean; data?: PostPaginationResponse; error?: string; __authError?: boolean }> {
try {
const params = new URLSearchParams();
if (filters?.search) params.append('search', filters.search);
if (filters?.is_notice !== undefined) params.append('is_notice', String(filters.is_notice));
if (filters?.status) params.append('status', filters.status);
if (filters?.per_page) params.append('per_page', String(filters.per_page));
if (filters?.page) params.append('page', String(filters.page));
const queryString = params.toString();
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/boards/${boardCode}/posts${queryString ? `?${queryString}` : ''}`;
const { response, error } = await serverFetch(url, {
method: 'GET',
cache: 'no-store',
});
if (error) {
return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' };
}
if (!response) {
return { success: false, error: '게시글 목록 조회에 실패했습니다.' };
}
let result: ApiResponse<PostPaginationResponse>;
try {
result = await response.json();
} catch {
console.error('[DynamicBoardActions] JSON parse error');
return { success: false, error: '서버 응답 형식 오류입니다.' };
}
if (!response.ok || !result.success) {
return { success: false, error: result.message || '게시글 목록 조회에 실패했습니다.' };
}
return { success: true, data: result.data };
} catch (error) {
console.error('[DynamicBoardActions] getDynamicBoardPosts error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}
/**
* 게시글 상세 조회
*/
export async function getDynamicBoardPost(
boardCode: string,
postId: number | string
): Promise<{ success: boolean; data?: PostApiData; error?: string; __authError?: boolean }> {
try {
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/boards/${boardCode}/posts/${postId}`;
const { response, error } = await serverFetch(url, {
method: 'GET',
cache: 'no-store',
});
if (error) {
return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' };
}
if (!response) {
return { success: false, error: '게시글 조회에 실패했습니다.' };
}
const result: ApiResponse<PostApiData> = await response.json();
if (!response.ok || !result.success || !result.data) {
return { success: false, error: result.message || '게시글을 찾을 수 없습니다.' };
}
return { success: true, data: result.data };
} catch (error) {
console.error('[DynamicBoardActions] getDynamicBoardPost error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}
/**
* 게시글 등록
*/
export async function createDynamicBoardPost(
boardCode: string,
data: {
title: string;
content: string;
is_secret?: boolean;
is_notice?: boolean;
custom_fields?: Record<string, string>;
}
): Promise<{ success: boolean; data?: PostApiData; error?: string; __authError?: boolean }> {
try {
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/boards/${boardCode}/posts`;
const { response, error } = await serverFetch(url, {
method: 'POST',
body: JSON.stringify(data),
});
if (error) {
return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' };
}
if (!response) {
return { success: false, error: '게시글 등록에 실패했습니다.' };
}
const result = await response.json();
if (!response.ok || !result.success) {
return { success: false, error: result.message || '게시글 등록에 실패했습니다.' };
}
return { success: true, data: result.data };
} catch (error) {
console.error('[DynamicBoardActions] createDynamicBoardPost error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}
/**
* 게시글 수정
*/
export async function updateDynamicBoardPost(
boardCode: string,
postId: number | string,
data: {
title?: string;
content?: string;
is_secret?: boolean;
is_notice?: boolean;
custom_fields?: Record<string, string>;
}
): Promise<{ success: boolean; data?: PostApiData; error?: string; __authError?: boolean }> {
try {
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/boards/${boardCode}/posts/${postId}`;
const { response, error } = await serverFetch(url, {
method: 'PUT',
body: JSON.stringify(data),
});
if (error) {
return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' };
}
if (!response) {
return { success: false, error: '게시글 수정에 실패했습니다.' };
}
const result = await response.json();
if (!response.ok || !result.success) {
return { success: false, error: result.message || '게시글 수정에 실패했습니다.' };
}
return { success: true, data: result.data };
} catch (error) {
console.error('[DynamicBoardActions] updateDynamicBoardPost error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}
/**
* 게시글 삭제
*/
export async function deleteDynamicBoardPost(
boardCode: string,
postId: number | string
): Promise<{ success: boolean; error?: string; __authError?: boolean }> {
try {
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/boards/${boardCode}/posts/${postId}`;
const { response, error } = await serverFetch(url, {
method: 'DELETE',
});
if (error) {
return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' };
}
if (!response) {
return { success: false, error: '게시글 삭제에 실패했습니다.' };
}
const result = await response.json();
if (!response.ok || !result.success) {
return { success: false, error: result.message || '게시글 삭제에 실패했습니다.' };
}
return { success: true };
} catch (error) {
console.error('[DynamicBoardActions] deleteDynamicBoardPost error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}
// ===== 댓글 API =====
/**
* 댓글 목록 조회
*/
export async function getDynamicBoardComments(
boardCode: string,
postId: number | string
): Promise<{ success: boolean; data?: CommentsApiResponse; error?: string; __authError?: boolean }> {
try {
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/boards/${boardCode}/posts/${postId}/comments`;
const { response, error } = await serverFetch(url, {
method: 'GET',
cache: 'no-store',
});
if (error) {
return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' };
}
if (!response) {
return { success: false, error: '댓글 목록 조회에 실패했습니다.' };
}
const result: ApiResponse<CommentsApiResponse> = await response.json();
if (!response.ok || !result.success) {
return { success: false, error: result.message || '댓글 목록 조회에 실패했습니다.' };
}
return { success: true, data: result.data };
} catch (error) {
console.error('[DynamicBoardActions] getDynamicBoardComments error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}
/**
* 댓글 작성
*/
export async function createDynamicBoardComment(
boardCode: string,
postId: number | string,
content: string
): Promise<{ success: boolean; data?: CommentApiData; error?: string; __authError?: boolean }> {
try {
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/boards/${boardCode}/posts/${postId}/comments`;
const { response, error } = await serverFetch(url, {
method: 'POST',
body: JSON.stringify({ content }),
});
if (error) {
return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' };
}
if (!response) {
return { success: false, error: '댓글 등록에 실패했습니다.' };
}
const result = await response.json();
if (!response.ok || !result.success) {
return { success: false, error: result.message || '댓글 등록에 실패했습니다.' };
}
return { success: true, data: result.data };
} catch (error) {
console.error('[DynamicBoardActions] createDynamicBoardComment error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}
/**
* 댓글 수정
*/
export async function updateDynamicBoardComment(
boardCode: string,
postId: number | string,
commentId: number | string,
content: string
): Promise<{ success: boolean; data?: CommentApiData; error?: string; __authError?: boolean }> {
try {
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/boards/${boardCode}/posts/${postId}/comments/${commentId}`;
const { response, error } = await serverFetch(url, {
method: 'PUT',
body: JSON.stringify({ content }),
});
if (error) {
return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' };
}
if (!response) {
return { success: false, error: '댓글 수정에 실패했습니다.' };
}
const result = await response.json();
if (!response.ok || !result.success) {
return { success: false, error: result.message || '댓글 수정에 실패했습니다.' };
}
return { success: true, data: result.data };
} catch (error) {
console.error('[DynamicBoardActions] updateDynamicBoardComment error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}
/**
* 댓글 삭제
*/
export async function deleteDynamicBoardComment(
boardCode: string,
postId: number | string,
commentId: number | string
): Promise<{ success: boolean; error?: string; __authError?: boolean }> {
try {
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/boards/${boardCode}/posts/${postId}/comments/${commentId}`;
const { response, error } = await serverFetch(url, {
method: 'DELETE',
});
if (error) {
return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' };
}
if (!response) {
return { success: false, error: '댓글 삭제에 실패했습니다.' };
}
const result = await response.json();
if (!response.ok || !result.success) {
return { success: false, error: result.message || '댓글 삭제에 실패했습니다.' };
}
return { success: true };
} catch (error) {
console.error('[DynamicBoardActions] deleteDynamicBoardComment error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}