From cc03572dd943b0d6158d9e24785b7d5b9d0ce274 Mon Sep 17 00:00:00 2001 From: sky121113 Date: Thu, 14 May 2026 14:26:35 +0800 Subject: [PATCH] =?UTF-8?q?[FEAT]=20=E5=AF=A6=E4=BD=9C=E5=93=A1=E5=B7=A5?= =?UTF-8?q?=E8=AD=98=E5=88=A5=E5=8D=A1=20Excel=20=E6=89=B9=E9=87=8F?= =?UTF-8?q?=E5=8C=AF=E5=85=A5=E5=8A=9F=E8=83=BD=E8=88=87=E7=AF=84=E4=BE=8B?= =?UTF-8?q?=E4=B8=8B=E8=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 新增 StaffCardImportService,整合 OpenSpout 5.x 處理解析與批量更新邏輯。 2. 在 StaffCardController 加入匯入與範例下載 API 端點。 3. 更新員工識別卡 index 視圖,新增「匯入 Excel」按鈕與 Alpine.js 異步匯入彈窗。 4. 註冊相關 Web 路由。 5. 補全繁中、日文、英文多語系翻譯,並修復重複鍵值。 6. 修復 OpenSpout 5.x 相容性問題 (Row::getCells -> Row::toArray)。 --- .../Controllers/Admin/StaffCardController.php | 31 ++++ app/Services/StaffCardImportService.php | 136 ++++++++++++++ composer.json | 1 + composer.lock | 95 +++++++++- lang/en.json | 11 +- lang/ja.json | 11 +- lang/zh_TW.json | 11 +- .../data-config/staff-cards/index.blade.php | 170 +++++++++++++++++- routes/web.php | 2 + 9 files changed, 456 insertions(+), 12 deletions(-) create mode 100644 app/Services/StaffCardImportService.php diff --git a/app/Http/Controllers/Admin/StaffCardController.php b/app/Http/Controllers/Admin/StaffCardController.php index 60a7ec9..a80169c 100644 --- a/app/Http/Controllers/Admin/StaffCardController.php +++ b/app/Http/Controllers/Admin/StaffCardController.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers\Admin; use App\Http\Controllers\Controller; use App\Models\StaffCard; use App\Models\StaffCardLog; +use App\Services\StaffCardImportService; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; @@ -141,4 +142,34 @@ class StaffCardController extends Controller 'message' => __('Status updated successfully'), ]); } + + public function downloadTemplate(StaffCardImportService $importService) + { + return $importService->downloadTemplate(); + } + + public function import(Request $request, StaffCardImportService $importService) + { + $request->validate([ + 'file' => 'required|file|mimes:xlsx,xls,csv|max:5120', + 'company_id' => 'nullable|exists:companies,id', + ]); + + $companyId = $request->get('company_id') ?: auth()->user()->company_id; + + if (auth()->user()->isSystemAdmin() && !$companyId) { + return response()->json([ + 'success' => false, + 'message' => __('Please select a company'), + ], 422); + } + + $results = $importService->import($request->file('file')->getRealPath(), $companyId); + + return response()->json([ + 'success' => true, + 'message' => __(':count staff cards imported successfully', ['count' => $results['success_count']]), + 'results' => $results, + ]); + } } diff --git a/app/Services/StaffCardImportService.php b/app/Services/StaffCardImportService.php new file mode 100644 index 0000000..a4fd12c --- /dev/null +++ b/app/Services/StaffCardImportService.php @@ -0,0 +1,136 @@ +open($filePath); + + $results = [ + 'success_count' => 0, + 'error_count' => 0, + 'errors' => [], + ]; + + $rowCount = 0; + foreach ($reader->getSheetIterator() as $sheet) { + foreach ($sheet->getRowIterator() as $row) { + $rowCount++; + + // Skip heading row (Row 1) + if ($rowCount === 1) { + continue; + } + + $cells = $row->toArray(); + + // Expecting: 0:employee_id, 1:name, 2:card_uid, 3:notes + $data = [ + 'employee_id' => isset($cells[0]) ? (string)$cells[0] : null, + 'name' => isset($cells[1]) ? (string)$cells[1] : null, + 'card_uid' => isset($cells[2]) ? (string)$cells[2] : null, + 'notes' => isset($cells[3]) ? (string)$cells[3] : null, + 'company_id' => $companyId, + ]; + + // Basic validation + if (empty($data['employee_id']) && empty($data['name']) && empty($data['card_uid'])) { + continue; // Skip empty rows + } + + $validator = Validator::make($data, [ + 'employee_id' => 'required|string|max:50', + 'name' => 'required|string|max:100', + 'card_uid' => 'required|string|max:100', + ]); + + if ($validator->fails()) { + $results['error_count']++; + $results['errors'][] = [ + 'row' => $rowCount, + 'messages' => $validator->errors()->all(), + ]; + continue; + } + + try { + // Update or create based on card_uid and company_id + StaffCard::updateOrCreate( + [ + 'company_id' => $data['company_id'], + 'card_uid' => $data['card_uid'], + ], + [ + 'employee_id' => $data['employee_id'], + 'name' => $data['name'], + 'notes' => $data['notes'], + 'status' => 'active', + ] + ); + $results['success_count']++; + } catch (\Exception $e) { + $results['error_count']++; + $results['errors'][] = [ + 'row' => $rowCount, + 'messages' => [$e->getMessage()], + ]; + } + } + } + + $reader->close(); + + return $results; + } + + /** + * Download Excel template for staff cards + * + * @return void + */ + public function downloadTemplate() + { + $writer = new Writer(); + $fileName = 'staff_cards_template.xlsx'; + + $writer->openToBrowser($fileName); + + // Header Row + $headerRow = Row::fromValues([ + 'Employee ID (必填)', + 'Staff Name (必填)', + 'IC Card UID (必填)', + 'Notes (選填)' + ]); + $writer->addRow($headerRow); + + // Example Row + $exampleRow = Row::fromValues([ + 'S001', + '王小明', + '12345678', + '這是備註範例' + ]); + $writer->addRow($exampleRow); + + $writer->close(); + } +} diff --git a/composer.json b/composer.json index 537e081..532cf84 100644 --- a/composer.json +++ b/composer.json @@ -14,6 +14,7 @@ "laravel/framework": "^12.0", "laravel/sanctum": "^4.3", "laravel/tinker": "^2.10.1", + "openspout/openspout": "^5.7", "simplesoftwareio/simple-qrcode": "^4.2", "spatie/laravel-permission": "^7.2" }, diff --git a/composer.lock b/composer.lock index 39b5199..f2eac5f 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": "2889e194212440faeb9f8f3dd7513795", + "content-hash": "0df2fc3ffb40fcff1794055a9ec63017", "packages": [ { "name": "bacon/bacon-qr-code", @@ -2894,6 +2894,99 @@ ], "time": "2026-02-16T23:10:27+00:00" }, + { + "name": "openspout/openspout", + "version": "v5.7.0", + "source": { + "type": "git", + "url": "https://github.com/openspout/openspout.git", + "reference": "b82aa46191802c187f67e9639ddc604b9e7a73e9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/openspout/openspout/zipball/b82aa46191802c187f67e9639ddc604b9e7a73e9", + "reference": "b82aa46191802c187f67e9639ddc604b9e7a73e9", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-filter": "*", + "ext-libxml": "*", + "ext-xmlreader": "*", + "ext-zip": "*", + "php": "~8.4.0 || ~8.5.0" + }, + "require-dev": { + "ext-fileinfo": "*", + "ext-zlib": "*", + "friendsofphp/php-cs-fixer": "^3.95.1", + "infection/infection": "^0.32.7", + "phpbench/phpbench": "^1.6.1", + "phpstan/phpstan": "^2.1.53", + "phpstan/phpstan-phpunit": "^2.0.16", + "phpstan/phpstan-strict-rules": "^2.0.10", + "phpunit/phpunit": "^13.1.7" + }, + "suggest": { + "ext-iconv": "To handle non UTF-8 CSV files (if \"php-mbstring\" is not already installed or is too limited)", + "ext-mbstring": "To handle non UTF-8 CSV files (if \"iconv\" is not already installed)" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.3.x-dev" + } + }, + "autoload": { + "psr-4": { + "OpenSpout\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Adrien Loison", + "email": "adrien@box.com" + } + ], + "description": "PHP Library to read and write spreadsheet files (CSV, XLSX and ODS), in a fast and scalable way", + "homepage": "https://github.com/openspout/openspout", + "keywords": [ + "OOXML", + "csv", + "excel", + "memory", + "odf", + "ods", + "office", + "open", + "php", + "read", + "scale", + "spreadsheet", + "stream", + "write", + "xlsx" + ], + "support": { + "issues": "https://github.com/openspout/openspout/issues", + "source": "https://github.com/openspout/openspout/tree/v5.7.0" + }, + "funding": [ + { + "url": "https://paypal.me/filippotessarotto", + "type": "custom" + }, + { + "url": "https://github.com/Slamdunk", + "type": "github" + } + ], + "time": "2026-04-29T07:42:36+00:00" + }, { "name": "phpoption/phpoption", "version": "1.9.5", diff --git a/lang/en.json b/lang/en.json index 02af7dc..2fe7faa 100644 --- a/lang/en.json +++ b/lang/en.json @@ -1964,5 +1964,14 @@ "visit_gift": "Visit Gift", "vs Yesterday": "vs Yesterday", "warehouses": "Warehouse Management", - "待填寫": "Pending" + "待填寫": "Pending", + "Import Excel": "Import Excel", + "Import Staff Cards": "Import Staff Cards", + "Excel File": "Excel File", + "Start Import": "Start Import", + "Only .xlsx, .xls, .csv files are supported (Max 5MB)": "Only .xlsx, .xls, .csv files are supported (Max 5MB)", + "Download Template": "Download Template", + "Please use our standard template to ensure data compatibility.": "Please use our standard template to ensure data compatibility.", + ":count staff cards imported successfully": ":count staff cards imported successfully", + "Import failed": "Import failed" } \ No newline at end of file diff --git a/lang/ja.json b/lang/ja.json index ec6fdc3..c341428 100644 --- a/lang/ja.json +++ b/lang/ja.json @@ -1964,5 +1964,14 @@ "visit_gift": "来店ギフト", "vs Yesterday": "昨日比", "warehouses": "倉庫管理", - "待填寫": "未記入" + "待填寫": "未記入", + "Import Excel": "Excel インポート", + "Import Staff Cards": "スタッフカードのインポート", + "Excel File": "Excel ファイル", + "Start Import": "インポート開始", + "Only .xlsx, .xls, .csv files are supported (Max 5MB)": ".xlsx, .xls, .csv ファイルのみサポートされています (最大 5MB)", + "Download Template": "テンプレートのダウンロード", + "Please use our standard template to ensure data compatibility.": "データの互換性を確保するために、標準テンプレートを使用してください。", + ":count staff cards imported successfully": ":count 枚のスタッフカードが正常にインポートされました", + "Import failed": "インポートに失敗しました" } \ No newline at end of file diff --git a/lang/zh_TW.json b/lang/zh_TW.json index 090d0b0..6a92804 100644 --- a/lang/zh_TW.json +++ b/lang/zh_TW.json @@ -2012,5 +2012,14 @@ "visit_gift": "來店禮", "vs Yesterday": "較昨日", "warehouses": "倉庫管理", - "待填寫": "待填寫" + "待填寫": "待填寫", + "Import Excel": "Excel 匯入", + "Import Staff Cards": "匯入員工識別卡", + "Excel File": "Excel 檔案", + "Start Import": "開始匯入", + "Only .xlsx, .xls, .csv files are supported (Max 5MB)": "僅支援 .xlsx, .xls, .csv 檔案 (最大 5MB)", + "Download Template": "下載範例檔", + "Please use our standard template to ensure data compatibility.": "請使用標準範例檔以確保資料相容性。", + ":count staff cards imported successfully": "成功匯入 :count 張員工識別卡", + "Import failed": "匯入失敗" } \ No newline at end of file diff --git a/resources/views/admin/data-config/staff-cards/index.blade.php b/resources/views/admin/data-config/staff-cards/index.blade.php index 2401b26..77ac1ee 100644 --- a/resources/views/admin/data-config/staff-cards/index.blade.php +++ b/resources/views/admin/data-config/staff-cards/index.blade.php @@ -8,13 +8,22 @@ @@ -65,7 +74,107 @@ {{-- Modals & Slide-overs --}} - + {{-- Import Staff Modal --}} +
+
+
+ +
+
+

+ {{ __('Import Staff Cards') }} +

+ +
+ +
+
+ @if(auth()->user()->isSystemAdmin()) +
+ + +
+ @endif + +
+ +
+ +
+ + + +
+
+

+ + + + {{ __('Only .xlsx, .xls, .csv files are supported (Max 5MB)') }} +

+
+ +
+

+ {{ __('Download Template') }} +

+

+ {{ __('Please use our standard template to ensure data compatibility.') }} +

+ + {{ __('Download Template') }} + + + + +
+
+ +
+ + +
+
+
+
+
user()->company_id ?? "" }}', file: null }, deleteFormAction: '', toggleFormAction: '', @@ -417,6 +528,49 @@ this.loading = false; } } + }, + + async submitImportForm() { + if (!this.importFormFields.file) return; + + this.loading = true; + const formData = new FormData(); + formData.append('file', this.importFormFields.file); + if (this.importFormFields.company_id) { + formData.append('company_id', this.importFormFields.company_id); + } + + try { + const response = await fetch(`{{ route('admin.data-config.staff-cards.import') }}`, { + method: 'POST', + headers: { + 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content'), + 'Accept': 'application/json', + 'X-Requested-With': 'XMLHttpRequest' + }, + body: formData + }); + const data = await response.json(); + if (data.success) { + this.isImportModalOpen = false; + this.importFormFields.file = null; + document.getElementById('import-file').value = ''; + await this.fetchTabData('staff_list'); + if (window.showToast) window.showToast(data.message, 'success'); + + if (data.results.error_count > 0) { + console.error('Import partially failed:', data.results.errors); + if (window.showToast) window.showToast(`Imported with ${data.results.error_count} errors. Check console.`, 'warning'); + } + } else { + if (window.showToast) window.showToast(data.message || 'Error', 'error'); + } + } catch (error) { + console.error('Import error:', error); + if (window.showToast) window.showToast('{{ __("Import failed") }}', 'error'); + } finally { + this.loading = false; + } } })); }); diff --git a/routes/web.php b/routes/web.php index 71325f2..12da720 100644 --- a/routes/web.php +++ b/routes/web.php @@ -192,6 +192,8 @@ Route::middleware(['auth', 'verified', 'tenant.access'])->prefix('admin')->name( Route::put('/sub-account-roles/{id}', [App\Http\Controllers\Admin\PermissionController::class, 'updateRole'])->name('sub-account-roles.update')->middleware('can:menu.data-config.sub-accounts'); Route::delete('/sub-account-roles/{id}', [App\Http\Controllers\Admin\PermissionController::class, 'destroyRole'])->name('sub-account-roles.destroy')->middleware('can:menu.data-config.sub-accounts'); Route::get('/points', [App\Http\Controllers\Admin\DataConfigController::class, 'points'])->name('points'); + Route::get('staff-cards/template', [App\Http\Controllers\Admin\StaffCardController::class, 'downloadTemplate'])->name('staff-cards.template'); + Route::post('staff-cards/import', [App\Http\Controllers\Admin\StaffCardController::class, 'import'])->name('staff-cards.import'); Route::resource('staff-cards', App\Http\Controllers\Admin\StaffCardController::class); Route::patch('staff-cards/{staffCard}/toggle-status', [App\Http\Controllers\Admin\StaffCardController::class, 'toggleStatus'])->name('staff-cards.status.toggle'); });