diff --git a/app/Http/Controllers/Api/Admin/EquipmentController.php b/app/Http/Controllers/Api/Admin/EquipmentController.php index 5790b4d1..dcfbc8a6 100644 --- a/app/Http/Controllers/Api/Admin/EquipmentController.php +++ b/app/Http/Controllers/Api/Admin/EquipmentController.php @@ -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); + } + } } diff --git a/app/Models/Boards/File.php b/app/Models/Boards/File.php index cc700d39..8b86a422 100644 --- a/app/Models/Boards/File.php +++ b/app/Models/Boards/File.php @@ -56,6 +56,9 @@ class File extends Model 'deleted_by', 'created_by', 'updated_by', + // GCS fields + 'gcs_object_name', + 'gcs_uri', ]; protected $casts = [ diff --git a/app/Models/Equipment/Equipment.php b/app/Models/Equipment/Equipment.php index 3ecd6511..82210d16 100644 --- a/app/Models/Equipment/Equipment.php +++ b/app/Models/Equipment/Equipment.php @@ -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') diff --git a/app/Services/EquipmentImportService.php b/app/Services/EquipmentImportService.php new file mode 100644 index 00000000..66c6288a --- /dev/null +++ b/app/Services/EquipmentImportService.php @@ -0,0 +1,276 @@ + '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); + } +} diff --git a/app/Services/EquipmentPhotoService.php b/app/Services/EquipmentPhotoService.php new file mode 100644 index 00000000..0aff32ec --- /dev/null +++ b/app/Services/EquipmentPhotoService.php @@ -0,0 +1,134 @@ + $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(); + } + } +} diff --git a/app/Services/EquipmentService.php b/app/Services/EquipmentService.php index 2c63a59a..e90fb2d1 100644 --- a/app/Services/EquipmentService.php +++ b/app/Services/EquipmentService.php @@ -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 diff --git a/composer.json b/composer.json index 131329fe..f029a93a 100644 --- a/composer.json +++ b/composer.json @@ -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", diff --git a/composer.lock b/composer.lock index 7f7feea7..eb82bf64 100644 --- a/composer.lock +++ b/composer.lock @@ -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", diff --git a/resources/views/equipment/create.blade.php b/resources/views/equipment/create.blade.php index 476a34d0..c74618f8 100644 --- a/resources/views/equipment/create.blade.php +++ b/resources/views/equipment/create.blade.php @@ -151,9 +151,30 @@ class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none foc + +
+최대 10장, 각 10MB 이하 이미지 파일
+클릭하거나 파일을 드래그하세요
+ +
+ 지원 형식: .xlsx, .xls (최대 10MB)
+ 첫 번째 행은 헤더로 인식합니다 (설비코드, 설비명, 설비유형, 규격, 제조사, 모델명, 제조번호, 위치, 생산라인, 구입일, 설치일, 구입가격, 내용연수, 상태, 담당자, 비고)
+
클릭하거나 엑셀 파일을 드래그하세요
+ +{{ $photo->original_name }}
+