feat: [approval] 지출결의서 첨부파일 업로드/다운로드 기능 추가
- 첨부파일 업로드 API (GCS 연동, 20MB 제한) - 첨부파일 삭제/다운로드 API 추가 - 지출결의서 폼에 드래그&드롭 멀티 파일 업로드 UI 추가 - ApprovalService에 linkAttachments 메서드 추가 (is_temp 플래그 관리) - show 페이지에 첨부파일 목록 표시 및 다운로드 링크 - 지출부서 기본값 '본사', 로그인 사용자 이름 자동입력, 제목 필드 제거
This commit is contained in:
@@ -3,9 +3,13 @@
|
||||
namespace App\Http\Controllers\Api\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Boards\File;
|
||||
use App\Services\ApprovalService;
|
||||
use App\Services\GoogleCloudStorageService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class ApprovalApiController extends Controller
|
||||
{
|
||||
@@ -100,6 +104,8 @@ public function store(Request $request): JsonResponse
|
||||
'steps' => 'nullable|array',
|
||||
'steps.*.user_id' => 'required_with:steps|exists:users,id',
|
||||
'steps.*.step_type' => 'required_with:steps|in:approval,agreement,reference',
|
||||
'attachment_file_ids' => 'nullable|array',
|
||||
'attachment_file_ids.*' => 'integer',
|
||||
]);
|
||||
|
||||
$approval = $this->service->createApproval($request->all());
|
||||
@@ -124,6 +130,8 @@ public function update(Request $request, int $id): JsonResponse
|
||||
'steps' => 'nullable|array',
|
||||
'steps.*.user_id' => 'required_with:steps|exists:users,id',
|
||||
'steps.*.step_type' => 'required_with:steps|in:approval,agreement,reference',
|
||||
'attachment_file_ids' => 'nullable|array',
|
||||
'attachment_file_ids.*' => 'integer',
|
||||
]);
|
||||
|
||||
try {
|
||||
@@ -455,4 +463,94 @@ public function badgeCounts(): JsonResponse
|
||||
|
||||
return response()->json(['success' => true, 'data' => $counts]);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 첨부파일
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* 첨부파일 업로드
|
||||
*/
|
||||
public function uploadFile(Request $request, GoogleCloudStorageService $gcs): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'file' => 'required|file|max:20480',
|
||||
]);
|
||||
|
||||
$file = $request->file('file');
|
||||
$tenantId = session('selected_tenant_id');
|
||||
$storedName = Str::random(40).'.'.$file->getClientOriginalExtension();
|
||||
$storagePath = "approvals/{$tenantId}/{$storedName}";
|
||||
|
||||
Storage::disk('tenant')->put($storagePath, file_get_contents($file));
|
||||
|
||||
$gcsUri = null;
|
||||
$gcsObjectName = null;
|
||||
if ($gcs->isAvailable()) {
|
||||
$gcsObjectName = $storagePath;
|
||||
$gcsUri = $gcs->upload($file->getRealPath(), $gcsObjectName);
|
||||
}
|
||||
|
||||
$fileRecord = File::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'document_type' => 'approval_attachment',
|
||||
'original_name' => $file->getClientOriginalName(),
|
||||
'stored_name' => $storedName,
|
||||
'file_path' => $storagePath,
|
||||
'mime_type' => $file->getMimeType(),
|
||||
'file_size' => $file->getSize(),
|
||||
'file_type' => strtolower($file->getClientOriginalExtension()),
|
||||
'gcs_object_name' => $gcsObjectName,
|
||||
'gcs_uri' => $gcsUri,
|
||||
'is_temp' => true,
|
||||
'uploaded_by' => auth()->id(),
|
||||
'created_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'id' => $fileRecord->id,
|
||||
'name' => $fileRecord->original_name,
|
||||
'size' => $fileRecord->file_size,
|
||||
'mime_type' => $fileRecord->mime_type,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 첨부파일 삭제
|
||||
*/
|
||||
public function deleteFile(int $fileId): JsonResponse
|
||||
{
|
||||
$file = File::where('id', $fileId)
|
||||
->where('uploaded_by', auth()->id())
|
||||
->first();
|
||||
|
||||
if (! $file) {
|
||||
return response()->json(['success' => false, 'message' => '파일을 찾을 수 없습니다.'], 404);
|
||||
}
|
||||
|
||||
if ($file->existsInStorage()) {
|
||||
Storage::disk('tenant')->delete($file->file_path);
|
||||
}
|
||||
|
||||
$file->forceDelete();
|
||||
|
||||
return response()->json(['success' => true, 'message' => '파일이 삭제되었습니다.']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 첨부파일 다운로드
|
||||
*/
|
||||
public function downloadFile(int $fileId)
|
||||
{
|
||||
$file = File::findOrFail($fileId);
|
||||
|
||||
if (Storage::disk('tenant')->exists($file->file_path)) {
|
||||
return Storage::disk('tenant')->download($file->file_path, $file->original_name);
|
||||
}
|
||||
|
||||
abort(404, '파일을 찾을 수 없습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
use App\Models\Approvals\ApprovalForm;
|
||||
use App\Models\Approvals\ApprovalLine;
|
||||
use App\Models\Approvals\ApprovalStep;
|
||||
use App\Models\Boards\File;
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
@@ -138,6 +139,11 @@ public function createApproval(array $data): Approval
|
||||
$this->saveApprovalSteps($approval, $data['steps']);
|
||||
}
|
||||
|
||||
// 첨부파일 연결
|
||||
if (! empty($data['attachment_file_ids'])) {
|
||||
$this->linkAttachments($approval, $data['attachment_file_ids']);
|
||||
}
|
||||
|
||||
return $approval->load(['form', 'drafter', 'steps.approver']);
|
||||
});
|
||||
}
|
||||
@@ -170,6 +176,11 @@ public function updateApproval(int $id, array $data): Approval
|
||||
$this->saveApprovalSteps($approval, $data['steps']);
|
||||
}
|
||||
|
||||
// 첨부파일 갱신
|
||||
if (array_key_exists('attachment_file_ids', $data)) {
|
||||
$this->linkAttachments($approval, $data['attachment_file_ids'] ?? []);
|
||||
}
|
||||
|
||||
return $approval->load(['form', 'drafter', 'steps.approver']);
|
||||
});
|
||||
}
|
||||
@@ -669,6 +680,37 @@ public function saveApprovalSteps(Approval $approval, array $steps): void
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 첨부파일 연결
|
||||
*/
|
||||
public function linkAttachments(Approval $approval, array $fileIds): void
|
||||
{
|
||||
$attachments = [];
|
||||
|
||||
if (! empty($fileIds)) {
|
||||
$files = File::whereIn('id', $fileIds)
|
||||
->where('uploaded_by', auth()->id())
|
||||
->get();
|
||||
|
||||
foreach ($files as $file) {
|
||||
$attachments[] = [
|
||||
'id' => $file->id,
|
||||
'name' => $file->original_name,
|
||||
'size' => $file->file_size,
|
||||
'mime_type' => $file->mime_type,
|
||||
];
|
||||
|
||||
$file->update([
|
||||
'is_temp' => false,
|
||||
'document_id' => $approval->id,
|
||||
'document_type' => 'approval',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$approval->update(['attachments' => $attachments]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 미처리 건수 (뱃지용)
|
||||
*/
|
||||
|
||||
@@ -432,10 +432,12 @@ function applyBodyTemplate(formId) {
|
||||
}
|
||||
|
||||
let expenseContent = {};
|
||||
let attachmentFileIds = [];
|
||||
if (isExpenseForm) {
|
||||
const expenseEl = document.getElementById('expense-form-container');
|
||||
if (expenseEl && expenseEl._x_dataStack) {
|
||||
expenseContent = expenseEl._x_dataStack[0].getFormData();
|
||||
attachmentFileIds = expenseEl._x_dataStack[0].getFileIds();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -444,6 +446,7 @@ function applyBodyTemplate(formId) {
|
||||
title: title,
|
||||
body: isExpenseForm ? null : getBodyContent(),
|
||||
content: isExpenseForm ? expenseContent : {},
|
||||
attachment_file_ids: attachmentFileIds,
|
||||
is_urgent: document.getElementById('is_urgent').checked,
|
||||
steps: steps,
|
||||
};
|
||||
|
||||
@@ -105,8 +105,25 @@ class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-
|
||||
</div>
|
||||
|
||||
{{-- 지출결의서 전용 폼 --}}
|
||||
@php
|
||||
$existingFiles = [];
|
||||
if (!empty($approval->attachments)) {
|
||||
$fileIds = collect($approval->attachments)->pluck('id')->filter();
|
||||
if ($fileIds->isNotEmpty()) {
|
||||
$existingFiles = \App\Models\Boards\File::whereIn('id', $fileIds)
|
||||
->get()
|
||||
->map(fn($f) => [
|
||||
'id' => $f->id,
|
||||
'name' => $f->original_name,
|
||||
'size' => $f->file_size,
|
||||
'mime_type' => $f->mime_type,
|
||||
])->toArray();
|
||||
}
|
||||
}
|
||||
@endphp
|
||||
@include('approvals.partials._expense-form', [
|
||||
'initialData' => $approval->content ?? [],
|
||||
'initialFiles' => $existingFiles,
|
||||
])
|
||||
|
||||
{{-- 액션 버튼 --}}
|
||||
@@ -480,10 +497,12 @@ function applyBodyTemplate(formId) {
|
||||
}
|
||||
|
||||
let expenseContent = {};
|
||||
let attachmentFileIds = [];
|
||||
if (isExpenseForm) {
|
||||
const expenseEl = document.getElementById('expense-form-container');
|
||||
if (expenseEl && expenseEl._x_dataStack) {
|
||||
expenseContent = expenseEl._x_dataStack[0].getFormData();
|
||||
attachmentFileIds = expenseEl._x_dataStack[0].getFileIds();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -492,6 +511,7 @@ function applyBodyTemplate(formId) {
|
||||
title: title,
|
||||
body: isExpenseForm ? null : getBodyContent(),
|
||||
content: isExpenseForm ? expenseContent : {},
|
||||
attachment_file_ids: attachmentFileIds,
|
||||
is_urgent: document.getElementById('is_urgent').checked,
|
||||
steps: steps,
|
||||
};
|
||||
|
||||
@@ -2,13 +2,16 @@
|
||||
지출결의서 전용 폼 (Alpine.js)
|
||||
Props:
|
||||
$initialData (array|null) - 기존 content JSON (edit 시)
|
||||
$initialFiles (array|null) - 기존 첨부파일 [{id, name, size, mime_type}] (edit 시)
|
||||
--}}
|
||||
@php
|
||||
$initialData = $initialData ?? [];
|
||||
$initialFiles = $initialFiles ?? [];
|
||||
$userName = auth()->user()->name ?? '';
|
||||
@endphp
|
||||
|
||||
<div id="expense-form-container" style="display: none;"
|
||||
x-data="expenseForm({{ json_encode($initialData) }})"
|
||||
x-data="expenseForm({{ json_encode($initialData) }}, '{{ $userName }}', {{ json_encode($initialFiles) }})"
|
||||
x-cloak>
|
||||
|
||||
{{-- 지출형식 --}}
|
||||
@@ -60,12 +63,6 @@ class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">제목</label>
|
||||
<input type="text" x-model="formData.subject" placeholder="지출 제목"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
</div>
|
||||
|
||||
{{-- 내역 테이블 --}}
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">내역</label>
|
||||
@@ -152,15 +149,65 @@ class="text-xs text-blue-600 hover:text-blue-800 font-medium transition">
|
||||
</div>
|
||||
|
||||
{{-- 첨부서류 메모 --}}
|
||||
<div class="mb-2">
|
||||
<div class="mb-4">
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">첨부서류</label>
|
||||
<textarea x-model="formData.attachment_memo" rows="2" placeholder="첨부서류 내역을 입력하세요"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"></textarea>
|
||||
</div>
|
||||
|
||||
{{-- 첨부파일 업로드 --}}
|
||||
<div class="mb-2">
|
||||
<label class="block text-xs font-medium text-gray-600 mb-1">첨부파일</label>
|
||||
|
||||
{{-- 드래그 앤 드롭 영역 --}}
|
||||
<div @dragover.prevent="isDragging = true"
|
||||
@dragleave.prevent="isDragging = false"
|
||||
@drop.prevent="isDragging = false; handleDrop($event)"
|
||||
:class="isDragging ? 'border-blue-500 bg-blue-50' : 'border-gray-300 bg-gray-50'"
|
||||
class="border-2 border-dashed rounded-lg p-4 text-center transition-colors cursor-pointer"
|
||||
@click="$refs.expenseFileInput.click()">
|
||||
<input type="file" x-ref="expenseFileInput" @change="handleFileSelect($event)" multiple class="hidden"
|
||||
accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.jpg,.jpeg,.png,.gif,.zip,.rar">
|
||||
<div class="flex items-center justify-center gap-2 text-sm text-gray-500">
|
||||
<svg class="w-5 h-5" :class="isDragging ? 'text-blue-500' : 'text-gray-400'" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"/>
|
||||
</svg>
|
||||
<span>파일을 드래그하거나 <span class="text-blue-600 font-medium">클릭하여 선택</span> (최대 20MB)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 업로드 중 프로그레스 --}}
|
||||
<div x-show="fileUploading" class="mt-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex-1 bg-gray-200 rounded-full h-1.5">
|
||||
<div class="h-full bg-blue-600 rounded-full transition-all" :style="'width:' + fileProgress + '%'"></div>
|
||||
</div>
|
||||
<span class="text-xs text-gray-500 whitespace-nowrap" x-text="fileUploadStatus"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 업로드된 파일 목록 --}}
|
||||
<div x-show="uploadedFiles.length > 0" class="mt-2 space-y-1">
|
||||
<template x-for="(f, idx) in uploadedFiles" :key="f.id">
|
||||
<div class="flex items-center gap-2 px-3 py-2 bg-gray-50 rounded-lg border border-gray-200 text-xs">
|
||||
<svg class="w-4 h-4 text-gray-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13"/>
|
||||
</svg>
|
||||
<span class="flex-1 truncate text-gray-700" x-text="f.name"></span>
|
||||
<span class="text-gray-400 shrink-0" x-text="formatFileSize(f.size)"></span>
|
||||
<button type="button" @click="removeFile(idx)" class="text-red-400 hover:text-red-600 shrink-0" title="삭제">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function expenseForm(initialData) {
|
||||
function expenseForm(initialData, authUserName, initialFiles) {
|
||||
let _keyCounter = 0;
|
||||
|
||||
function makeItem(data) {
|
||||
@@ -197,13 +244,19 @@ function makeItem(data) {
|
||||
expense_type: initialData?.expense_type || 'corporate_card',
|
||||
tax_invoice: initialData?.tax_invoice || 'normal',
|
||||
write_date: initialData?.write_date || today,
|
||||
department: initialData?.department || '',
|
||||
writer_name: initialData?.writer_name || '',
|
||||
subject: initialData?.subject || '',
|
||||
department: initialData?.department || '본사',
|
||||
writer_name: initialData?.writer_name || authUserName,
|
||||
items: items,
|
||||
attachment_memo: initialData?.attachment_memo || '',
|
||||
},
|
||||
|
||||
// 파일 업로드 상태
|
||||
isDragging: false,
|
||||
fileUploading: false,
|
||||
fileProgress: 0,
|
||||
fileUploadStatus: '',
|
||||
uploadedFiles: initialFiles || [],
|
||||
|
||||
get totalAmount() {
|
||||
return this.formData.items.reduce((sum, item) => sum + (parseInt(item.amount) || 0), 0);
|
||||
},
|
||||
@@ -227,6 +280,97 @@ function makeItem(data) {
|
||||
return parseInt(String(str).replace(/[^0-9-]/g, '')) || 0;
|
||||
},
|
||||
|
||||
formatFileSize(bytes) {
|
||||
if (!bytes) return '';
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return (bytes / 1048576).toFixed(1) + ' MB';
|
||||
},
|
||||
|
||||
handleDrop(event) {
|
||||
const files = Array.from(event.dataTransfer.files);
|
||||
this.uploadFiles(files);
|
||||
},
|
||||
|
||||
handleFileSelect(event) {
|
||||
const files = Array.from(event.target.files);
|
||||
this.uploadFiles(files);
|
||||
event.target.value = '';
|
||||
},
|
||||
|
||||
async uploadFiles(files) {
|
||||
if (this.fileUploading || files.length === 0) return;
|
||||
|
||||
const maxSize = 20 * 1024 * 1024;
|
||||
const valid = files.filter(f => {
|
||||
if (f.size > maxSize) {
|
||||
showToast(`${f.name}: 20MB를 초과합니다.`, 'warning');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
if (valid.length === 0) return;
|
||||
|
||||
this.fileUploading = true;
|
||||
this.fileProgress = 0;
|
||||
let completed = 0;
|
||||
|
||||
for (const file of valid) {
|
||||
this.fileUploadStatus = `${completed + 1}/${valid.length} 업로드 중...`;
|
||||
try {
|
||||
const result = await this.uploadSingle(file, (p) => {
|
||||
this.fileProgress = Math.round(((completed + p / 100) / valid.length) * 100);
|
||||
});
|
||||
this.uploadedFiles.push(result);
|
||||
completed++;
|
||||
} catch (e) {
|
||||
showToast(`${file.name} 업로드 실패`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
this.fileProgress = 100;
|
||||
this.fileUploadStatus = '완료';
|
||||
setTimeout(() => { this.fileUploading = false; this.fileProgress = 0; }, 800);
|
||||
},
|
||||
|
||||
uploadSingle(file, onProgress) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.upload.addEventListener('progress', (e) => {
|
||||
if (e.lengthComputable) onProgress(Math.round(e.loaded / e.total * 100));
|
||||
});
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
const res = JSON.parse(xhr.responseText);
|
||||
if (res.success) resolve(res.data);
|
||||
else reject(new Error(res.message));
|
||||
} else { reject(new Error('업로드 실패')); }
|
||||
};
|
||||
xhr.onerror = () => reject(new Error('네트워크 오류'));
|
||||
xhr.open('POST', '/api/admin/approvals/upload-file');
|
||||
xhr.setRequestHeader('X-CSRF-TOKEN', document.querySelector('meta[name="csrf-token"]').content);
|
||||
xhr.setRequestHeader('Accept', 'application/json');
|
||||
xhr.send(formData);
|
||||
});
|
||||
},
|
||||
|
||||
async removeFile(index) {
|
||||
const file = this.uploadedFiles[index];
|
||||
try {
|
||||
await fetch(`/api/admin/approvals/files/${file.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
} catch (e) { /* 삭제 실패해도 목록에서는 제거 */ }
|
||||
this.uploadedFiles.splice(index, 1);
|
||||
},
|
||||
|
||||
getFormData() {
|
||||
return {
|
||||
expense_type: this.formData.expense_type,
|
||||
@@ -234,7 +378,6 @@ function makeItem(data) {
|
||||
write_date: this.formData.write_date,
|
||||
department: this.formData.department,
|
||||
writer_name: this.formData.writer_name,
|
||||
subject: this.formData.subject,
|
||||
items: this.formData.items.map(item => ({
|
||||
date: item.date,
|
||||
description: item.description,
|
||||
@@ -249,6 +392,10 @@ function makeItem(data) {
|
||||
attachment_memo: this.formData.attachment_memo,
|
||||
};
|
||||
},
|
||||
|
||||
getFileIds() {
|
||||
return this.uploadedFiles.map(f => f.id);
|
||||
},
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -96,4 +96,26 @@
|
||||
<div class="text-sm text-gray-700 mt-0.5 whitespace-pre-wrap">{{ $content['attachment_memo'] }}</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- 첨부파일 --}}
|
||||
@if(!empty($approval->attachments))
|
||||
<div>
|
||||
<span class="text-xs text-gray-500">첨부파일</span>
|
||||
<div class="mt-1 space-y-1">
|
||||
@foreach($approval->attachments as $file)
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<svg class="w-4 h-4 text-gray-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13"/>
|
||||
</svg>
|
||||
<a href="{{ route('api.admin.approvals.download-file', $file['id']) }}" class="text-blue-600 hover:underline" target="_blank">
|
||||
{{ $file['name'] ?? '파일' }}
|
||||
</a>
|
||||
<span class="text-xs text-gray-400">
|
||||
{{ isset($file['size']) ? number_format($file['size'] / 1024, 1) . 'KB' : '' }}
|
||||
</span>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@@ -959,6 +959,9 @@
|
||||
Route::delete('/lines/{id}', [\App\Http\Controllers\Api\Admin\ApprovalApiController::class, 'destroyLine'])->name('lines.destroy');
|
||||
Route::get('/forms', [\App\Http\Controllers\Api\Admin\ApprovalApiController::class, 'forms'])->name('forms');
|
||||
Route::get('/badge-counts', [\App\Http\Controllers\Api\Admin\ApprovalApiController::class, 'badgeCounts'])->name('badge-counts');
|
||||
Route::post('/upload-file', [\App\Http\Controllers\Api\Admin\ApprovalApiController::class, 'uploadFile'])->name('upload-file');
|
||||
Route::delete('/files/{fileId}', [\App\Http\Controllers\Api\Admin\ApprovalApiController::class, 'deleteFile'])->name('delete-file');
|
||||
Route::get('/files/{fileId}/download', [\App\Http\Controllers\Api\Admin\ApprovalApiController::class, 'downloadFile'])->name('download-file');
|
||||
|
||||
// CRUD
|
||||
Route::post('/', [\App\Http\Controllers\Api\Admin\ApprovalApiController::class, 'store'])->name('store');
|
||||
|
||||
Reference in New Issue
Block a user