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:
김보곤
2026-02-25 20:15:06 +09:00
parent d9c9739de1
commit 0c9d2fd441
14 changed files with 1452 additions and 13 deletions

View File

@@ -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);
}
}
}

View File

@@ -56,6 +56,9 @@ class File extends Model
'deleted_by',
'created_by',
'updated_by',
// GCS fields
'gcs_object_name',
'gcs_uri',
];
protected $casts = [

View File

@@ -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')

View 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);
}
}

View 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();
}
}
}

View File

@@ -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

View File

@@ -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
View File

@@ -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",

View File

@@ -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

View File

@@ -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

View 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">
&larr; 목록으로
</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

View File

@@ -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>
<!-- 필터 -->

View File

@@ -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>

View File

@@ -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 () {