From d9f0d3ffbffc3afd838ccc47a8c79954aa50a4af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Mon, 16 Mar 2026 15:56:16 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20[daily-work-log]=20=EB=A9=94=EB=AA=A8/?= =?UTF-8?q?=ED=9A=8C=EA=B3=A0=20=ED=8C=8C=EC=9D=BC=20=EC=97=85=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 메모, 회고 섹션에 파일 첨부 기능 추가 - 드래그앤드롭 및 클릭 업로드 지원 - 이미지 썸네일 미리보기, 파일 다운로드/삭제 - Boards\File 모델 재사용 (document_type: daily_work_log) --- .../Finance/DailyWorkLogController.php | 116 ++++++++++++++++++ app/Models/Boards/File.php | 1 + .../views/finance/daily-work-log.blade.php | 60 +++++++++ routes/web.php | 3 + 4 files changed, 180 insertions(+) diff --git a/app/Http/Controllers/Finance/DailyWorkLogController.php b/app/Http/Controllers/Finance/DailyWorkLogController.php index 6d5d9e57..ab51da84 100644 --- a/app/Http/Controllers/Finance/DailyWorkLogController.php +++ b/app/Http/Controllers/Finance/DailyWorkLogController.php @@ -3,10 +3,13 @@ namespace App\Http\Controllers\Finance; use App\Http\Controllers\Controller; +use App\Models\Boards\File; use App\Models\Finance\DailyWorkLog; use App\Models\Finance\DailyWorkLogItem; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Storage; +use Illuminate\Support\Str; class DailyWorkLogController extends Controller { @@ -41,6 +44,21 @@ public function show(Request $request): JsonResponse $total = $items->count(); $completed = $items->where('is_completed', true)->count(); + $files = File::where('document_type', 'daily_work_log') + ->where('document_id', $log->id) + ->whereNull('deleted_at') + ->get() + ->map(fn (File $f) => [ + 'id' => $f->id, + 'field_key' => $f->field_key, + 'original_name' => $f->original_name, + 'file_size' => $f->file_size, + 'formatted_size' => $f->getFormattedSize(), + 'mime_type' => $f->mime_type, + 'is_image' => $f->isImage(), + 'url' => $f->getUrl(), + ]); + return response()->json([ 'success' => true, 'data' => [ @@ -50,6 +68,7 @@ public function show(Request $request): JsonResponse 'reflection' => $log->reflection ?? '', 'items' => $items->values(), 'achievement_rate' => $total > 0 ? round(($completed / $total) * 100, 2) : 0, + 'files' => $files->values(), ], ]); } @@ -173,4 +192,101 @@ public function copyFromPrevious(Request $request): JsonResponse ], ]); } + + /** + * 파일 업로드 (메모/회고 첨부) + */ + public function uploadFile(Request $request): JsonResponse + { + $request->validate([ + 'log_date' => 'required|date', + 'field_key' => 'required|in:memo,reflection', + 'file' => 'required|file|max:10240', + ]); + + $tenantId = session('selected_tenant_id', 1); + + $log = DailyWorkLog::updateOrCreate( + ['tenant_id' => $tenantId, 'log_date' => $request->input('log_date')], + ['created_by' => auth()->id()] + ); + + $file = $request->file('file'); + $storedName = Str::uuid().'.'.$file->getClientOriginalExtension(); + $basePath = "daily-work-log/{$tenantId}/{$log->id}"; + $filePath = "{$basePath}/{$storedName}"; + + Storage::disk('tenant')->put($filePath, file_get_contents($file)); + + $record = File::create([ + 'tenant_id' => $tenantId, + 'is_temp' => false, + 'file_path' => $filePath, + 'display_name' => $file->getClientOriginalName(), + 'stored_name' => $storedName, + 'original_name' => $file->getClientOriginalName(), + 'file_name' => $file->getClientOriginalName(), + 'file_size' => $file->getSize(), + 'mime_type' => $file->getMimeType(), + 'file_type' => $this->detectFileType($file->getMimeType()), + 'field_key' => $request->input('field_key'), + 'document_type' => 'daily_work_log', + 'document_id' => $log->id, + 'uploaded_by' => auth()->id(), + 'created_by' => auth()->id(), + ]); + + return response()->json([ + 'success' => true, + 'file' => [ + 'id' => $record->id, + 'field_key' => $record->field_key, + 'original_name' => $record->original_name, + 'file_size' => $record->file_size, + 'formatted_size' => $record->getFormattedSize(), + 'mime_type' => $record->mime_type, + 'is_image' => $record->isImage(), + 'url' => $record->getUrl(), + ], + ]); + } + + /** + * 파일 삭제 + */ + public function deleteFile(int $fileId): JsonResponse + { + $file = File::where('document_type', 'daily_work_log')->findOrFail($fileId); + $file->softDeleteFile(auth()->id()); + + return response()->json(['success' => true]); + } + + /** + * 파일 다운로드 + */ + public function downloadFile(int $fileId) + { + $file = File::where('document_type', 'daily_work_log')->findOrFail($fileId); + + return $file->download(); + } + + private function detectFileType(?string $mimeType): string + { + if (! $mimeType) { + return 'other'; + } + if (str_starts_with($mimeType, 'image/')) { + return 'image'; + } + if ($mimeType === 'application/pdf') { + return 'pdf'; + } + if (str_contains($mimeType, 'spreadsheet') || str_contains($mimeType, 'excel')) { + return 'excel'; + } + + return 'document'; + } } diff --git a/app/Models/Boards/File.php b/app/Models/Boards/File.php index fff9f652..30fccc37 100644 --- a/app/Models/Boards/File.php +++ b/app/Models/Boards/File.php @@ -49,6 +49,7 @@ class File extends Model 'file_size', 'mime_type', 'file_type', + 'field_key', // New fields (API 방식) 'document_id', 'document_type', diff --git a/resources/views/finance/daily-work-log.blade.php b/resources/views/finance/daily-work-log.blade.php index 16f6f0f9..7e603619 100644 --- a/resources/views/finance/daily-work-log.blade.php +++ b/resources/views/finance/daily-work-log.blade.php @@ -63,8 +63,11 @@ function DailyWorkLog() { const [items, setItems] = useState([]); const [memo, setMemo] = useState(''); const [reflection, setReflection] = useState(''); + const [memoFiles, setMemoFiles] = useState([]); + const [reflectionFiles, setReflectionFiles] = useState([]); const [loading, setLoading] = useState(false); const [saving, setSaving] = useState(false); + const [uploading, setUploading] = useState(false); const [dirty, setDirty] = useState(false); const [message, setMessage] = useState(null); @@ -82,11 +85,16 @@ function DailyWorkLog() { setItems(json.data.items.map(it => ({...it, _key: Math.random()}))); setMemo(json.data.memo); setReflection(json.data.reflection); + const allFiles = json.data.files || []; + setMemoFiles(allFiles.filter(f => f.field_key === 'memo')); + setReflectionFiles(allFiles.filter(f => f.field_key === 'reflection')); } else { setLogId(null); setItems([]); setMemo(''); setReflection(''); + setMemoFiles([]); + setReflectionFiles([]); } setDirty(false); } catch (e) { @@ -176,6 +184,56 @@ function DailyWorkLog() { setTimeout(() => setMessage(null), 3000); }; + // 파일 업로드 + const handleFileUpload = async (fieldKey, files) => { + if (!files || files.length === 0) return; + setUploading(true); + try { + for (const file of files) { + const formData = new FormData(); + formData.append('file', file); + formData.append('log_date', currentDate); + formData.append('field_key', fieldKey); + const res = await fetch('/finance/daily-work-log/upload-file', { + method: 'POST', + headers: { 'X-CSRF-TOKEN': csrfToken }, + body: formData, + }); + const json = await res.json(); + if (json.success) { + const setter = fieldKey === 'memo' ? setMemoFiles : setReflectionFiles; + setter(prev => [...prev, json.file]); + } else { + showMessage('업로드 실패: ' + (json.message || ''), 'error'); + } + } + showMessage('파일이 업로드되었습니다.', 'success'); + } catch (e) { + showMessage('파일 업로드 실패', 'error'); + } finally { + setUploading(false); + } + }; + + // 파일 삭제 + const handleFileDelete = async (fileId, fieldKey) => { + if (!confirm('파일을 삭제하시겠습니까?')) return; + try { + const res = await fetch('/finance/daily-work-log/file/' + fileId, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken }, + }); + const json = await res.json(); + if (json.success) { + const setter = fieldKey === 'memo' ? setMemoFiles : setReflectionFiles; + setter(prev => prev.filter(f => f.id !== fileId)); + showMessage('파일이 삭제되었습니다.', 'success'); + } + } catch (e) { + showMessage('파일 삭제 실패', 'error'); + } + }; + // 항목 CRUD const addItem = () => { setItems([...items, { _key: Math.random(), category: '', task: '', priority: '', is_completed: false, note: '', highlight: '' }]); @@ -444,6 +502,7 @@ className="text-xs border border-gray-200 rounded" style={colorSelectStyle} titl