공지사항, csv 표출 작업1
This commit is contained in:
@@ -37,6 +37,9 @@ export default function LessonEditPage() {
|
||||
const [questionFileObject, setQuestionFileObject] = useState<File | null>(null);
|
||||
const [questionFileKey, setQuestionFileKey] = useState<string | null>(null);
|
||||
const [existingQuestionFile, setExistingQuestionFile] = useState<{fileName: string, fileKey?: string} | null>(null);
|
||||
const [csvData, setCsvData] = useState<string[][]>([]);
|
||||
const [csvHeaders, setCsvHeaders] = useState<string[]>([]);
|
||||
const [csvRows, setCsvRows] = useState<string[][]>([]);
|
||||
|
||||
// 원본 데이터 저장 (변경사항 비교용)
|
||||
const [originalData, setOriginalData] = useState<{
|
||||
@@ -177,7 +180,7 @@ export default function LessonEditPage() {
|
||||
// 기존 평가 문제 파일
|
||||
if (data.csvKey || data.csvUrl) {
|
||||
setExistingQuestionFile({
|
||||
fileName: '평가문제.csv',
|
||||
fileName: data.csvFileName || data.csv_file_name || data.csvName || '평가문제.csv',
|
||||
fileKey: data.csvKey,
|
||||
});
|
||||
setQuestionFileCount(1);
|
||||
@@ -935,8 +938,92 @@ export default function LessonEditPage() {
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
const file = files[0];
|
||||
if (file.name.toLowerCase().endsWith('.csv')) {
|
||||
|
||||
// CSV 파일만 허용
|
||||
if (!file.name.toLowerCase().endsWith('.csv')) {
|
||||
alert('CSV 파일 형식만 첨부할 수 있습니다.');
|
||||
e.target.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// CSV 파일 파싱
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
const text = event.target?.result as string;
|
||||
if (!text) return;
|
||||
|
||||
try {
|
||||
// CSV 파싱 함수
|
||||
const parseCsv = (csvText: string): string[][] => {
|
||||
const lines: string[][] = [];
|
||||
let currentLine: string[] = [];
|
||||
let currentField = '';
|
||||
let inQuotes = false;
|
||||
|
||||
for (let i = 0; i < csvText.length; i++) {
|
||||
const char = csvText[i];
|
||||
const nextChar = csvText[i + 1];
|
||||
|
||||
if (char === '"') {
|
||||
if (inQuotes && nextChar === '"') {
|
||||
currentField += '"';
|
||||
i++;
|
||||
} else {
|
||||
inQuotes = !inQuotes;
|
||||
}
|
||||
} else if (char === ',' && !inQuotes) {
|
||||
currentLine.push(currentField.trim());
|
||||
currentField = '';
|
||||
} else if ((char === '\n' || char === '\r') && !inQuotes) {
|
||||
if (char === '\r' && nextChar === '\n') {
|
||||
i++;
|
||||
}
|
||||
if (currentField || currentLine.length > 0) {
|
||||
currentLine.push(currentField.trim());
|
||||
lines.push(currentLine);
|
||||
currentLine = [];
|
||||
currentField = '';
|
||||
}
|
||||
} else {
|
||||
currentField += char;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentField || currentLine.length > 0) {
|
||||
currentLine.push(currentField.trim());
|
||||
lines.push(currentLine);
|
||||
}
|
||||
|
||||
return lines;
|
||||
};
|
||||
|
||||
const parsed = parseCsv(text);
|
||||
|
||||
if (parsed.length === 0) {
|
||||
alert('CSV 파일이 비어있습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 첫 번째 줄을 헤더로 사용
|
||||
const headers = parsed[0];
|
||||
const rows = parsed.slice(1);
|
||||
|
||||
setCsvHeaders(headers);
|
||||
setCsvRows(rows);
|
||||
setCsvData(parsed);
|
||||
} catch (parseError) {
|
||||
console.error('CSV 파싱 오류:', parseError);
|
||||
alert('CSV 파일을 읽는 중 오류가 발생했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
reader.onerror = () => {
|
||||
alert('파일을 읽는 중 오류가 발생했습니다.');
|
||||
};
|
||||
|
||||
reader.readAsText(file, 'UTF-8');
|
||||
|
||||
// 단일 파일 업로드
|
||||
const uploadResponse = await apiService.uploadFile(file);
|
||||
|
||||
@@ -946,6 +1033,14 @@ export default function LessonEditPage() {
|
||||
fileKey = uploadResponse.data.fileKey;
|
||||
} else if (uploadResponse.data?.key) {
|
||||
fileKey = uploadResponse.data.key;
|
||||
} else if (uploadResponse.data?.id) {
|
||||
fileKey = uploadResponse.data.id;
|
||||
} else if (uploadResponse.data?.imageKey) {
|
||||
fileKey = uploadResponse.data.imageKey;
|
||||
} else if (uploadResponse.data?.fileId) {
|
||||
fileKey = uploadResponse.data.fileId;
|
||||
} else if (uploadResponse.data?.data && (uploadResponse.data.data.key || uploadResponse.data.data.fileKey)) {
|
||||
fileKey = uploadResponse.data.data.key || uploadResponse.data.data.fileKey;
|
||||
} else if (uploadResponse.data?.results && Array.isArray(uploadResponse.data.results) && uploadResponse.data.results.length > 0) {
|
||||
const result = uploadResponse.data.results[0];
|
||||
if (result.ok && result.fileKey) {
|
||||
@@ -965,15 +1060,25 @@ export default function LessonEditPage() {
|
||||
console.error('학습 평가 문제 업로드 실패:', error);
|
||||
alert('파일 업로드에 실패했습니다. 다시 시도해주세요.');
|
||||
}
|
||||
}
|
||||
// input 초기화 (같은 파일 다시 선택 가능하도록)
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border border-[#dee1e6] rounded-[8px] bg-gray-50">
|
||||
{existingQuestionFile || questionFileObject ? (
|
||||
<div className="h-[64px] px-[20px] flex items-center gap-[12px]">
|
||||
{csvData.length === 0 ? (
|
||||
<div className="h-[64px] flex items-center justify-center">
|
||||
<p className="text-[14px] font-normal leading-[1.5] text-[#8c95a1] text-center">
|
||||
학습 평가용 문항 파일을 첨부해주세요.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col">
|
||||
{/* 파일 정보 및 삭제 버튼 */}
|
||||
{(existingQuestionFile || questionFileObject) && (
|
||||
<div className="h-[40px] px-[20px] py-[12px] flex items-center gap-[12px] bg-white border-b border-[#dee1e6]">
|
||||
<p className="flex-1 text-[15px] font-normal leading-[1.5] text-[#333c47]">
|
||||
{existingQuestionFile ? `${existingQuestionFile.fileName} (기존)` : questionFileObject?.name}
|
||||
</p>
|
||||
@@ -984,6 +1089,9 @@ export default function LessonEditPage() {
|
||||
setQuestionFileKey(null);
|
||||
setExistingQuestionFile(null);
|
||||
setQuestionFileCount(0);
|
||||
setCsvData([]);
|
||||
setCsvHeaders([]);
|
||||
setCsvRows([]);
|
||||
}}
|
||||
className="size-[16px] flex items-center justify-center cursor-pointer hover:opacity-70 transition-opacity shrink-0"
|
||||
aria-label="파일 삭제"
|
||||
@@ -991,12 +1099,62 @@ export default function LessonEditPage() {
|
||||
<CloseXOSvg />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-[64px] flex items-center justify-center">
|
||||
<p className="text-[14px] font-normal leading-[1.5] text-[#8c95a1] text-center">
|
||||
학습 평가용 문항 파일을 첨부해주세요.
|
||||
)}
|
||||
{/* CSV 표 */}
|
||||
<div className="m-[24px] border border-[#dee1e6] border-solid relative bg-white max-h-[400px] overflow-y-auto">
|
||||
<div className="content-stretch flex flex-col items-start justify-center relative size-full">
|
||||
{/* 헤더 */}
|
||||
<div className="bg-[#f1f8ff] content-stretch flex h-[48px] items-center overflow-clip relative shrink-0 w-full sticky top-0 z-10">
|
||||
{csvHeaders.map((header, index) => {
|
||||
const isLast = index === csvHeaders.length - 1;
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={`border-[#dee1e6] ${
|
||||
isLast ? '' : 'border-[0px_1px_0px_0px]'
|
||||
} border-solid box-border content-stretch flex gap-[10px] h-full items-center justify-center px-[8px] py-[12px] relative shrink-0 ${
|
||||
index === 0 ? 'w-[48px]' : index === 1 ? 'basis-0 grow min-h-px min-w-px' : 'w-[140px]'
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col font-['Pretendard:SemiBold',sans-serif] justify-center leading-[0] not-italic relative shrink-0 text-[#4c5561] text-[14px] text-nowrap">
|
||||
<p className="leading-[1.5] whitespace-pre">{header || `열 ${index + 1}`}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 데이터 행 */}
|
||||
{csvRows.map((row, rowIndex) => (
|
||||
<div
|
||||
key={rowIndex}
|
||||
className="border-[#dee1e6] border-[1px_0px_0px] border-solid h-[48px] relative shrink-0 w-full"
|
||||
>
|
||||
<div className="content-stretch flex h-[48px] items-start overflow-clip relative rounded-[inherit] w-full">
|
||||
{csvHeaders.map((_, colIndex) => {
|
||||
const isLast = colIndex === csvHeaders.length - 1;
|
||||
const cellValue = row[colIndex] || '';
|
||||
return (
|
||||
<div
|
||||
key={colIndex}
|
||||
className={`border-[#dee1e6] ${
|
||||
isLast ? '' : 'border-[0px_1px_0px_0px]'
|
||||
} border-solid box-border content-stretch flex flex-col gap-[4px] items-center justify-center px-[8px] py-[12px] relative shrink-0 ${
|
||||
colIndex === 0 ? 'w-[48px]' : colIndex === 1 ? 'basis-0 grow min-h-px min-w-px' : 'w-[140px]'
|
||||
}`}
|
||||
>
|
||||
<p className="font-['Pretendard:Medium',sans-serif] leading-[1.5] not-italic relative shrink-0 text-[#1b2027] text-[15px] text-nowrap whitespace-pre">
|
||||
{cellValue}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -47,6 +47,9 @@ export default function AdminLessonsPage() {
|
||||
const [questionFileCount, setQuestionFileCount] = useState(0);
|
||||
const [questionFileObject, setQuestionFileObject] = useState<File | null>(null);
|
||||
const [questionFileKey, setQuestionFileKey] = useState<string | null>(null);
|
||||
const [csvData, setCsvData] = useState<string[][]>([]);
|
||||
const [csvHeaders, setCsvHeaders] = useState<string[]>([]);
|
||||
const [csvRows, setCsvRows] = useState<string[][]>([]);
|
||||
|
||||
// 에러 상태
|
||||
const [errors, setErrors] = useState<{
|
||||
@@ -232,6 +235,9 @@ export default function AdminLessonsPage() {
|
||||
setQuestionFileCount(0);
|
||||
setQuestionFileObject(null);
|
||||
setQuestionFileKey(null);
|
||||
setCsvData([]);
|
||||
setCsvHeaders([]);
|
||||
setCsvRows([]);
|
||||
setErrors({});
|
||||
};
|
||||
|
||||
@@ -372,6 +378,9 @@ export default function AdminLessonsPage() {
|
||||
setQuestionFileCount(0);
|
||||
setQuestionFileObject(null);
|
||||
setQuestionFileKey(null);
|
||||
setCsvData([]);
|
||||
setCsvHeaders([]);
|
||||
setCsvRows([]);
|
||||
|
||||
// 토스트 팝업 표시
|
||||
setShowToast(true);
|
||||
@@ -881,6 +890,83 @@ export default function AdminLessonsPage() {
|
||||
}
|
||||
|
||||
try {
|
||||
// CSV 파일 파싱
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
const text = event.target?.result as string;
|
||||
if (!text) return;
|
||||
|
||||
try {
|
||||
// CSV 파싱 함수
|
||||
const parseCsv = (csvText: string): string[][] => {
|
||||
const lines: string[][] = [];
|
||||
let currentLine: string[] = [];
|
||||
let currentField = '';
|
||||
let inQuotes = false;
|
||||
|
||||
for (let i = 0; i < csvText.length; i++) {
|
||||
const char = csvText[i];
|
||||
const nextChar = csvText[i + 1];
|
||||
|
||||
if (char === '"') {
|
||||
if (inQuotes && nextChar === '"') {
|
||||
currentField += '"';
|
||||
i++;
|
||||
} else {
|
||||
inQuotes = !inQuotes;
|
||||
}
|
||||
} else if (char === ',' && !inQuotes) {
|
||||
currentLine.push(currentField.trim());
|
||||
currentField = '';
|
||||
} else if ((char === '\n' || char === '\r') && !inQuotes) {
|
||||
if (char === '\r' && nextChar === '\n') {
|
||||
i++;
|
||||
}
|
||||
if (currentField || currentLine.length > 0) {
|
||||
currentLine.push(currentField.trim());
|
||||
lines.push(currentLine);
|
||||
currentLine = [];
|
||||
currentField = '';
|
||||
}
|
||||
} else {
|
||||
currentField += char;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentField || currentLine.length > 0) {
|
||||
currentLine.push(currentField.trim());
|
||||
lines.push(currentLine);
|
||||
}
|
||||
|
||||
return lines;
|
||||
};
|
||||
|
||||
const parsed = parseCsv(text);
|
||||
|
||||
if (parsed.length === 0) {
|
||||
alert('CSV 파일이 비어있습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 첫 번째 줄을 헤더로 사용
|
||||
const headers = parsed[0];
|
||||
const rows = parsed.slice(1);
|
||||
|
||||
setCsvHeaders(headers);
|
||||
setCsvRows(rows);
|
||||
setCsvData(parsed);
|
||||
} catch (parseError) {
|
||||
console.error('CSV 파싱 오류:', parseError);
|
||||
alert('CSV 파일을 읽는 중 오류가 발생했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
reader.onerror = () => {
|
||||
alert('파일을 읽는 중 오류가 발생했습니다.');
|
||||
};
|
||||
|
||||
reader.readAsText(file, 'UTF-8');
|
||||
|
||||
// 단일 파일 업로드
|
||||
const uploadResponse = await apiService.uploadFile(file);
|
||||
|
||||
@@ -923,11 +1009,95 @@ export default function AdminLessonsPage() {
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-[64px] border border-[#dee1e6] rounded-[8px] bg-gray-50 flex items-center justify-center">
|
||||
<div className="border border-[#dee1e6] rounded-[8px] bg-gray-50">
|
||||
{csvData.length === 0 ? (
|
||||
<div className="h-[64px] flex items-center justify-center">
|
||||
<p className="text-[14px] font-normal leading-[1.5] text-[#8c95a1] text-center">
|
||||
학습 평가용 문항 파일을 첨부해주세요.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col">
|
||||
{/* 파일 정보 및 삭제 버튼 */}
|
||||
{questionFileObject && (
|
||||
<div className="h-[40px] px-[20px] py-[12px] flex items-center gap-[12px] bg-white border-b border-[#dee1e6]">
|
||||
<p className="flex-1 text-[15px] font-normal leading-[1.5] text-[#333c47]">
|
||||
{questionFileObject.name}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setQuestionFileObject(null);
|
||||
setQuestionFileKey(null);
|
||||
setQuestionFileCount(0);
|
||||
setCsvData([]);
|
||||
setCsvHeaders([]);
|
||||
setCsvRows([]);
|
||||
}}
|
||||
className="size-[16px] flex items-center justify-center cursor-pointer hover:opacity-70 transition-opacity shrink-0"
|
||||
aria-label="파일 삭제"
|
||||
>
|
||||
<CloseXOSvg />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{/* CSV 표 */}
|
||||
<div className="m-[24px] border border-[#dee1e6] border-solid relative bg-white max-h-[400px] overflow-y-auto">
|
||||
<div className="content-stretch flex flex-col items-start justify-center relative size-full">
|
||||
{/* 헤더 */}
|
||||
<div className="bg-[#f1f8ff] content-stretch flex h-[48px] items-center overflow-clip relative shrink-0 w-full sticky top-0 z-10">
|
||||
{csvHeaders.map((header, index) => {
|
||||
const isLast = index === csvHeaders.length - 1;
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={`border-[#dee1e6] ${
|
||||
isLast ? '' : 'border-[0px_1px_0px_0px]'
|
||||
} border-solid box-border content-stretch flex gap-[10px] h-full items-center justify-center px-[8px] py-[12px] relative shrink-0 ${
|
||||
index === 0 ? 'w-[48px]' : index === 1 ? 'basis-0 grow min-h-px min-w-px' : 'w-[140px]'
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col font-['Pretendard:SemiBold',sans-serif] justify-center leading-[0] not-italic relative shrink-0 text-[#4c5561] text-[14px] text-nowrap">
|
||||
<p className="leading-[1.5] whitespace-pre">{header || `열 ${index + 1}`}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 데이터 행 */}
|
||||
{csvRows.map((row, rowIndex) => (
|
||||
<div
|
||||
key={rowIndex}
|
||||
className="border-[#dee1e6] border-[1px_0px_0px] border-solid h-[48px] relative shrink-0 w-full"
|
||||
>
|
||||
<div className="content-stretch flex h-[48px] items-start overflow-clip relative rounded-[inherit] w-full">
|
||||
{csvHeaders.map((_, colIndex) => {
|
||||
const isLast = colIndex === csvHeaders.length - 1;
|
||||
const cellValue = row[colIndex] || '';
|
||||
return (
|
||||
<div
|
||||
key={colIndex}
|
||||
className={`border-[#dee1e6] ${
|
||||
isLast ? '' : 'border-[0px_1px_0px_0px]'
|
||||
} border-solid box-border content-stretch flex flex-col gap-[4px] items-center justify-center px-[8px] py-[12px] relative shrink-0 ${
|
||||
colIndex === 0 ? 'w-[48px]' : colIndex === 1 ? 'basis-0 grow min-h-px min-w-px' : 'w-[140px]'
|
||||
}`}
|
||||
>
|
||||
<p className="font-['Pretendard:Medium',sans-serif] leading-[1.5] not-italic relative shrink-0 text-[#1b2027] text-[15px] text-nowrap whitespace-pre">
|
||||
{cellValue}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
54
src/app/admin/notices/NoticeValidationModal.tsx
Normal file
54
src/app/admin/notices/NoticeValidationModal.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
|
||||
type NoticeValidationModalProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* 공지사항 작성 시 제목 또는 내용이 비어있을 때 표시되는 검증 모달
|
||||
*/
|
||||
export default function NoticeValidationModal({
|
||||
open,
|
||||
onClose,
|
||||
}: NoticeValidationModalProps) {
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="닫기"
|
||||
className="absolute inset-0 bg-black/40 cursor-default"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="notice-validation-title"
|
||||
className="relative bg-white box-border flex flex-col items-stretch justify-end gap-[32px] p-6 rounded-[8px] w-[320px] shadow-[0px_8px_20px_0px_rgba(0,0,0,0.06),0px_24px_60px_0px_rgba(0,0,0,0.12)]"
|
||||
>
|
||||
<div className="flex flex-col gap-4 items-center w-full">
|
||||
<p
|
||||
className="text-[15px] font-normal leading-[1.5] text-[#333c47] w-full"
|
||||
id="notice-validation-title"
|
||||
>
|
||||
내용 또는 제목을 입력해 주세요.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center justify-end w-full">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="h-[37px] min-w-[82px] px-2 rounded-[8px] bg-[#1f2b91] text-white text-[16px] font-semibold leading-[1.5] cursor-pointer whitespace-nowrap"
|
||||
>
|
||||
확인
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
331
src/app/admin/notices/[id]/page.tsx
Normal file
331
src/app/admin/notices/[id]/page.tsx
Normal file
@@ -0,0 +1,331 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import AdminSidebar from '@/app/components/AdminSidebar';
|
||||
import BackCircleSvg from '@/app/svgs/backcirclesvg';
|
||||
import DownloadIcon from '@/app/svgs/downloadicon';
|
||||
import PaperClipSvg from '@/app/svgs/paperclipsvg';
|
||||
import apiService from '@/app/lib/apiService';
|
||||
import type { Notice } from '@/app/admin/notices/mockData';
|
||||
|
||||
type Attachment = {
|
||||
name: string;
|
||||
size: string;
|
||||
url?: string;
|
||||
fileKey?: string;
|
||||
};
|
||||
|
||||
export default function AdminNoticeDetailPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const [notice, setNotice] = useState<Notice | null>(null);
|
||||
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchNotice() {
|
||||
if (!params?.id) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const response = await apiService.getNotice(params.id);
|
||||
const data = response.data;
|
||||
|
||||
// API 응답 데이터를 Notice 형식으로 변환
|
||||
const transformedNotice: Notice = {
|
||||
id: data.id || data.noticeId || Number(params.id),
|
||||
title: data.title || '',
|
||||
date: data.date || data.createdAt || data.createdDate || new Date().toISOString().split('T')[0],
|
||||
views: data.views || data.viewCount || 0,
|
||||
writer: data.writer || data.author || data.createdBy || '관리자',
|
||||
content: data.content
|
||||
? (Array.isArray(data.content)
|
||||
? data.content
|
||||
: typeof data.content === 'string'
|
||||
? data.content.split('\n').filter((line: string) => line.trim())
|
||||
: [String(data.content)])
|
||||
: [],
|
||||
hasAttachment: data.hasAttachment || data.attachment || false,
|
||||
};
|
||||
|
||||
// 첨부파일 정보 처리
|
||||
if (data.attachments && Array.isArray(data.attachments)) {
|
||||
setAttachments(data.attachments.map((att: any) => ({
|
||||
name: att.name || att.fileName || '',
|
||||
size: att.size || att.fileSize || '',
|
||||
url: att.url || att.downloadUrl,
|
||||
fileKey: att.fileKey || att.key || att.fileId,
|
||||
})));
|
||||
} else if (transformedNotice.hasAttachment && data.attachment) {
|
||||
// 단일 첨부파일인 경우
|
||||
setAttachments([{
|
||||
name: data.attachment.name || data.attachment.fileName || '첨부파일',
|
||||
size: data.attachment.size || data.attachment.fileSize || '',
|
||||
url: data.attachment.url || data.attachment.downloadUrl,
|
||||
fileKey: data.attachment.fileKey || data.attachment.key || data.attachment.fileId,
|
||||
}]);
|
||||
}
|
||||
|
||||
if (!transformedNotice.title) {
|
||||
throw new Error('공지사항을 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
setNotice(transformedNotice);
|
||||
} catch (err) {
|
||||
console.error('공지사항 조회 오류:', err);
|
||||
setError('공지사항을 불러오는 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchNotice();
|
||||
}, [params?.id]);
|
||||
|
||||
const handleDownload = async (fileKey?: string, url?: string, fileName?: string) => {
|
||||
if (url) {
|
||||
// URL이 있으면 직접 다운로드
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = fileName || 'download';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
} else if (fileKey) {
|
||||
// fileKey가 있으면 API를 통해 다운로드
|
||||
try {
|
||||
const fileUrl = await apiService.getFile(fileKey);
|
||||
if (fileUrl) {
|
||||
const link = document.createElement('a');
|
||||
link.href = fileUrl;
|
||||
link.download = fileName || 'download';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('파일 다운로드 실패:', err);
|
||||
alert('파일 다운로드에 실패했습니다.');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col bg-white">
|
||||
<div className="flex flex-1 min-h-0 justify-center">
|
||||
<div className="w-[1440px] flex min-h-0">
|
||||
<div className="flex">
|
||||
<AdminSidebar />
|
||||
</div>
|
||||
<main className="w-[1120px] bg-white">
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="h-[100px] flex items-center justify-center px-[32px]">
|
||||
<p className="text-[16px] font-medium text-[#333c47]">로딩 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !notice || !notice.content || notice.content.length === 0) {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col bg-white">
|
||||
<div className="flex flex-1 min-h-0 justify-center">
|
||||
<div className="w-[1440px] flex min-h-0">
|
||||
<div className="flex">
|
||||
<AdminSidebar />
|
||||
</div>
|
||||
<main className="w-[1120px] bg-white">
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="h-[100px] flex items-center gap-[10px] px-[32px]">
|
||||
<Link
|
||||
href="/admin/notices"
|
||||
aria-label="뒤로 가기"
|
||||
className="size-8 rounded-full inline-flex items-center justify-center text-[#8C95A1] hover:bg-black/5 no-underline"
|
||||
>
|
||||
<BackCircleSvg width={32} height={32} />
|
||||
</Link>
|
||||
<h1 className="m-0 text-[24px] font-bold leading-normal text-[#1B2027]">
|
||||
공지사항 상세
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<p className="text-[16px] font-medium text-[#333c47]">
|
||||
{error || '공지사항을 찾을 수 없습니다.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col bg-white">
|
||||
<div className="flex flex-1 min-h-0 justify-center">
|
||||
<div className="w-[1440px] flex min-h-0">
|
||||
{/* 사이드바 */}
|
||||
<div className="flex">
|
||||
<AdminSidebar />
|
||||
</div>
|
||||
|
||||
{/* 메인 콘텐츠 */}
|
||||
<main className="w-[1120px] bg-white">
|
||||
<div className="h-full flex flex-col">
|
||||
{/* 상단 타이틀 */}
|
||||
<div className="h-[100px] flex items-center gap-[10px] px-[32px]">
|
||||
<Link
|
||||
href="/admin/notices"
|
||||
aria-label="뒤로 가기"
|
||||
className="size-8 rounded-full inline-flex items-center justify-center text-[#8C95A1] hover:bg-black/5 no-underline shrink-0"
|
||||
>
|
||||
<BackCircleSvg width={32} height={32} />
|
||||
</Link>
|
||||
<h1 className="m-0 text-[24px] font-bold leading-[1.5] text-[#1B2027]">
|
||||
공지사항 상세
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* 카드 */}
|
||||
<section className="flex flex-col gap-[40px] px-[32px] py-[24px]">
|
||||
<div className="rounded-[8px] border border-[#DEE1E6] overflow-hidden bg-white">
|
||||
{/* 헤더 */}
|
||||
<div className="p-[32px]">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-[8px]">
|
||||
<h2 className="m-0 text-[20px] font-bold leading-[1.5] text-[#333C47]">
|
||||
{notice.title}
|
||||
</h2>
|
||||
<div className="flex items-center gap-[16px] text-[13px] font-medium leading-[1.4]">
|
||||
<div className="flex items-center gap-[8px]">
|
||||
<span className="text-[#8C95A1]">작성자</span>
|
||||
<span className="text-[#333C47]">{notice.writer}</span>
|
||||
</div>
|
||||
<div className="h-[16px] w-0 flex items-center justify-center">
|
||||
<div className="h-0 w-[16px] border-t border-[#DEE1E6]" />
|
||||
</div>
|
||||
<div className="flex items-center gap-[8px]">
|
||||
<span className="text-[#8C95A1]">게시일</span>
|
||||
<span className="text-[#333C47]">
|
||||
{notice.date.includes('T')
|
||||
? new Date(notice.date).toISOString().split('T')[0]
|
||||
: notice.date}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-[16px] w-0 flex items-center justify-center">
|
||||
<div className="h-0 w-[16px] border-t border-[#DEE1E6]" />
|
||||
</div>
|
||||
<div className="flex items-center gap-[8px]">
|
||||
<span className="text-[#8C95A1]">조회수</span>
|
||||
<span className="text-[#333C47]">{notice.views.toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 구분선 */}
|
||||
<div className="h-px bg-[#DEE1E6] w-full" />
|
||||
|
||||
{/* 본문 및 첨부파일 */}
|
||||
<div className="flex flex-col gap-[40px] p-[32px]">
|
||||
{/* 본문 */}
|
||||
<div className="text-[15px] font-normal leading-[1.5] text-[#333C47]">
|
||||
{notice.content.map((p, idx) => (
|
||||
<p key={idx} className="mb-0 leading-[1.5]">
|
||||
{p}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 첨부파일 섹션 */}
|
||||
{attachments.length > 0 && (
|
||||
<div className="flex flex-col gap-[24px] w-full">
|
||||
<div className="flex flex-col gap-[8px] w-full">
|
||||
<div className="flex items-center gap-[12px] h-[32px]">
|
||||
<div className="flex items-center">
|
||||
<p className="text-[15px] font-semibold leading-[1.5] text-[#6C7682] m-0">
|
||||
첨부 파일
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{attachments.map((attachment, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="bg-white border border-[#DEE1E6] rounded-[6px] h-[64px] flex items-center gap-[12px] px-[17px] py-1 w-full"
|
||||
>
|
||||
<div className="size-[24px] shrink-0">
|
||||
<PaperClipSvg width={24} height={24} className="text-[#333C47]" />
|
||||
</div>
|
||||
<div className="flex-1 flex items-center gap-[8px] min-w-0">
|
||||
<p className="text-[15px] font-normal leading-[1.5] text-[#1B2027] truncate m-0">
|
||||
{attachment.name}
|
||||
</p>
|
||||
<p className="text-[13px] font-normal leading-[1.4] text-[#8C95A1] shrink-0 m-0">
|
||||
{attachment.size}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDownload(attachment.fileKey, attachment.url, attachment.name)}
|
||||
className="bg-white border border-[#8C95A1] rounded-[6px] h-[32px] flex items-center justify-center gap-[4px] px-[16px] py-[3px] shrink-0 hover:bg-[#F9FAFB] cursor-pointer"
|
||||
>
|
||||
<DownloadIcon width={16} height={16} className="text-[#4C5561]" />
|
||||
<span className="text-[13px] font-medium leading-[1.4] text-[#4C5561]">
|
||||
다운로드
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 버튼 영역 */}
|
||||
<div className="flex items-center justify-end gap-[12px]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (confirm('정말 삭제하시겠습니까?')) {
|
||||
// TODO: 삭제 API 호출
|
||||
router.push('/admin/notices');
|
||||
}
|
||||
}}
|
||||
className="bg-[#FEF2F2] h-[48px] rounded-[10px] px-[8px] shrink-0 min-w-[80px] flex items-center justify-center hover:bg-[#FEE2E2] transition-colors"
|
||||
>
|
||||
<span className="text-[16px] font-semibold leading-[1.5] text-[#F64C4C] text-center">
|
||||
삭제
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push(`/admin/notices/${params.id}/edit`)}
|
||||
className="bg-[#F1F3F5] h-[48px] rounded-[10px] px-[16px] shrink-0 min-w-[90px] flex items-center justify-center hover:bg-[#E9ECEF] transition-colors"
|
||||
>
|
||||
<span className="text-[16px] font-semibold leading-[1.5] text-[#4C5561] text-center">
|
||||
수정하기
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,21 +1,90 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo, useRef, ChangeEvent } from "react";
|
||||
import { useState, useMemo, useRef, ChangeEvent, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import AdminSidebar from "@/app/components/AdminSidebar";
|
||||
import ChevronDownSvg from "@/app/svgs/chevrondownsvg";
|
||||
import BackArrowSvg from "@/app/svgs/backarrow";
|
||||
import { MOCK_NOTICES, type Notice } from "@/app/admin/notices/mockData";
|
||||
import { type Notice } from "@/app/admin/notices/mockData";
|
||||
import apiService from "@/app/lib/apiService";
|
||||
import NoticeValidationModal from "@/app/admin/notices/NoticeValidationModal";
|
||||
|
||||
export default function AdminNoticesPage() {
|
||||
const [notices, setNotices] = useState<Notice[]>(MOCK_NOTICES);
|
||||
const router = useRouter();
|
||||
const [notices, setNotices] = useState<Notice[]>([]);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [isWritingMode, setIsWritingMode] = useState(false);
|
||||
const [title, setTitle] = useState('');
|
||||
const [content, setContent] = useState('');
|
||||
const [attachedFile, setAttachedFile] = useState<File | null>(null);
|
||||
const [fileKey, setFileKey] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isValidationModalOpen, setIsValidationModalOpen] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 날짜를 yyyy-mm-dd 형식으로 포맷팅
|
||||
const formatDate = (dateString: string): string => {
|
||||
if (!dateString) return '';
|
||||
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
if (isNaN(date.getTime())) {
|
||||
// 이미 yyyy-mm-dd 형식인 경우 그대로 반환
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(dateString)) {
|
||||
return dateString;
|
||||
}
|
||||
return dateString;
|
||||
}
|
||||
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
} catch {
|
||||
return dateString;
|
||||
}
|
||||
};
|
||||
|
||||
// API에서 공지사항 목록 가져오기
|
||||
useEffect(() => {
|
||||
async function fetchNotices() {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await apiService.getNotices();
|
||||
const data = response.data;
|
||||
|
||||
// API 응답이 배열이 아닌 경우 처리 (예: { items: [...] } 형태)
|
||||
let noticesArray: any[] = [];
|
||||
if (Array.isArray(data)) {
|
||||
noticesArray = data;
|
||||
} else if (data && typeof data === 'object') {
|
||||
noticesArray = data.items || data.notices || data.data || data.list || [];
|
||||
}
|
||||
|
||||
// API 응답 데이터를 Notice 형식으로 변환
|
||||
const transformedNotices: Notice[] = noticesArray.map((notice: any) => ({
|
||||
id: notice.id || notice.noticeId || 0,
|
||||
title: notice.title || '',
|
||||
date: notice.date || notice.createdAt || notice.createdDate || new Date().toISOString().split('T')[0],
|
||||
views: notice.views || notice.viewCount || 0,
|
||||
writer: notice.writer || notice.author || notice.createdBy || '관리자',
|
||||
content: notice.content ? (Array.isArray(notice.content) ? notice.content : [notice.content]) : undefined,
|
||||
hasAttachment: notice.hasAttachment || notice.attachment || false,
|
||||
}));
|
||||
|
||||
setNotices(transformedNotices);
|
||||
} catch (error) {
|
||||
console.error('공지사항 목록 조회 오류:', error);
|
||||
// 에러 발생 시 빈 배열로 설정
|
||||
setNotices([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchNotices();
|
||||
}, []);
|
||||
|
||||
const totalCount = useMemo(() => notices.length, [notices]);
|
||||
|
||||
const characterCount = useMemo(() => content.length, [content]);
|
||||
@@ -25,6 +94,11 @@ export default function AdminNoticesPage() {
|
||||
setTitle('');
|
||||
setContent('');
|
||||
setAttachedFile(null);
|
||||
setFileKey(null);
|
||||
// 파일 입력 초기화
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileAttach = () => {
|
||||
@@ -39,43 +113,116 @@ export default function AdminNoticesPage() {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setIsLoading(true);
|
||||
// 단일 파일 업로드
|
||||
await apiService.uploadFile(file);
|
||||
const uploadResponse = await apiService.uploadFile(file);
|
||||
|
||||
// 응답에서 fileKey 추출
|
||||
let extractedFileKey: string | null = null;
|
||||
if (uploadResponse.data?.fileKey) {
|
||||
extractedFileKey = uploadResponse.data.fileKey;
|
||||
} else if (uploadResponse.data?.key) {
|
||||
extractedFileKey = uploadResponse.data.key;
|
||||
} else if (uploadResponse.data?.id) {
|
||||
extractedFileKey = uploadResponse.data.id;
|
||||
} else if (uploadResponse.data?.imageKey) {
|
||||
extractedFileKey = uploadResponse.data.imageKey;
|
||||
} else if (uploadResponse.data?.fileId) {
|
||||
extractedFileKey = uploadResponse.data.fileId;
|
||||
} else if (uploadResponse.data?.data && (uploadResponse.data.data.key || uploadResponse.data.data.fileKey)) {
|
||||
extractedFileKey = uploadResponse.data.data.key || uploadResponse.data.data.fileKey;
|
||||
} else if (uploadResponse.data?.results && Array.isArray(uploadResponse.data.results) && uploadResponse.data.results.length > 0) {
|
||||
const result = uploadResponse.data.results[0];
|
||||
if (result.ok && result.fileKey) {
|
||||
extractedFileKey = result.fileKey;
|
||||
}
|
||||
}
|
||||
|
||||
if (extractedFileKey) {
|
||||
setFileKey(extractedFileKey);
|
||||
setAttachedFile(file);
|
||||
} else {
|
||||
throw new Error('파일 키를 받아오지 못했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('파일 업로드 실패:', error);
|
||||
alert('파일 업로드에 실패했습니다. 다시 시도해주세요.');
|
||||
setAttachedFile(null);
|
||||
setFileKey(null);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
// 파일 입력 초기화
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (!title.trim()) {
|
||||
alert('제목을 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
if (!content.trim()) {
|
||||
alert('내용을 입력해주세요.');
|
||||
const handleSave = async () => {
|
||||
if (!title.trim() || !content.trim()) {
|
||||
setIsValidationModalOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// 새 공지사항 추가
|
||||
const newNotice: Notice = {
|
||||
id: notices.length > 0 ? Math.max(...notices.map(n => n.id)) + 1 : 1,
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
// 공지사항 생성 API 호출
|
||||
const noticeData: any = {
|
||||
title: title.trim(),
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
views: 0,
|
||||
writer: '관리자', // TODO: 실제 작성자 정보 사용
|
||||
content: content.split('\n'),
|
||||
hasAttachment: attachedFile !== null,
|
||||
content: content.trim(),
|
||||
};
|
||||
|
||||
setNotices([newNotice, ...notices]);
|
||||
// fileKey와 파일 정보가 있으면 attachments 배열로 포함
|
||||
if (fileKey && attachedFile) {
|
||||
noticeData.attachments = [
|
||||
{
|
||||
fileKey: fileKey,
|
||||
filename: attachedFile.name,
|
||||
mimeType: attachedFile.type || 'application/octet-stream',
|
||||
size: attachedFile.size,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const response = await apiService.createNotice(noticeData);
|
||||
|
||||
// API 응답 후 목록 새로고침
|
||||
const fetchResponse = await apiService.getNotices();
|
||||
const data = fetchResponse.data;
|
||||
|
||||
// API 응답이 배열이 아닌 경우 처리
|
||||
let noticesArray: any[] = [];
|
||||
if (Array.isArray(data)) {
|
||||
noticesArray = data;
|
||||
} else if (data && typeof data === 'object') {
|
||||
noticesArray = data.items || data.notices || data.data || data.list || [];
|
||||
}
|
||||
|
||||
// API 응답 데이터를 Notice 형식으로 변환
|
||||
const transformedNotices: Notice[] = noticesArray.map((notice: any) => ({
|
||||
id: notice.id || notice.noticeId || 0,
|
||||
title: notice.title || '',
|
||||
date: notice.date || notice.createdAt || notice.createdDate || new Date().toISOString().split('T')[0],
|
||||
views: notice.views || notice.viewCount || 0,
|
||||
writer: notice.writer || notice.author || notice.createdBy || '관리자',
|
||||
content: notice.content ? (Array.isArray(notice.content) ? notice.content : [notice.content]) : undefined,
|
||||
hasAttachment: notice.hasAttachment || notice.attachment || !!notice.fileKey || false,
|
||||
}));
|
||||
|
||||
setNotices(transformedNotices);
|
||||
handleBack();
|
||||
} catch (error) {
|
||||
console.error('공지사항 저장 실패:', error);
|
||||
alert('공지사항 저장에 실패했습니다. 다시 시도해주세요.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
if (title.trim() || content.trim() || attachedFile) {
|
||||
if (title.trim() || content.trim() || attachedFile || fileKey) {
|
||||
if (confirm('작성 중인 내용이 있습니다. 정말 취소하시겠습니까?')) {
|
||||
handleBack();
|
||||
}
|
||||
@@ -100,6 +247,11 @@ export default function AdminNoticesPage() {
|
||||
}, [sortedNotices, currentPage]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<NoticeValidationModal
|
||||
open={isValidationModalOpen}
|
||||
onClose={() => setIsValidationModalOpen(false)}
|
||||
/>
|
||||
<div className="min-h-screen flex flex-col bg-white">
|
||||
{/* 메인 레이아웃 */}
|
||||
<div className="flex flex-1 min-h-0 justify-center">
|
||||
@@ -190,10 +342,11 @@ export default function AdminNoticesPage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleFileAttach}
|
||||
className="h-[32px] w-[62px] px-[4px] py-[3px] rounded-[6px] border border-[#8c95a1] bg-white flex items-center justify-center cursor-pointer hover:bg-gray-50 transition-colors shrink-0"
|
||||
disabled={isLoading}
|
||||
className="h-[32px] w-[62px] px-[4px] py-[3px] rounded-[6px] border border-[#8c95a1] bg-white flex items-center justify-center cursor-pointer hover:bg-gray-50 transition-colors shrink-0 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span className="text-[13px] font-medium leading-[1.4] text-[#4c5561] whitespace-nowrap">
|
||||
첨부
|
||||
{isLoading ? '업로드 중...' : '첨부'}
|
||||
</span>
|
||||
</button>
|
||||
<input
|
||||
@@ -230,9 +383,10 @@ export default function AdminNoticesPage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
className="h-12 px-4 rounded-[10px] bg-[#1f2b91] text-[16px] font-semibold leading-[1.5] text-white whitespace-nowrap hover:bg-[#1a2478] transition-colors cursor-pointer"
|
||||
disabled={isLoading}
|
||||
className="h-12 px-4 rounded-[10px] bg-[#1f2b91] text-[16px] font-semibold leading-[1.5] text-white whitespace-nowrap hover:bg-[#1a2478] transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
저장하기
|
||||
{isLoading ? '저장 중...' : '저장하기'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -264,7 +418,7 @@ export default function AdminNoticesPage() {
|
||||
<div className="flex-1 pt-2 flex flex-col">
|
||||
{notices.length === 0 ? (
|
||||
<div className="rounded-lg border border-[#dee1e6] bg-white min-h-[400px] flex items-center justify-center">
|
||||
<p className="text-[16px] font-medium leading-[1.5] text-[#333c47]">
|
||||
<p className="text-[16px] font-medium leading-[1.5] text-[#333c47] text-center">
|
||||
등록된 공지사항이 없습니다.
|
||||
<br />
|
||||
공지사항을 등록해주세요.
|
||||
@@ -298,7 +452,8 @@ export default function AdminNoticesPage() {
|
||||
return (
|
||||
<tr
|
||||
key={notice.id}
|
||||
className="h-12 hover:bg-[#F5F7FF] transition-colors"
|
||||
onClick={() => router.push(`/admin/notices/${notice.id}`)}
|
||||
className="h-12 hover:bg-[#F5F7FF] transition-colors cursor-pointer"
|
||||
>
|
||||
<td className="border-t border-r border-[#dee1e6] px-4 text-[13px] leading-[1.5] text-[#1b2027] whitespace-nowrap text-center">
|
||||
{noticeNumber}
|
||||
@@ -307,7 +462,7 @@ export default function AdminNoticesPage() {
|
||||
{notice.title}
|
||||
</td>
|
||||
<td className="border-t border-r border-[#dee1e6] px-4 text-[13px] leading-[1.5] text-[#1b2027] whitespace-nowrap">
|
||||
{notice.date}
|
||||
{formatDate(notice.date)}
|
||||
</td>
|
||||
<td className="border-t border-r border-[#dee1e6] px-4 text-[13px] leading-[1.5] text-[#1b2027] whitespace-nowrap">
|
||||
{notice.views.toLocaleString()}
|
||||
@@ -416,6 +571,7 @@ export default function AdminNoticesPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user