feat: [equipment] 사진 멀티 업로드(GCS) + 엑셀 Import 기능 추가
- EquipmentPhotoService: GCS 기반 사진 업로드/삭제/조회 (최대 10장) - EquipmentImportService: 엑셀 파싱 → 설비 일괄 등록 (한글 헤더 자동 매핑) - API: 사진 업로드/목록/삭제, Import 미리보기/실행 엔드포인트 - 뷰: create/edit에 드래그앤드롭 사진 업로드, show에 갤러리 표시 - import.blade.php: 3단계 Import UI (파일선택 → 미리보기 → 결과) - phpoffice/phpspreadsheet 패키지 추가
This commit is contained in:
@@ -5,6 +5,8 @@
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\StoreEquipmentRequest;
|
||||
use App\Http\Requests\UpdateEquipmentRequest;
|
||||
use App\Services\EquipmentImportService;
|
||||
use App\Services\EquipmentPhotoService;
|
||||
use App\Services\EquipmentService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -12,7 +14,9 @@
|
||||
class EquipmentController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private EquipmentService $equipmentService
|
||||
private EquipmentService $equipmentService,
|
||||
private EquipmentPhotoService $photoService,
|
||||
private EquipmentImportService $importService,
|
||||
) {}
|
||||
|
||||
public function index(Request $request)
|
||||
@@ -138,4 +142,119 @@ public function templates(int $id): JsonResponse
|
||||
'data' => $equipment->inspectionTemplates,
|
||||
]);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 사진 관리
|
||||
// =========================================================================
|
||||
|
||||
public function uploadPhotos(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'photos' => 'required|array|min:1|max:10',
|
||||
'photos.*' => 'required|image|max:10240',
|
||||
]);
|
||||
|
||||
$equipment = $this->equipmentService->getEquipmentById($id);
|
||||
|
||||
if (! $equipment) {
|
||||
return response()->json(['success' => false, 'message' => '설비를 찾을 수 없습니다.'], 404);
|
||||
}
|
||||
|
||||
$result = $this->photoService->uploadPhotos($equipment, $request->file('photos'));
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => "{$result['uploaded']}장 업로드 완료",
|
||||
'data' => [
|
||||
'uploaded' => $result['uploaded'],
|
||||
'errors' => $result['errors'],
|
||||
'photos' => $this->photoService->getPhotoUrls($equipment),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function photos(int $id): JsonResponse
|
||||
{
|
||||
$equipment = $this->equipmentService->getEquipmentById($id);
|
||||
|
||||
if (! $equipment) {
|
||||
return response()->json(['success' => false, 'message' => '설비를 찾을 수 없습니다.'], 404);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $this->photoService->getPhotoUrls($equipment),
|
||||
]);
|
||||
}
|
||||
|
||||
public function deletePhoto(int $id, int $fileId): JsonResponse
|
||||
{
|
||||
$equipment = $this->equipmentService->getEquipmentById($id);
|
||||
|
||||
if (! $equipment) {
|
||||
return response()->json(['success' => false, 'message' => '설비를 찾을 수 없습니다.'], 404);
|
||||
}
|
||||
|
||||
$deleted = $this->photoService->deletePhoto($equipment, $fileId);
|
||||
|
||||
if (! $deleted) {
|
||||
return response()->json(['success' => false, 'message' => '사진을 찾을 수 없습니다.'], 404);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => '사진이 삭제되었습니다.',
|
||||
]);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 엑셀 Import
|
||||
// =========================================================================
|
||||
|
||||
public function importPreview(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'file' => 'required|file|mimes:xlsx,xls|max:10240',
|
||||
]);
|
||||
|
||||
try {
|
||||
$result = $this->importService->preview($request->file('file')->getRealPath());
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => $result,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage(),
|
||||
], 400);
|
||||
}
|
||||
}
|
||||
|
||||
public function importExecute(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'file' => 'required|file|mimes:xlsx,xls|max:10240',
|
||||
'duplicate_action' => 'in:skip,overwrite',
|
||||
]);
|
||||
|
||||
try {
|
||||
$result = $this->importService->import(
|
||||
$request->file('file')->getRealPath(),
|
||||
['duplicate_action' => $request->input('duplicate_action', 'skip')]
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => "Import 완료: 성공 {$result['success']}건, 실패 {$result['failed']}건, 건너뜀 {$result['skipped']}건",
|
||||
'data' => $result,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage(),
|
||||
], 400);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,9 @@ class File extends Model
|
||||
'deleted_by',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
// GCS fields
|
||||
'gcs_object_name',
|
||||
'gcs_uri',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Models\Equipment;
|
||||
|
||||
use App\Models\Boards\File;
|
||||
use App\Traits\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
@@ -70,6 +71,13 @@ public function repairs(): HasMany
|
||||
return $this->hasMany(EquipmentRepair::class, 'equipment_id');
|
||||
}
|
||||
|
||||
public function photos(): HasMany
|
||||
{
|
||||
return $this->hasMany(File::class, 'document_id')
|
||||
->where('document_type', 'equipment')
|
||||
->orderBy('id');
|
||||
}
|
||||
|
||||
public function processes(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(\App\Models\Process::class, 'equipment_process')
|
||||
|
||||
276
app/Services/EquipmentImportService.php
Normal file
276
app/Services/EquipmentImportService.php
Normal file
@@ -0,0 +1,276 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Equipment\Equipment;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
|
||||
class EquipmentImportService
|
||||
{
|
||||
private const HEADER_MAP = [
|
||||
'설비코드' => 'equipment_code',
|
||||
'설비명' => 'name',
|
||||
'설비유형' => 'equipment_type',
|
||||
'규격' => 'specification',
|
||||
'제조사' => 'manufacturer',
|
||||
'모델명' => 'model_name',
|
||||
'제조번호' => 'serial_no',
|
||||
'위치' => 'location',
|
||||
'생산라인' => 'production_line',
|
||||
'구입일' => 'purchase_date',
|
||||
'설치일' => 'install_date',
|
||||
'구입가격' => 'purchase_price',
|
||||
'내용연수' => 'useful_life',
|
||||
'상태' => 'status',
|
||||
'담당자' => 'manager_name',
|
||||
'비고' => 'memo',
|
||||
// 영문 매핑 (호환)
|
||||
'equipment_code' => 'equipment_code',
|
||||
'name' => 'name',
|
||||
'equipment_type' => 'equipment_type',
|
||||
'specification' => 'specification',
|
||||
'manufacturer' => 'manufacturer',
|
||||
'model_name' => 'model_name',
|
||||
'serial_no' => 'serial_no',
|
||||
'location' => 'location',
|
||||
'production_line' => 'production_line',
|
||||
'purchase_date' => 'purchase_date',
|
||||
'install_date' => 'install_date',
|
||||
'purchase_price' => 'purchase_price',
|
||||
'useful_life' => 'useful_life',
|
||||
'status' => 'status',
|
||||
'manager_name' => 'manager_name',
|
||||
'memo' => 'memo',
|
||||
];
|
||||
|
||||
private const STATUS_MAP = [
|
||||
'가동' => 'active',
|
||||
'사용' => 'active',
|
||||
'정상' => 'active',
|
||||
'유휴' => 'idle',
|
||||
'대기' => 'idle',
|
||||
'폐기' => 'disposed',
|
||||
'처분' => 'disposed',
|
||||
'active' => 'active',
|
||||
'idle' => 'idle',
|
||||
'disposed' => 'disposed',
|
||||
];
|
||||
|
||||
/**
|
||||
* 엑셀 파일 미리보기 (헤더 + 첫 10행)
|
||||
*/
|
||||
public function preview(string $filePath): array
|
||||
{
|
||||
$spreadsheet = IOFactory::load($filePath);
|
||||
$sheet = $spreadsheet->getActiveSheet();
|
||||
$rows = $sheet->toArray(null, true, true, true);
|
||||
|
||||
if (empty($rows)) {
|
||||
throw new \RuntimeException('빈 파일입니다.');
|
||||
}
|
||||
|
||||
// 첫 행 = 헤더
|
||||
$headerRow = array_shift($rows);
|
||||
$headers = $this->mapHeaders($headerRow);
|
||||
|
||||
if (empty($headers)) {
|
||||
throw new \RuntimeException('인식 가능한 헤더가 없습니다. 설비코드, 설비명 등의 헤더가 필요합니다.');
|
||||
}
|
||||
|
||||
// 미리보기 10행
|
||||
$previewRows = [];
|
||||
$count = 0;
|
||||
foreach ($rows as $row) {
|
||||
if ($count >= 10) {
|
||||
break;
|
||||
}
|
||||
$mapped = $this->mapRow($row, $headers);
|
||||
if ($mapped) {
|
||||
$previewRows[] = $mapped;
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'headers' => array_values($headers),
|
||||
'original_headers' => array_values(array_filter($headerRow)),
|
||||
'preview' => $previewRows,
|
||||
'total_rows' => count($rows),
|
||||
'mapped_columns' => count($headers),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 엑셀 데이터 일괄 등록
|
||||
*/
|
||||
public function import(string $filePath, array $options = []): array
|
||||
{
|
||||
$duplicateAction = $options['duplicate_action'] ?? 'skip';
|
||||
$tenantId = session('selected_tenant_id', 1);
|
||||
|
||||
$spreadsheet = IOFactory::load($filePath);
|
||||
$sheet = $spreadsheet->getActiveSheet();
|
||||
$rows = $sheet->toArray(null, true, true, true);
|
||||
|
||||
if (empty($rows)) {
|
||||
throw new \RuntimeException('빈 파일입니다.');
|
||||
}
|
||||
|
||||
$headerRow = array_shift($rows);
|
||||
$headers = $this->mapHeaders($headerRow);
|
||||
|
||||
$result = ['success' => 0, 'failed' => 0, 'skipped' => 0, 'errors' => []];
|
||||
|
||||
foreach ($rows as $rowNum => $row) {
|
||||
$mapped = $this->mapRow($row, $headers);
|
||||
|
||||
if (! $mapped || empty($mapped['equipment_code'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$existing = Equipment::where('tenant_id', $tenantId)
|
||||
->where('equipment_code', $mapped['equipment_code'])
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
if ($duplicateAction === 'overwrite') {
|
||||
$this->updateEquipment($existing, $mapped);
|
||||
$result['success']++;
|
||||
} else {
|
||||
$result['skipped']++;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->createEquipment($mapped, $tenantId);
|
||||
$result['success']++;
|
||||
} catch (\Exception $e) {
|
||||
$result['failed']++;
|
||||
$result['errors'][] = "행 ".($rowNum + 2).": {$e->getMessage()}";
|
||||
Log::warning('EquipmentImport: 행 처리 실패', [
|
||||
'row' => $rowNum + 2,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function mapHeaders(array $headerRow): array
|
||||
{
|
||||
$mapped = [];
|
||||
foreach ($headerRow as $col => $value) {
|
||||
$value = trim((string) $value);
|
||||
if (isset(self::HEADER_MAP[$value])) {
|
||||
$mapped[$col] = self::HEADER_MAP[$value];
|
||||
}
|
||||
}
|
||||
|
||||
return $mapped;
|
||||
}
|
||||
|
||||
private function mapRow(array $row, array $headers): ?array
|
||||
{
|
||||
$mapped = [];
|
||||
$hasData = false;
|
||||
|
||||
foreach ($headers as $col => $field) {
|
||||
$value = isset($row[$col]) ? trim((string) $row[$col]) : '';
|
||||
|
||||
if ($field === 'status') {
|
||||
$value = self::STATUS_MAP[mb_strtolower($value)] ?? 'active';
|
||||
}
|
||||
|
||||
if ($field === 'purchase_date' || $field === 'install_date') {
|
||||
$value = $this->parseDate($value);
|
||||
}
|
||||
|
||||
if ($field === 'purchase_price') {
|
||||
$value = $value !== '' ? (float) preg_replace('/[^0-9.]/', '', $value) : null;
|
||||
}
|
||||
|
||||
if ($field === 'useful_life') {
|
||||
$value = $value !== '' ? (int) preg_replace('/[^0-9]/', '', $value) : null;
|
||||
}
|
||||
|
||||
if ($value !== '' && $value !== null) {
|
||||
$hasData = true;
|
||||
}
|
||||
|
||||
$mapped[$field] = $value;
|
||||
}
|
||||
|
||||
return $hasData ? $mapped : null;
|
||||
}
|
||||
|
||||
private function parseDate(string $value): ?string
|
||||
{
|
||||
if (empty($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 엑셀 날짜 시리얼 번호
|
||||
if (is_numeric($value)) {
|
||||
try {
|
||||
$date = \PhpOffice\PhpSpreadsheet\Shared\Date::excelToDateTimeObject((int) $value);
|
||||
|
||||
return $date->format('Y-m-d');
|
||||
} catch (\Exception) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 일반 날짜 문자열
|
||||
foreach (['Y-m-d', 'Y/m/d', 'Y.m.d', 'Ymd'] as $format) {
|
||||
$date = \DateTime::createFromFormat($format, $value);
|
||||
if ($date) {
|
||||
return $date->format('Y-m-d');
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function createEquipment(array $data, int $tenantId): Equipment
|
||||
{
|
||||
$managerName = $data['manager_name'] ?? null;
|
||||
unset($data['manager_name']);
|
||||
|
||||
$data['tenant_id'] = $tenantId;
|
||||
$data['created_by'] = auth()->id();
|
||||
|
||||
if (! isset($data['status'])) {
|
||||
$data['status'] = 'active';
|
||||
}
|
||||
|
||||
if ($managerName) {
|
||||
$user = \App\Models\User::where('name', $managerName)->first();
|
||||
if ($user) {
|
||||
$data['manager_id'] = $user->id;
|
||||
}
|
||||
}
|
||||
|
||||
return Equipment::create($data);
|
||||
}
|
||||
|
||||
private function updateEquipment(Equipment $equipment, array $data): void
|
||||
{
|
||||
$managerName = $data['manager_name'] ?? null;
|
||||
unset($data['manager_name']);
|
||||
|
||||
$data['updated_by'] = auth()->id();
|
||||
|
||||
if ($managerName) {
|
||||
$user = \App\Models\User::where('name', $managerName)->first();
|
||||
if ($user) {
|
||||
$data['manager_id'] = $user->id;
|
||||
}
|
||||
}
|
||||
|
||||
$equipment->update($data);
|
||||
}
|
||||
}
|
||||
134
app/Services/EquipmentPhotoService.php
Normal file
134
app/Services/EquipmentPhotoService.php
Normal file
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Boards\File;
|
||||
use App\Models\Equipment\Equipment;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class EquipmentPhotoService
|
||||
{
|
||||
private const MAX_PHOTOS = 10;
|
||||
|
||||
public function __construct(
|
||||
private readonly GoogleCloudService $googleCloudService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 설비 사진 업로드 (멀티)
|
||||
*
|
||||
* @param array<UploadedFile> $files
|
||||
* @return array ['uploaded' => int, 'errors' => array]
|
||||
*/
|
||||
public function uploadPhotos(Equipment $equipment, array $files): array
|
||||
{
|
||||
$tenantId = session('selected_tenant_id');
|
||||
$currentCount = $equipment->photos()->count();
|
||||
$uploaded = 0;
|
||||
$errors = [];
|
||||
|
||||
foreach ($files as $index => $file) {
|
||||
if ($currentCount + $uploaded >= self::MAX_PHOTOS) {
|
||||
$errors[] = "최대 ".self::MAX_PHOTOS."장까지 업로드 가능합니다.";
|
||||
break;
|
||||
}
|
||||
|
||||
$extension = $file->getClientOriginalExtension();
|
||||
$timestamp = now()->format('Ymd_His');
|
||||
$objectName = "equipment-photos/{$tenantId}/{$equipment->id}/{$timestamp}_{$index}.{$extension}";
|
||||
|
||||
$tempPath = $file->getRealPath();
|
||||
$result = $this->googleCloudService->uploadToStorage($tempPath, $objectName);
|
||||
|
||||
if (! $result) {
|
||||
$errors[] = "파일 '{$file->getClientOriginalName()}' 업로드 실패";
|
||||
Log::error('EquipmentPhoto: GCS 업로드 실패', [
|
||||
'equipment_id' => $equipment->id,
|
||||
'file' => $file->getClientOriginalName(),
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
File::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'document_id' => $equipment->id,
|
||||
'document_type' => 'equipment',
|
||||
'file_path' => $objectName,
|
||||
'original_name' => $file->getClientOriginalName(),
|
||||
'display_name' => $file->getClientOriginalName(),
|
||||
'file_size' => $result['size'],
|
||||
'mime_type' => $file->getMimeType(),
|
||||
'file_type' => 'image',
|
||||
'gcs_object_name' => $objectName,
|
||||
'gcs_uri' => $result['uri'],
|
||||
'uploaded_by' => Auth::id(),
|
||||
'is_temp' => false,
|
||||
]);
|
||||
|
||||
$uploaded++;
|
||||
}
|
||||
|
||||
return ['uploaded' => $uploaded, 'errors' => $errors];
|
||||
}
|
||||
|
||||
/**
|
||||
* 설비 사진 삭제
|
||||
*/
|
||||
public function deletePhoto(Equipment $equipment, int $fileId): bool
|
||||
{
|
||||
$file = $equipment->photos()->where('id', $fileId)->first();
|
||||
|
||||
if (! $file) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($file->gcs_object_name) {
|
||||
$this->googleCloudService->deleteFromStorage($file->gcs_object_name);
|
||||
}
|
||||
|
||||
$file->forceDelete();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 설비 사진 목록 (Signed URL 포함)
|
||||
*/
|
||||
public function getPhotoUrls(Equipment $equipment): array
|
||||
{
|
||||
$photos = $equipment->photos()->get();
|
||||
$bucket = config('services.google.storage_bucket');
|
||||
|
||||
return $photos->map(function ($photo) use ($bucket) {
|
||||
$url = null;
|
||||
if ($photo->gcs_object_name && $bucket) {
|
||||
$url = "https://storage.googleapis.com/{$bucket}/{$photo->gcs_object_name}";
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $photo->id,
|
||||
'original_name' => $photo->original_name,
|
||||
'file_size' => $photo->file_size,
|
||||
'mime_type' => $photo->mime_type,
|
||||
'url' => $url,
|
||||
'created_at' => $photo->created_at?->format('Y-m-d H:i'),
|
||||
];
|
||||
})->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* 설비의 모든 사진 삭제 (설비 삭제 시)
|
||||
*/
|
||||
public function deleteAllPhotos(Equipment $equipment): void
|
||||
{
|
||||
foreach ($equipment->photos as $photo) {
|
||||
if ($photo->gcs_object_name) {
|
||||
$this->googleCloudService->deleteFromStorage($photo->gcs_object_name);
|
||||
}
|
||||
$photo->forceDelete();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,7 @@ public function getEquipments(array $filters = [], int $perPage = 20): LengthAwa
|
||||
|
||||
public function getEquipmentById(int $id): ?Equipment
|
||||
{
|
||||
return Equipment::with(['manager', 'inspectionTemplates', 'repairs', 'processes'])->find($id);
|
||||
return Equipment::with(['manager', 'inspectionTemplates', 'repairs', 'processes', 'photos'])->find($id);
|
||||
}
|
||||
|
||||
public function createEquipment(array $data): Equipment
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"laravel/framework": "^12.0",
|
||||
"laravel/sanctum": "^4.2",
|
||||
"laravel/tinker": "^2.10.1",
|
||||
"phpoffice/phpspreadsheet": "^5.4",
|
||||
"setasign/fpdi": "^2.6",
|
||||
"spatie/laravel-permission": "^6.23",
|
||||
"stevebauman/purify": "^6.3",
|
||||
|
||||
375
composer.lock
generated
375
composer.lock
generated
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "83c51821a6ceb02518fc31ba200a6226",
|
||||
"content-hash": "fa477884dd95c46333bc82ce0a64e4fb",
|
||||
"packages": [
|
||||
{
|
||||
"name": "brick/math",
|
||||
@@ -135,6 +135,85 @@
|
||||
],
|
||||
"time": "2024-02-09T16:56:22+00:00"
|
||||
},
|
||||
{
|
||||
"name": "composer/pcre",
|
||||
"version": "3.3.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/composer/pcre.git",
|
||||
"reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
|
||||
"reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^7.4 || ^8.0"
|
||||
},
|
||||
"conflict": {
|
||||
"phpstan/phpstan": "<1.11.10"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpstan/phpstan": "^1.12 || ^2",
|
||||
"phpstan/phpstan-strict-rules": "^1 || ^2",
|
||||
"phpunit/phpunit": "^8 || ^9"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"phpstan": {
|
||||
"includes": [
|
||||
"extension.neon"
|
||||
]
|
||||
},
|
||||
"branch-alias": {
|
||||
"dev-main": "3.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Composer\\Pcre\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Jordi Boggiano",
|
||||
"email": "j.boggiano@seld.be",
|
||||
"homepage": "http://seld.be"
|
||||
}
|
||||
],
|
||||
"description": "PCRE wrapping library that offers type-safe preg_* replacements.",
|
||||
"keywords": [
|
||||
"PCRE",
|
||||
"preg",
|
||||
"regex",
|
||||
"regular expression"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/composer/pcre/issues",
|
||||
"source": "https://github.com/composer/pcre/tree/3.3.2"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://packagist.com",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/composer",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/composer/composer",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2024-11-12T16:29:46+00:00"
|
||||
},
|
||||
{
|
||||
"name": "darkaonline/l5-swagger",
|
||||
"version": "9.0.1",
|
||||
@@ -2426,6 +2505,191 @@
|
||||
],
|
||||
"time": "2025-12-07T16:03:21+00:00"
|
||||
},
|
||||
{
|
||||
"name": "maennchen/zipstream-php",
|
||||
"version": "3.2.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/maennchen/ZipStream-PHP.git",
|
||||
"reference": "682f1098a8fddbaf43edac2306a691c7ad508ec5"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/682f1098a8fddbaf43edac2306a691c7ad508ec5",
|
||||
"reference": "682f1098a8fddbaf43edac2306a691c7ad508ec5",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-mbstring": "*",
|
||||
"ext-zlib": "*",
|
||||
"php-64bit": "^8.3"
|
||||
},
|
||||
"require-dev": {
|
||||
"brianium/paratest": "^7.7",
|
||||
"ext-zip": "*",
|
||||
"friendsofphp/php-cs-fixer": "^3.86",
|
||||
"guzzlehttp/guzzle": "^7.5",
|
||||
"mikey179/vfsstream": "^1.6",
|
||||
"php-coveralls/php-coveralls": "^2.5",
|
||||
"phpunit/phpunit": "^12.0",
|
||||
"vimeo/psalm": "^6.0"
|
||||
},
|
||||
"suggest": {
|
||||
"guzzlehttp/psr7": "^2.4",
|
||||
"psr/http-message": "^2.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"ZipStream\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Paul Duncan",
|
||||
"email": "pabs@pablotron.org"
|
||||
},
|
||||
{
|
||||
"name": "Jonatan Männchen",
|
||||
"email": "jonatan@maennchen.ch"
|
||||
},
|
||||
{
|
||||
"name": "Jesse Donat",
|
||||
"email": "donatj@gmail.com"
|
||||
},
|
||||
{
|
||||
"name": "András Kolesár",
|
||||
"email": "kolesar@kolesar.hu"
|
||||
}
|
||||
],
|
||||
"description": "ZipStream is a library for dynamically streaming dynamic zip files from PHP without writing to the disk at all on the server.",
|
||||
"keywords": [
|
||||
"stream",
|
||||
"zip"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/maennchen/ZipStream-PHP/issues",
|
||||
"source": "https://github.com/maennchen/ZipStream-PHP/tree/3.2.1"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/maennchen",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2025-12-10T09:58:31+00:00"
|
||||
},
|
||||
{
|
||||
"name": "markbaker/complex",
|
||||
"version": "3.0.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/MarkBaker/PHPComplex.git",
|
||||
"reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/MarkBaker/PHPComplex/zipball/95c56caa1cf5c766ad6d65b6344b807c1e8405b9",
|
||||
"reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^7.2 || ^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"dealerdirect/phpcodesniffer-composer-installer": "dev-master",
|
||||
"phpcompatibility/php-compatibility": "^9.3",
|
||||
"phpunit/phpunit": "^7.0 || ^8.0 || ^9.0",
|
||||
"squizlabs/php_codesniffer": "^3.7"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Complex\\": "classes/src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Mark Baker",
|
||||
"email": "mark@lange.demon.co.uk"
|
||||
}
|
||||
],
|
||||
"description": "PHP Class for working with complex numbers",
|
||||
"homepage": "https://github.com/MarkBaker/PHPComplex",
|
||||
"keywords": [
|
||||
"complex",
|
||||
"mathematics"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/MarkBaker/PHPComplex/issues",
|
||||
"source": "https://github.com/MarkBaker/PHPComplex/tree/3.0.2"
|
||||
},
|
||||
"time": "2022-12-06T16:21:08+00:00"
|
||||
},
|
||||
{
|
||||
"name": "markbaker/matrix",
|
||||
"version": "3.0.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/MarkBaker/PHPMatrix.git",
|
||||
"reference": "728434227fe21be27ff6d86621a1b13107a2562c"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/MarkBaker/PHPMatrix/zipball/728434227fe21be27ff6d86621a1b13107a2562c",
|
||||
"reference": "728434227fe21be27ff6d86621a1b13107a2562c",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^7.1 || ^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"dealerdirect/phpcodesniffer-composer-installer": "dev-master",
|
||||
"phpcompatibility/php-compatibility": "^9.3",
|
||||
"phpdocumentor/phpdocumentor": "2.*",
|
||||
"phploc/phploc": "^4.0",
|
||||
"phpmd/phpmd": "2.*",
|
||||
"phpunit/phpunit": "^7.0 || ^8.0 || ^9.0",
|
||||
"sebastian/phpcpd": "^4.0",
|
||||
"squizlabs/php_codesniffer": "^3.7"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Matrix\\": "classes/src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Mark Baker",
|
||||
"email": "mark@demon-angel.eu"
|
||||
}
|
||||
],
|
||||
"description": "PHP Class for working with matrices",
|
||||
"homepage": "https://github.com/MarkBaker/PHPMatrix",
|
||||
"keywords": [
|
||||
"mathematics",
|
||||
"matrix",
|
||||
"vector"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/MarkBaker/PHPMatrix/issues",
|
||||
"source": "https://github.com/MarkBaker/PHPMatrix/tree/3.0.1"
|
||||
},
|
||||
"time": "2022-12-02T22:17:43+00:00"
|
||||
},
|
||||
{
|
||||
"name": "monolog/monolog",
|
||||
"version": "3.9.0",
|
||||
@@ -2933,6 +3197,115 @@
|
||||
],
|
||||
"time": "2025-11-20T02:34:59+00:00"
|
||||
},
|
||||
{
|
||||
"name": "phpoffice/phpspreadsheet",
|
||||
"version": "5.4.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/PHPOffice/PhpSpreadsheet.git",
|
||||
"reference": "48f2fe37d64c2dece0ef71fb2ac55497566782af"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/48f2fe37d64c2dece0ef71fb2ac55497566782af",
|
||||
"reference": "48f2fe37d64c2dece0ef71fb2ac55497566782af",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"composer/pcre": "^1||^2||^3",
|
||||
"ext-ctype": "*",
|
||||
"ext-dom": "*",
|
||||
"ext-fileinfo": "*",
|
||||
"ext-filter": "*",
|
||||
"ext-gd": "*",
|
||||
"ext-iconv": "*",
|
||||
"ext-libxml": "*",
|
||||
"ext-mbstring": "*",
|
||||
"ext-simplexml": "*",
|
||||
"ext-xml": "*",
|
||||
"ext-xmlreader": "*",
|
||||
"ext-xmlwriter": "*",
|
||||
"ext-zip": "*",
|
||||
"ext-zlib": "*",
|
||||
"maennchen/zipstream-php": "^2.1 || ^3.0",
|
||||
"markbaker/complex": "^3.0",
|
||||
"markbaker/matrix": "^3.0",
|
||||
"php": "^8.1",
|
||||
"psr/simple-cache": "^1.0 || ^2.0 || ^3.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"dealerdirect/phpcodesniffer-composer-installer": "dev-main",
|
||||
"dompdf/dompdf": "^2.0 || ^3.0",
|
||||
"ext-intl": "*",
|
||||
"friendsofphp/php-cs-fixer": "^3.2",
|
||||
"mitoteam/jpgraph": "^10.5",
|
||||
"mpdf/mpdf": "^8.1.1",
|
||||
"phpcompatibility/php-compatibility": "^9.3",
|
||||
"phpstan/phpstan": "^1.1 || ^2.0",
|
||||
"phpstan/phpstan-deprecation-rules": "^1.0 || ^2.0",
|
||||
"phpstan/phpstan-phpunit": "^1.0 || ^2.0",
|
||||
"phpunit/phpunit": "^10.5",
|
||||
"squizlabs/php_codesniffer": "^3.7",
|
||||
"tecnickcom/tcpdf": "^6.5"
|
||||
},
|
||||
"suggest": {
|
||||
"dompdf/dompdf": "Option for rendering PDF with PDF Writer",
|
||||
"ext-intl": "PHP Internationalization Functions, required for NumberFormat Wizard and StringHelper::setLocale()",
|
||||
"mitoteam/jpgraph": "Option for rendering charts, or including charts with PDF or HTML Writers",
|
||||
"mpdf/mpdf": "Option for rendering PDF with PDF Writer",
|
||||
"tecnickcom/tcpdf": "Option for rendering PDF with PDF Writer"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"PhpOffice\\PhpSpreadsheet\\": "src/PhpSpreadsheet"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Maarten Balliauw",
|
||||
"homepage": "https://blog.maartenballiauw.be"
|
||||
},
|
||||
{
|
||||
"name": "Mark Baker",
|
||||
"homepage": "https://markbakeruk.net"
|
||||
},
|
||||
{
|
||||
"name": "Franck Lefevre",
|
||||
"homepage": "https://rootslabs.net"
|
||||
},
|
||||
{
|
||||
"name": "Erik Tilt"
|
||||
},
|
||||
{
|
||||
"name": "Adrien Crivelli"
|
||||
},
|
||||
{
|
||||
"name": "Owen Leibman"
|
||||
}
|
||||
],
|
||||
"description": "PHPSpreadsheet - Read, Create and Write Spreadsheet documents in PHP - Spreadsheet engine",
|
||||
"homepage": "https://github.com/PHPOffice/PhpSpreadsheet",
|
||||
"keywords": [
|
||||
"OpenXML",
|
||||
"excel",
|
||||
"gnumeric",
|
||||
"ods",
|
||||
"php",
|
||||
"spreadsheet",
|
||||
"xls",
|
||||
"xlsx"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues",
|
||||
"source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/5.4.0"
|
||||
},
|
||||
"time": "2026-01-11T04:52:00+00:00"
|
||||
},
|
||||
{
|
||||
"name": "phpoption/phpoption",
|
||||
"version": "1.9.4",
|
||||
|
||||
@@ -151,9 +151,30 @@ class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none foc
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 사진 업로드 (설비 저장 후 활성화) -->
|
||||
<div class="bg-white rounded-lg shadow-sm p-6" id="photoSection" style="display: none;">
|
||||
<h2 class="text-lg font-semibold text-gray-800 mb-4 pb-2 border-b">설비 사진</h2>
|
||||
<p class="text-sm text-gray-500 mb-3">최대 10장, 각 10MB 이하 이미지 파일</p>
|
||||
<div id="photoDropzone"
|
||||
class="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center cursor-pointer hover:border-blue-400 transition">
|
||||
<svg class="mx-auto h-10 w-10 text-gray-400 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
<p class="text-gray-500">클릭하거나 파일을 드래그하세요</p>
|
||||
<input type="file" id="photoInput" multiple accept="image/*" class="hidden">
|
||||
</div>
|
||||
<div id="photoUploadProgress" class="mt-3" style="display: none;">
|
||||
<div class="w-full bg-gray-200 rounded-full h-2">
|
||||
<div id="photoProgressBar" class="bg-blue-600 h-2 rounded-full transition-all" style="width: 0%"></div>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 mt-1" id="photoProgressText">업로드 중...</p>
|
||||
</div>
|
||||
<div id="photoGallery" class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3 mt-4"></div>
|
||||
</div>
|
||||
|
||||
<!-- 버튼 -->
|
||||
<div class="flex gap-3">
|
||||
<button type="submit" class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-lg transition">
|
||||
<button type="submit" id="submitBtn" class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-lg transition">
|
||||
등록
|
||||
</button>
|
||||
<a href="{{ route('equipment.index') }}"
|
||||
@@ -168,6 +189,9 @@ class="bg-gray-200 hover:bg-gray-300 text-gray-800 px-6 py-2 rounded-lg transiti
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
let createdEquipmentId = null;
|
||||
const csrfToken = '{{ csrf_token() }}';
|
||||
|
||||
document.getElementById('equipmentForm').addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -178,7 +202,7 @@ class="bg-gray-200 hover:bg-gray-300 text-gray-800 px-6 py-2 rounded-lg transiti
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
||||
'X-CSRF-TOKEN': csrfToken,
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
@@ -186,13 +210,95 @@ class="bg-gray-200 hover:bg-gray-300 text-gray-800 px-6 py-2 rounded-lg transiti
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
showToast(data.message, 'success');
|
||||
window.location.href = '{{ route("equipment.index") }}';
|
||||
createdEquipmentId = data.data.id;
|
||||
showToast('설비가 등록되었습니다. 사진을 추가할 수 있습니다.', 'success');
|
||||
document.getElementById('submitBtn').textContent = '완료 (목록으로)';
|
||||
document.getElementById('submitBtn').type = 'button';
|
||||
document.getElementById('submitBtn').onclick = function() {
|
||||
window.location.href = '{{ route("equipment.index") }}';
|
||||
};
|
||||
document.getElementById('photoSection').style.display = 'block';
|
||||
} else {
|
||||
showToast(data.message || '등록에 실패했습니다.', 'error');
|
||||
}
|
||||
})
|
||||
.catch(() => showToast('서버 오류가 발생했습니다.', 'error'));
|
||||
});
|
||||
|
||||
// 사진 드래그앤드롭
|
||||
const dropzone = document.getElementById('photoDropzone');
|
||||
const photoInput = document.getElementById('photoInput');
|
||||
|
||||
dropzone.addEventListener('click', () => photoInput.click());
|
||||
dropzone.addEventListener('dragover', (e) => { e.preventDefault(); dropzone.classList.add('border-blue-400', 'bg-blue-50'); });
|
||||
dropzone.addEventListener('dragleave', () => { dropzone.classList.remove('border-blue-400', 'bg-blue-50'); });
|
||||
dropzone.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
dropzone.classList.remove('border-blue-400', 'bg-blue-50');
|
||||
if (e.dataTransfer.files.length) uploadPhotos(e.dataTransfer.files);
|
||||
});
|
||||
photoInput.addEventListener('change', (e) => { if (e.target.files.length) uploadPhotos(e.target.files); });
|
||||
|
||||
function uploadPhotos(files) {
|
||||
if (!createdEquipmentId) return;
|
||||
const formData = new FormData();
|
||||
for (let f of files) formData.append('photos[]', f);
|
||||
|
||||
document.getElementById('photoUploadProgress').style.display = 'block';
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.upload.addEventListener('progress', (e) => {
|
||||
if (e.lengthComputable) {
|
||||
const pct = Math.round((e.loaded / e.total) * 100);
|
||||
document.getElementById('photoProgressBar').style.width = pct + '%';
|
||||
document.getElementById('photoProgressText').textContent = pct + '% 업로드 중...';
|
||||
}
|
||||
});
|
||||
xhr.addEventListener('load', () => {
|
||||
document.getElementById('photoUploadProgress').style.display = 'none';
|
||||
const res = JSON.parse(xhr.responseText);
|
||||
if (res.success) {
|
||||
showToast(res.message, 'success');
|
||||
renderPhotos(res.data.photos);
|
||||
} else {
|
||||
showToast(res.message || '업로드 실패', 'error');
|
||||
}
|
||||
photoInput.value = '';
|
||||
});
|
||||
xhr.open('POST', `/api/admin/equipment/${createdEquipmentId}/photos`);
|
||||
xhr.setRequestHeader('X-CSRF-TOKEN', csrfToken);
|
||||
xhr.setRequestHeader('Accept', 'application/json');
|
||||
xhr.send(formData);
|
||||
}
|
||||
|
||||
function renderPhotos(photos) {
|
||||
const gallery = document.getElementById('photoGallery');
|
||||
gallery.innerHTML = '';
|
||||
photos.forEach(p => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'relative group';
|
||||
div.innerHTML = `
|
||||
<img src="${p.url}" alt="${p.original_name}" class="w-full h-32 object-cover rounded-lg border">
|
||||
<button type="button" onclick="deletePhoto(${p.id}, this)"
|
||||
class="absolute top-1 right-1 bg-red-600 text-white rounded-full w-6 h-6 flex items-center justify-center text-xs opacity-0 group-hover:opacity-100 transition">X</button>
|
||||
<p class="text-xs text-gray-500 mt-1 truncate">${p.original_name}</p>
|
||||
`;
|
||||
gallery.appendChild(div);
|
||||
});
|
||||
}
|
||||
|
||||
function deletePhoto(fileId, btn) {
|
||||
if (!confirm('이 사진을 삭제하시겠습니까?')) return;
|
||||
fetch(`/api/admin/equipment/${createdEquipmentId}/photos/${fileId}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'X-CSRF-TOKEN': csrfToken, 'Accept': 'application/json' }
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
btn.closest('.relative').remove();
|
||||
showToast('사진이 삭제되었습니다.', 'success');
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
@endpush
|
||||
|
||||
@@ -159,6 +159,27 @@ class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none foc
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 사진 관리 -->
|
||||
<div class="bg-white rounded-lg shadow-sm p-6">
|
||||
<h2 class="text-lg font-semibold text-gray-800 mb-4 pb-2 border-b">설비 사진</h2>
|
||||
<p class="text-sm text-gray-500 mb-3">최대 10장, 각 10MB 이하 이미지 파일</p>
|
||||
<div id="photoDropzone"
|
||||
class="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center cursor-pointer hover:border-blue-400 transition">
|
||||
<svg class="mx-auto h-10 w-10 text-gray-400 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
<p class="text-gray-500">클릭하거나 파일을 드래그하세요</p>
|
||||
<input type="file" id="photoInput" multiple accept="image/*" class="hidden">
|
||||
</div>
|
||||
<div id="photoUploadProgress" class="mt-3" style="display: none;">
|
||||
<div class="w-full bg-gray-200 rounded-full h-2">
|
||||
<div id="photoProgressBar" class="bg-blue-600 h-2 rounded-full transition-all" style="width: 0%"></div>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 mt-1" id="photoProgressText">업로드 중...</p>
|
||||
</div>
|
||||
<div id="photoGallery" class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3 mt-4"></div>
|
||||
</div>
|
||||
|
||||
<!-- 버튼 -->
|
||||
<div class="flex gap-3">
|
||||
<button type="submit" class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-lg transition">
|
||||
@@ -178,12 +199,14 @@ class="bg-gray-200 hover:bg-gray-300 text-gray-800 px-6 py-2 rounded-lg transiti
|
||||
@push('scripts')
|
||||
<script>
|
||||
const equipmentId = {{ $id }};
|
||||
const csrfToken = '{{ csrf_token() }}';
|
||||
const fields = ['equipment_code', 'name', 'equipment_type', 'specification', 'manufacturer',
|
||||
'model_name', 'serial_no', 'location', 'production_line', 'purchase_date', 'install_date',
|
||||
'purchase_price', 'useful_life', 'status', 'manager_id', 'memo'];
|
||||
|
||||
// 설비 데이터 로드
|
||||
fetch(`/api/admin/equipment/${equipmentId}`, {
|
||||
headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' }
|
||||
headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': csrfToken }
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
@@ -195,6 +218,7 @@ class="bg-gray-200 hover:bg-gray-300 text-gray-800 px-6 py-2 rounded-lg transiti
|
||||
});
|
||||
document.getElementById('loadingState').style.display = 'none';
|
||||
document.getElementById('formContainer').style.display = 'block';
|
||||
loadPhotos();
|
||||
} else {
|
||||
showToast('설비 정보를 불러올 수 없습니다.', 'error');
|
||||
window.location.href = '{{ route("equipment.index") }}';
|
||||
@@ -210,7 +234,7 @@ class="bg-gray-200 hover:bg-gray-300 text-gray-800 px-6 py-2 rounded-lg transiti
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': '{{ csrf_token() }}',
|
||||
'X-CSRF-TOKEN': csrfToken,
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
@@ -226,5 +250,89 @@ class="bg-gray-200 hover:bg-gray-300 text-gray-800 px-6 py-2 rounded-lg transiti
|
||||
})
|
||||
.catch(() => showToast('서버 오류가 발생했습니다.', 'error'));
|
||||
});
|
||||
|
||||
// 사진 로드
|
||||
function loadPhotos() {
|
||||
fetch(`/api/admin/equipment/${equipmentId}/photos`, {
|
||||
headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': csrfToken }
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => { if (data.success) renderPhotos(data.data); });
|
||||
}
|
||||
|
||||
// 사진 드래그앤드롭
|
||||
const dropzone = document.getElementById('photoDropzone');
|
||||
const photoInput = document.getElementById('photoInput');
|
||||
|
||||
dropzone.addEventListener('click', () => photoInput.click());
|
||||
dropzone.addEventListener('dragover', (e) => { e.preventDefault(); dropzone.classList.add('border-blue-400', 'bg-blue-50'); });
|
||||
dropzone.addEventListener('dragleave', () => { dropzone.classList.remove('border-blue-400', 'bg-blue-50'); });
|
||||
dropzone.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
dropzone.classList.remove('border-blue-400', 'bg-blue-50');
|
||||
if (e.dataTransfer.files.length) uploadPhotos(e.dataTransfer.files);
|
||||
});
|
||||
photoInput.addEventListener('change', (e) => { if (e.target.files.length) uploadPhotos(e.target.files); });
|
||||
|
||||
function uploadPhotos(files) {
|
||||
const formData = new FormData();
|
||||
for (let f of files) formData.append('photos[]', f);
|
||||
|
||||
document.getElementById('photoUploadProgress').style.display = 'block';
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.upload.addEventListener('progress', (e) => {
|
||||
if (e.lengthComputable) {
|
||||
const pct = Math.round((e.loaded / e.total) * 100);
|
||||
document.getElementById('photoProgressBar').style.width = pct + '%';
|
||||
document.getElementById('photoProgressText').textContent = pct + '% 업로드 중...';
|
||||
}
|
||||
});
|
||||
xhr.addEventListener('load', () => {
|
||||
document.getElementById('photoUploadProgress').style.display = 'none';
|
||||
const res = JSON.parse(xhr.responseText);
|
||||
if (res.success) {
|
||||
showToast(res.message, 'success');
|
||||
renderPhotos(res.data.photos);
|
||||
} else {
|
||||
showToast(res.message || '업로드 실패', 'error');
|
||||
}
|
||||
photoInput.value = '';
|
||||
});
|
||||
xhr.open('POST', `/api/admin/equipment/${equipmentId}/photos`);
|
||||
xhr.setRequestHeader('X-CSRF-TOKEN', csrfToken);
|
||||
xhr.setRequestHeader('Accept', 'application/json');
|
||||
xhr.send(formData);
|
||||
}
|
||||
|
||||
function renderPhotos(photos) {
|
||||
const gallery = document.getElementById('photoGallery');
|
||||
gallery.innerHTML = '';
|
||||
photos.forEach(p => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'relative group';
|
||||
div.innerHTML = `
|
||||
<img src="${p.url}" alt="${p.original_name}" class="w-full h-32 object-cover rounded-lg border">
|
||||
<button type="button" onclick="deletePhoto(${p.id}, this)"
|
||||
class="absolute top-1 right-1 bg-red-600 text-white rounded-full w-6 h-6 flex items-center justify-center text-xs opacity-0 group-hover:opacity-100 transition">X</button>
|
||||
<p class="text-xs text-gray-500 mt-1 truncate">${p.original_name}</p>
|
||||
`;
|
||||
gallery.appendChild(div);
|
||||
});
|
||||
}
|
||||
|
||||
function deletePhoto(fileId, btn) {
|
||||
if (!confirm('이 사진을 삭제하시겠습니까?')) return;
|
||||
fetch(`/api/admin/equipment/${equipmentId}/photos/${fileId}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'X-CSRF-TOKEN': csrfToken, 'Accept': 'application/json' }
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
btn.closest('.relative').remove();
|
||||
showToast('사진이 삭제되었습니다.', 'success');
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
@endpush
|
||||
|
||||
271
resources/views/equipment/import.blade.php
Normal file
271
resources/views/equipment/import.blade.php
Normal file
@@ -0,0 +1,271 @@
|
||||
@extends('layouts.app')
|
||||
@section('title', '설비 엑셀 Import')
|
||||
@section('content')
|
||||
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-800">설비 엑셀 Import</h1>
|
||||
<a href="{{ route('equipment.index') }}" class="text-gray-600 hover:text-gray-800">
|
||||
← 목록으로
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- 단계 표시 -->
|
||||
<div class="flex items-center gap-2 mb-6">
|
||||
<span id="step1Badge" class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-blue-100 text-blue-800">1. 파일 업로드</span>
|
||||
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
|
||||
<span id="step2Badge" class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-gray-100 text-gray-500">2. 미리보기</span>
|
||||
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
|
||||
<span id="step3Badge" class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-gray-100 text-gray-500">3. 결과</span>
|
||||
</div>
|
||||
|
||||
<!-- Step 1: 파일 업로드 -->
|
||||
<div id="step1" class="bg-white rounded-lg shadow-sm p-6">
|
||||
<h2 class="text-lg font-semibold text-gray-800 mb-4">엑셀 파일 선택</h2>
|
||||
<p class="text-sm text-gray-500 mb-4">
|
||||
지원 형식: .xlsx, .xls (최대 10MB)<br>
|
||||
첫 번째 행은 헤더로 인식합니다 (설비코드, 설비명, 설비유형, 규격, 제조사, 모델명, 제조번호, 위치, 생산라인, 구입일, 설치일, 구입가격, 내용연수, 상태, 담당자, 비고)
|
||||
</p>
|
||||
|
||||
<div id="fileDropzone"
|
||||
class="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center cursor-pointer hover:border-blue-400 transition">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-400 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
||||
</svg>
|
||||
<p class="text-gray-500">클릭하거나 엑셀 파일을 드래그하세요</p>
|
||||
<input type="file" id="fileInput" accept=".xlsx,.xls" class="hidden">
|
||||
</div>
|
||||
<div id="selectedFile" class="mt-3" style="display: none;">
|
||||
<p class="text-sm text-gray-700"><span id="fileName"></span></p>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<label class="block text-sm font-semibold text-gray-700 mb-2">중복 설비코드 처리</label>
|
||||
<select id="duplicateAction" class="px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<option value="skip">건너뛰기 (기존 데이터 유지)</option>
|
||||
<option value="overwrite">덮어쓰기 (기존 데이터 갱신)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button id="previewBtn" onclick="doPreview()" disabled
|
||||
class="mt-4 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-300 text-white px-6 py-2 rounded-lg transition">
|
||||
미리보기
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: 미리보기 -->
|
||||
<div id="step2" class="bg-white rounded-lg shadow-sm p-6 mt-4" style="display: none;">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-lg font-semibold text-gray-800">미리보기</h2>
|
||||
<p class="text-sm text-gray-500" id="previewInfo"></p>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full text-sm" id="previewTable">
|
||||
<thead id="previewHead" class="bg-gray-50"></thead>
|
||||
<tbody id="previewBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="flex gap-3 mt-4">
|
||||
<button onclick="doImport()" class="bg-green-600 hover:bg-green-700 text-white px-6 py-2 rounded-lg transition">
|
||||
Import 실행
|
||||
</button>
|
||||
<button onclick="resetForm()" class="bg-gray-200 hover:bg-gray-300 text-gray-800 px-6 py-2 rounded-lg transition">
|
||||
다시 선택
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: 결과 -->
|
||||
<div id="step3" class="bg-white rounded-lg shadow-sm p-6 mt-4" style="display: none;">
|
||||
<h2 class="text-lg font-semibold text-gray-800 mb-4">Import 결과</h2>
|
||||
<div class="grid grid-cols-3 gap-4 mb-4">
|
||||
<div class="text-center p-4 bg-green-50 rounded-lg">
|
||||
<p class="text-2xl font-bold text-green-600" id="resultSuccess">0</p>
|
||||
<p class="text-sm text-green-700">성공</p>
|
||||
</div>
|
||||
<div class="text-center p-4 bg-yellow-50 rounded-lg">
|
||||
<p class="text-2xl font-bold text-yellow-600" id="resultSkipped">0</p>
|
||||
<p class="text-sm text-yellow-700">건너뜀</p>
|
||||
</div>
|
||||
<div class="text-center p-4 bg-red-50 rounded-lg">
|
||||
<p class="text-2xl font-bold text-red-600" id="resultFailed">0</p>
|
||||
<p class="text-sm text-red-700">실패</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="resultErrors" style="display: none;" class="mb-4">
|
||||
<h3 class="text-sm font-semibold text-red-700 mb-2">오류 상세</h3>
|
||||
<ul id="errorList" class="text-sm text-red-600 list-disc pl-5"></ul>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<a href="{{ route('equipment.index') }}" class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-lg transition">
|
||||
설비 목록으로
|
||||
</a>
|
||||
<button onclick="resetForm()" class="bg-gray-200 hover:bg-gray-300 text-gray-800 px-6 py-2 rounded-lg transition">
|
||||
추가 Import
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 로딩 -->
|
||||
<div id="loadingOverlay" style="display: none;" class="fixed inset-0 bg-black bg-opacity-30 flex items-center justify-center z-50">
|
||||
<div class="bg-white rounded-lg p-6 text-center">
|
||||
<div class="animate-spin rounded-full h-10 w-10 border-b-2 border-blue-600 mx-auto mb-3"></div>
|
||||
<p class="text-gray-700" id="loadingText">처리 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
const csrfToken = '{{ csrf_token() }}';
|
||||
let selectedFile = null;
|
||||
|
||||
// 파일 선택
|
||||
const dropzone = document.getElementById('fileDropzone');
|
||||
const fileInput = document.getElementById('fileInput');
|
||||
|
||||
dropzone.addEventListener('click', () => fileInput.click());
|
||||
dropzone.addEventListener('dragover', (e) => { e.preventDefault(); dropzone.classList.add('border-blue-400', 'bg-blue-50'); });
|
||||
dropzone.addEventListener('dragleave', () => { dropzone.classList.remove('border-blue-400', 'bg-blue-50'); });
|
||||
dropzone.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
dropzone.classList.remove('border-blue-400', 'bg-blue-50');
|
||||
if (e.dataTransfer.files.length) selectFile(e.dataTransfer.files[0]);
|
||||
});
|
||||
fileInput.addEventListener('change', (e) => { if (e.target.files.length) selectFile(e.target.files[0]); });
|
||||
|
||||
function selectFile(file) {
|
||||
if (!file.name.match(/\.(xlsx|xls)$/i)) {
|
||||
showToast('엑셀 파일(.xlsx, .xls)만 지원합니다.', 'error');
|
||||
return;
|
||||
}
|
||||
selectedFile = file;
|
||||
document.getElementById('fileName').textContent = file.name + ' (' + (file.size / 1024).toFixed(1) + ' KB)';
|
||||
document.getElementById('selectedFile').style.display = 'block';
|
||||
document.getElementById('previewBtn').disabled = false;
|
||||
}
|
||||
|
||||
function setStep(step) {
|
||||
['step1Badge', 'step2Badge', 'step3Badge'].forEach((id, i) => {
|
||||
const el = document.getElementById(id);
|
||||
if (i + 1 <= step) {
|
||||
el.classList.remove('bg-gray-100', 'text-gray-500');
|
||||
el.classList.add('bg-blue-100', 'text-blue-800');
|
||||
} else {
|
||||
el.classList.remove('bg-blue-100', 'text-blue-800');
|
||||
el.classList.add('bg-gray-100', 'text-gray-500');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function doPreview() {
|
||||
if (!selectedFile) return;
|
||||
document.getElementById('loadingOverlay').style.display = 'flex';
|
||||
document.getElementById('loadingText').textContent = '파일 분석 중...';
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', selectedFile);
|
||||
|
||||
fetch('/api/admin/equipment/import/preview', {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-TOKEN': csrfToken, 'Accept': 'application/json' },
|
||||
body: formData
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
document.getElementById('loadingOverlay').style.display = 'none';
|
||||
if (data.success) {
|
||||
showPreview(data.data);
|
||||
setStep(2);
|
||||
} else {
|
||||
showToast(data.message || '미리보기 실패', 'error');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
document.getElementById('loadingOverlay').style.display = 'none';
|
||||
showToast('서버 오류가 발생했습니다.', 'error');
|
||||
});
|
||||
}
|
||||
|
||||
function showPreview(data) {
|
||||
document.getElementById('step2').style.display = 'block';
|
||||
document.getElementById('previewInfo').textContent =
|
||||
`전체 ${data.total_rows}행 중 ${data.preview.length}행 미리보기 | 매핑된 컬럼: ${data.mapped_columns}개`;
|
||||
|
||||
const head = document.getElementById('previewHead');
|
||||
const body = document.getElementById('previewBody');
|
||||
head.innerHTML = '<tr>' + data.headers.map(h => `<th class="px-3 py-2 text-left font-medium text-gray-700">${h}</th>`).join('') + '</tr>';
|
||||
body.innerHTML = '';
|
||||
|
||||
data.preview.forEach(row => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.className = 'border-t';
|
||||
tr.innerHTML = data.headers.map(h => `<td class="px-3 py-2 text-gray-600">${row[h] ?? '-'}</td>`).join('');
|
||||
body.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
function doImport() {
|
||||
if (!selectedFile) return;
|
||||
document.getElementById('loadingOverlay').style.display = 'flex';
|
||||
document.getElementById('loadingText').textContent = 'Import 실행 중...';
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', selectedFile);
|
||||
formData.append('duplicate_action', document.getElementById('duplicateAction').value);
|
||||
|
||||
fetch('/api/admin/equipment/import', {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-TOKEN': csrfToken, 'Accept': 'application/json' },
|
||||
body: formData
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
document.getElementById('loadingOverlay').style.display = 'none';
|
||||
if (data.success) {
|
||||
showResult(data.data);
|
||||
setStep(3);
|
||||
showToast(data.message, 'success');
|
||||
} else {
|
||||
showToast(data.message || 'Import 실패', 'error');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
document.getElementById('loadingOverlay').style.display = 'none';
|
||||
showToast('서버 오류가 발생했습니다.', 'error');
|
||||
});
|
||||
}
|
||||
|
||||
function showResult(data) {
|
||||
document.getElementById('step2').style.display = 'none';
|
||||
document.getElementById('step3').style.display = 'block';
|
||||
document.getElementById('resultSuccess').textContent = data.success;
|
||||
document.getElementById('resultSkipped').textContent = data.skipped;
|
||||
document.getElementById('resultFailed').textContent = data.failed;
|
||||
|
||||
if (data.errors && data.errors.length > 0) {
|
||||
document.getElementById('resultErrors').style.display = 'block';
|
||||
const list = document.getElementById('errorList');
|
||||
list.innerHTML = '';
|
||||
data.errors.forEach(err => {
|
||||
const li = document.createElement('li');
|
||||
li.textContent = err;
|
||||
list.appendChild(li);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
selectedFile = null;
|
||||
fileInput.value = '';
|
||||
document.getElementById('selectedFile').style.display = 'none';
|
||||
document.getElementById('previewBtn').disabled = true;
|
||||
document.getElementById('step2').style.display = 'none';
|
||||
document.getElementById('step3').style.display = 'none';
|
||||
document.getElementById('resultErrors').style.display = 'none';
|
||||
setStep(1);
|
||||
}
|
||||
</script>
|
||||
@endpush
|
||||
@@ -5,10 +5,16 @@
|
||||
<!-- 헤더 -->
|
||||
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-800">설비 등록대장</h1>
|
||||
<a href="{{ route('equipment.create') }}"
|
||||
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg transition text-center">
|
||||
+ 설비 등록
|
||||
</a>
|
||||
<div class="flex gap-2">
|
||||
<a href="{{ route('equipment.import') }}"
|
||||
class="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg transition text-center">
|
||||
엑셀 Import
|
||||
</a>
|
||||
<a href="{{ route('equipment.create') }}"
|
||||
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg transition text-center">
|
||||
+ 설비 등록
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 필터 -->
|
||||
|
||||
@@ -85,6 +85,31 @@
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if($equipment->photos->isNotEmpty())
|
||||
<div class="bg-white rounded-lg shadow-sm p-6 mb-6">
|
||||
<h2 class="text-lg font-semibold text-gray-800 mb-4 pb-2 border-b">설비 사진</h2>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
|
||||
@foreach($equipment->photos as $photo)
|
||||
@php
|
||||
$bucket = config('services.google.storage_bucket');
|
||||
$url = $photo->gcs_object_name && $bucket
|
||||
? "https://storage.googleapis.com/{$bucket}/{$photo->gcs_object_name}"
|
||||
: null;
|
||||
@endphp
|
||||
@if($url)
|
||||
<div>
|
||||
<a href="{{ $url }}" target="_blank">
|
||||
<img src="{{ $url }}" alt="{{ $photo->original_name }}"
|
||||
class="w-full h-32 object-cover rounded-lg border hover:opacity-80 transition">
|
||||
</a>
|
||||
<p class="text-xs text-gray-500 mt-1 truncate">{{ $photo->original_name }}</p>
|
||||
</div>
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if($equipment->processes->isNotEmpty())
|
||||
<div class="bg-white rounded-lg shadow-sm p-6">
|
||||
<h2 class="text-lg font-semibold text-gray-800 mb-4 pb-2 border-b">연결된 공정</h2>
|
||||
|
||||
@@ -995,6 +995,15 @@
|
||||
Route::post('/repairs', [\App\Http\Controllers\Api\Admin\EquipmentRepairController::class, 'store'])->name('repairs.store');
|
||||
Route::put('/repairs/{id}', [\App\Http\Controllers\Api\Admin\EquipmentRepairController::class, 'update'])->whereNumber('id')->name('repairs.update');
|
||||
Route::delete('/repairs/{id}', [\App\Http\Controllers\Api\Admin\EquipmentRepairController::class, 'destroy'])->whereNumber('id')->name('repairs.destroy');
|
||||
|
||||
// 사진 관리
|
||||
Route::post('/{id}/photos', [\App\Http\Controllers\Api\Admin\EquipmentController::class, 'uploadPhotos'])->whereNumber('id')->name('photos.upload');
|
||||
Route::get('/{id}/photos', [\App\Http\Controllers\Api\Admin\EquipmentController::class, 'photos'])->whereNumber('id')->name('photos.index');
|
||||
Route::delete('/{id}/photos/{fileId}', [\App\Http\Controllers\Api\Admin\EquipmentController::class, 'deletePhoto'])->whereNumber('id')->whereNumber('fileId')->name('photos.destroy');
|
||||
|
||||
// 엑셀 Import
|
||||
Route::post('/import/preview', [\App\Http\Controllers\Api\Admin\EquipmentController::class, 'importPreview'])->name('import.preview');
|
||||
Route::post('/import', [\App\Http\Controllers\Api\Admin\EquipmentController::class, 'importExecute'])->name('import.execute');
|
||||
});
|
||||
|
||||
Route::middleware(['web', 'auth'])->prefix('meeting-logs')->name('api.admin.meeting-logs.')->group(function () {
|
||||
|
||||
Reference in New Issue
Block a user