File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
import { useEffect, useRef, useState } from 'react'
import {
addSharedFile,
deleteSharedFile,
getSharedFiles,
sharedFileDownloadUrl,
updateSharedFileDescription,
type SharedFile,
} from '../api/client'
/**
* 자료실 — 공용 양식 보관소(요구사항 [26]).
*
* 기관에 딸리지 않는 서식을 여기 올려 두고, 어느 기관 일을 하든 내려받아 씁니다.
* 기관별 자료는 기관관리 > 자료 탭에 그대로 있습니다.
*/
function formatSize(bytes: number): string {
if (bytes < 1024) {
return `${bytes} B`
}
if (bytes < 1024 * 1024) {
return `${(bytes / 1024).toFixed(1)} KB`
}
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}
function formatDate(ms: number): string {
const d = new Date(ms)
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
}
export default function SharedFiles() {
const [files, setFiles] = useState<SharedFile[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
const [description, setDescription] = useState('')
const [editingId, setEditingId] = useState<number | null>(null)
const [editingText, setEditingText] = useState('')
const inputRef = useRef<HTMLInputElement>(null)
async function load() {
setLoading(true)
try {
setFiles(await getSharedFiles())
setError(null)
} catch (e) {
setError((e as Error).message)
} finally {
setLoading(false)
}
}
useEffect(() => {
void load()
}, [])
async function handleUpload(file: File) {
setBusy(true)
setError(null)
try {
setFiles(await addSharedFile(file, description.trim()))
setDescription('')
} catch (e) {
setError((e as Error).message)
} finally {
setBusy(false)
}
}
async function handleDelete(file: SharedFile) {
if (!window.confirm(`「${file.fileName}」을(를) 지울까요?`)) {
return
}
setBusy(true)
try {
await deleteSharedFile(file.id)
await load()
} finally {
setBusy(false)
}
}
async function saveDescription(file: SharedFile) {
setBusy(true)
try {
setFiles(await updateSharedFileDescription(file.id, editingText.trim()))
setEditingId(null)
} catch (e) {
setError((e as Error).message)
} finally {
setBusy(false)
}
}
return (
<div className="p-8">
<h2 className="text-sm font-semibold">자료실</h2>
<p className="mt-1 text-xs text-gray-500">
기관에 딸리지 않는 공용 서식을 올려 둡니다. 기관별 자료는 기관관리 > 자료 탭에 있습니다.
</p>
<div className="mt-4 flex flex-wrap items-center gap-2 rounded-lg border border-gray-200 p-3">
<input
type="text"
aria-label="자료 설명"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="설명(선택)"
className="w-72 rounded-md border border-gray-300 px-2 py-1.5 text-sm"
/>
<input
ref={inputRef}
type="file"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) void handleUpload(file)
e.target.value = ''
}}
/>
<button
type="button"
disabled={busy}
onClick={() => inputRef.current?.click()}
className="rounded-md bg-blue-600 px-3 py-1.5 text-sm text-white disabled:bg-gray-300"
>
자료 올리기
</button>
</div>
{error && <p className="mt-2 text-sm text-red-600">{error}</p>}
{loading ? (
<p className="mt-4 text-sm text-gray-500">불러오는 중…</p>
) : files.length === 0 ? (
<p className="mt-6 text-sm text-gray-500">올려둔 자료가 없습니다.</p>
) : (
<table data-testid="shared-file-table" className="mt-4 w-full text-sm">
<thead className="border-b border-gray-200 text-left text-xs text-gray-500">
<tr>
<th className="px-3 py-2 font-medium">파일명</th>
<th className="px-3 py-2 font-medium">설명</th>
<th className="whitespace-nowrap px-3 py-2 font-medium">크기</th>
<th className="whitespace-nowrap px-3 py-2 font-medium">올린 사람</th>
<th className="whitespace-nowrap px-3 py-2 font-medium">올린 날짜</th>
<th className="px-3 py-2" />
</tr>
</thead>
<tbody>
{files.map((file) => (
<tr key={file.id} className="border-b border-gray-100 last:border-b-0">
<td className="px-3 py-2">
<a
href={sharedFileDownloadUrl(file.id)}
className="font-medium text-blue-700 hover:underline"
>
{file.fileName}
</a>
</td>
<td className="px-3 py-2 text-gray-600">
{editingId === file.id ? (
<div className="flex items-center gap-1.5">
<input
aria-label="설명 수정"
value={editingText}
onChange={(e) => setEditingText(e.target.value)}
className="w-56 rounded-md border border-gray-300 px-2 py-1 text-sm"
/>
<button
type="button"
disabled={busy}
onClick={() => void saveDescription(file)}
className="rounded-md border border-gray-300 px-2 py-0.5 text-xs"
>
저장
</button>
</div>
) : (
<button
type="button"
onClick={() => {
setEditingId(file.id)
setEditingText(file.description ?? '')
}}
className="text-left hover:underline"
>
{file.description ?? <span className="text-gray-300">설명 없음</span>}
</button>
)}
</td>
<td className="whitespace-nowrap px-3 py-2 text-gray-500">{formatSize(file.byteSize)}</td>
<td className="whitespace-nowrap px-3 py-2 text-gray-500">{file.uploadedBy}</td>
<td className="whitespace-nowrap px-3 py-2 text-gray-500">{formatDate(file.uploadedAt)}</td>
<td className="px-3 py-2 text-right">
<button
type="button"
disabled={busy}
onClick={() => void handleDelete(file)}
className="rounded-md border border-gray-300 px-2 py-0.5 text-xs text-red-600 hover:bg-red-50"
>
삭제
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)
}