Merge remote-tracking branch 'origin/master'
This commit is contained in:
165
src/components/board/DynamicBoard/DynamicBoardCreateForm.tsx
Normal file
165
src/components/board/DynamicBoard/DynamicBoardCreateForm.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 동적 게시판 등록 폼 컴포넌트
|
||||
* - mode=new 또는 /create 페이지에서 사용
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { ArrowLeft, Save, MessageSquare } from 'lucide-react';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { createDynamicBoardPost } from '@/components/board/DynamicBoard/actions';
|
||||
import { getBoardByCode } from '@/components/board/BoardManagement/actions';
|
||||
|
||||
interface DynamicBoardCreateFormProps {
|
||||
boardCode: string;
|
||||
}
|
||||
|
||||
export function DynamicBoardCreateForm({ boardCode }: DynamicBoardCreateFormProps) {
|
||||
const router = useRouter();
|
||||
|
||||
// 게시판 정보
|
||||
const [boardName, setBoardName] = useState<string>('게시판');
|
||||
|
||||
// 폼 상태
|
||||
const [title, setTitle] = useState('');
|
||||
const [content, setContent] = useState('');
|
||||
const [isSecret, setIsSecret] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 게시판 정보 로드
|
||||
useEffect(() => {
|
||||
async function fetchBoardInfo() {
|
||||
const result = await getBoardByCode(boardCode);
|
||||
if (result.success && result.data) {
|
||||
setBoardName(result.data.boardName);
|
||||
}
|
||||
}
|
||||
fetchBoardInfo();
|
||||
}, [boardCode]);
|
||||
|
||||
// 폼 제출
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!title.trim()) {
|
||||
setError('제목을 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!content.trim()) {
|
||||
setError('내용을 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
const result = await createDynamicBoardPost(boardCode, {
|
||||
title: title.trim(),
|
||||
content: content.trim(),
|
||||
is_secret: isSecret,
|
||||
});
|
||||
|
||||
if (result.success && result.data) {
|
||||
router.push(`/ko/boards/${boardCode}/${result.data.id}`);
|
||||
} else {
|
||||
setError(result.error || '게시글 등록에 실패했습니다.');
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 취소
|
||||
const handleCancel = () => {
|
||||
router.push(`/ko/boards/${boardCode}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<PageHeader
|
||||
title={boardName}
|
||||
description="새 게시글 등록"
|
||||
icon={MessageSquare}
|
||||
/>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">게시글 작성</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{error && (
|
||||
<div className="p-3 bg-destructive/10 border border-destructive/20 rounded-md">
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 제목 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">제목 *</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="제목을 입력하세요"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 내용 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="content">내용 *</Label>
|
||||
<Textarea
|
||||
id="content"
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder="내용을 입력하세요"
|
||||
rows={15}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 비밀글 설정 */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="isSecret"
|
||||
checked={isSecret}
|
||||
onCheckedChange={(checked) => setIsSecret(checked as boolean)}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<Label htmlFor="isSecret" className="font-normal cursor-pointer">
|
||||
비밀글로 등록
|
||||
</Label>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 버튼 영역 */}
|
||||
<div className="mt-6 flex items-center justify-between">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleCancel}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
취소
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
{isSubmitting ? '등록 중...' : '등록'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
252
src/components/board/DynamicBoard/DynamicBoardEditForm.tsx
Normal file
252
src/components/board/DynamicBoard/DynamicBoardEditForm.tsx
Normal file
@@ -0,0 +1,252 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 동적 게시판 수정 폼 컴포넌트
|
||||
* - mode=edit 또는 /edit 페이지에서 사용
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { ArrowLeft, Save, MessageSquare } from 'lucide-react';
|
||||
import { DetailPageSkeleton } from '@/components/ui/skeleton';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { getDynamicBoardPost, updateDynamicBoardPost } from '@/components/board/DynamicBoard/actions';
|
||||
import { getBoardByCode } from '@/components/board/BoardManagement/actions';
|
||||
import type { PostApiData } from '@/components/customer-center/shared/types';
|
||||
|
||||
interface BoardPost {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
authorId: string;
|
||||
authorName: string;
|
||||
status: string;
|
||||
views: number;
|
||||
isNotice: boolean;
|
||||
isSecret: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// API 데이터 → 프론트엔드 타입 변환
|
||||
function transformApiToPost(apiData: PostApiData): BoardPost {
|
||||
return {
|
||||
id: String(apiData.id),
|
||||
title: apiData.title,
|
||||
content: apiData.content,
|
||||
authorId: String(apiData.user_id),
|
||||
authorName: apiData.author?.name || '회원',
|
||||
status: apiData.status,
|
||||
views: apiData.views,
|
||||
isNotice: apiData.is_notice,
|
||||
isSecret: apiData.is_secret,
|
||||
createdAt: apiData.created_at,
|
||||
updatedAt: apiData.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
interface DynamicBoardEditFormProps {
|
||||
boardCode: string;
|
||||
postId: string;
|
||||
}
|
||||
|
||||
export function DynamicBoardEditForm({ boardCode, postId }: DynamicBoardEditFormProps) {
|
||||
const router = useRouter();
|
||||
|
||||
// 게시판 정보
|
||||
const [boardName, setBoardName] = useState<string>('게시판');
|
||||
|
||||
// 원본 게시글
|
||||
const [post, setPost] = useState<BoardPost | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
|
||||
// 폼 상태
|
||||
const [title, setTitle] = useState('');
|
||||
const [content, setContent] = useState('');
|
||||
const [isSecret, setIsSecret] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 게시판 정보 로드
|
||||
useEffect(() => {
|
||||
async function fetchBoardInfo() {
|
||||
const result = await getBoardByCode(boardCode);
|
||||
if (result.success && result.data) {
|
||||
setBoardName(result.data.boardName);
|
||||
}
|
||||
}
|
||||
fetchBoardInfo();
|
||||
}, [boardCode]);
|
||||
|
||||
// 게시글 로드
|
||||
useEffect(() => {
|
||||
async function fetchPost() {
|
||||
setIsLoading(true);
|
||||
setLoadError(null);
|
||||
|
||||
const result = await getDynamicBoardPost(boardCode, postId);
|
||||
|
||||
if (result.success && result.data) {
|
||||
const postData = transformApiToPost(result.data);
|
||||
setPost(postData);
|
||||
setTitle(postData.title);
|
||||
setContent(postData.content);
|
||||
setIsSecret(postData.isSecret);
|
||||
} else {
|
||||
setLoadError(result.error || '게시글을 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
fetchPost();
|
||||
}, [boardCode, postId]);
|
||||
|
||||
// 폼 제출
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!title.trim()) {
|
||||
setError('제목을 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!content.trim()) {
|
||||
setError('내용을 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
const result = await updateDynamicBoardPost(boardCode, postId, {
|
||||
title: title.trim(),
|
||||
content: content.trim(),
|
||||
is_secret: isSecret,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
router.push(`/ko/boards/${boardCode}/${postId}`);
|
||||
} else {
|
||||
setError(result.error || '게시글 수정에 실패했습니다.');
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 취소
|
||||
const handleCancel = () => {
|
||||
router.push(`/ko/boards/${boardCode}/${postId}`);
|
||||
};
|
||||
|
||||
// 로딩 상태
|
||||
if (isLoading) {
|
||||
return (
|
||||
<PageLayout>
|
||||
<DetailPageSkeleton />
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
// 로드 에러
|
||||
if (loadError || !post) {
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] gap-4">
|
||||
<p className="text-muted-foreground">{loadError || '게시글을 찾을 수 없습니다.'}</p>
|
||||
<Button variant="outline" onClick={() => router.push(`/ko/boards/${boardCode}`)}>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
목록으로
|
||||
</Button>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<PageHeader
|
||||
title={boardName}
|
||||
description="게시글 수정"
|
||||
icon={MessageSquare}
|
||||
/>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">게시글 수정</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{error && (
|
||||
<div className="p-3 bg-destructive/10 border border-destructive/20 rounded-md">
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 제목 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">제목 *</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="제목을 입력하세요"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 내용 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="content">내용 *</Label>
|
||||
<Textarea
|
||||
id="content"
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder="내용을 입력하세요"
|
||||
rows={15}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 비밀글 설정 */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="isSecret"
|
||||
checked={isSecret}
|
||||
onCheckedChange={(checked) => setIsSecret(checked as boolean)}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<Label htmlFor="isSecret" className="font-normal cursor-pointer">
|
||||
비밀글로 등록
|
||||
</Label>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 버튼 영역 */}
|
||||
<div className="mt-6 flex items-center justify-between">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleCancel}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
취소
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
{isSubmitting ? '저장 중...' : '저장'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ interface ApiAttendanceSetting {
|
||||
id: number;
|
||||
tenant_id: number;
|
||||
use_gps: boolean;
|
||||
use_auto: boolean;
|
||||
allowed_radius: number;
|
||||
hq_address: string | null;
|
||||
hq_latitude: number | null;
|
||||
@@ -21,9 +22,10 @@ interface ApiAttendanceSetting {
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
// React 폼 데이터 타입 (API 지원 필드만)
|
||||
// React 폼 데이터 타입
|
||||
export interface AttendanceSettingFormData {
|
||||
useGps: boolean;
|
||||
useAuto: boolean;
|
||||
allowedRadius: number;
|
||||
hqAddress: string | null;
|
||||
hqLatitude: number | null;
|
||||
@@ -60,6 +62,7 @@ interface ApiResponse<T> {
|
||||
function transformFromApi(data: ApiAttendanceSetting): AttendanceSettingFormData {
|
||||
return {
|
||||
useGps: data.use_gps,
|
||||
useAuto: data.use_auto,
|
||||
allowedRadius: data.allowed_radius,
|
||||
hqAddress: data.hq_address,
|
||||
hqLatitude: data.hq_latitude,
|
||||
@@ -74,6 +77,7 @@ function transformToApi(data: Partial<AttendanceSettingFormData>): Record<string
|
||||
const apiData: Record<string, unknown> = {};
|
||||
|
||||
if (data.useGps !== undefined) apiData.use_gps = data.useGps;
|
||||
if (data.useAuto !== undefined) apiData.use_auto = data.useAuto;
|
||||
if (data.allowedRadius !== undefined) apiData.allowed_radius = data.allowedRadius;
|
||||
if (data.hqAddress !== undefined) apiData.hq_address = data.hqAddress;
|
||||
if (data.hqLatitude !== undefined) apiData.hq_latitude = data.hqLatitude;
|
||||
|
||||
@@ -69,6 +69,7 @@ export function AttendanceSettingsManagement() {
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
gpsEnabled: settingResult.data!.useGps,
|
||||
autoEnabled: settingResult.data!.useAuto,
|
||||
allowedRadius: settingResult.data!.allowedRadius as AllowedRadius,
|
||||
}));
|
||||
} else if (settingResult.error) {
|
||||
@@ -103,7 +104,7 @@ export function AttendanceSettingsManagement() {
|
||||
}));
|
||||
};
|
||||
|
||||
// 자동 출퇴근 사용 토글 (UI 전용 - API 미지원)
|
||||
// 자동 출퇴근 사용 토글
|
||||
const handleAutoToggle = (checked: boolean) => {
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
@@ -132,9 +133,9 @@ export function AttendanceSettingsManagement() {
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
// API 지원 필드만 전송
|
||||
const result = await updateAttendanceSetting({
|
||||
useGps: settings.gpsEnabled,
|
||||
useAuto: settings.autoEnabled,
|
||||
allowedRadius: settings.allowedRadius,
|
||||
});
|
||||
|
||||
|
||||
@@ -250,6 +250,13 @@ export function UniversalListPage<T>({
|
||||
}
|
||||
}, [config.tabs]);
|
||||
|
||||
// 선택 항목 변경 시 외부 콜백 호출
|
||||
useEffect(() => {
|
||||
if (config.onSelectionChange && !externalSelection) {
|
||||
config.onSelectionChange(selectedItems);
|
||||
}
|
||||
}, [selectedItems, config.onSelectionChange, externalSelection]);
|
||||
|
||||
// 데이터 변경 콜백 (동적 컬럼 계산 등에 사용)
|
||||
// ⚠️ config.onDataChange를 deps에서 제외: 콜백 참조 변경으로 인한 무한 루프 방지
|
||||
useEffect(() => {
|
||||
|
||||
@@ -331,6 +331,8 @@ export interface UniversalListConfig<T> {
|
||||
tabsContent?: ReactNode;
|
||||
/** 추가 필터 (Select, DatePicker 등) */
|
||||
extraFilters?: ReactNode;
|
||||
/** 선택 항목 변경 콜백 (외부에서 선택 상태 동기화 필요 시) */
|
||||
onSelectionChange?: (selectedItems: Set<string>) => void;
|
||||
|
||||
// ===== 커스텀 다이얼로그 슬롯 =====
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user