[FEAT] 實作員工識別卡 Excel 批量匯入功能與範例下載
1. 新增 StaffCardImportService,整合 OpenSpout 5.x 處理解析與批量更新邏輯。 2. 在 StaffCardController 加入匯入與範例下載 API 端點。 3. 更新員工識別卡 index 視圖,新增「匯入 Excel」按鈕與 Alpine.js 異步匯入彈窗。 4. 註冊相關 Web 路由。 5. 補全繁中、日文、英文多語系翻譯,並修復重複鍵值。 6. 修復 OpenSpout 5.x 相容性問題 (Row::getCells -> Row::toArray)。
This commit is contained in:
parent
87c4c281a1
commit
cc03572dd9
@ -5,6 +5,7 @@ namespace App\Http\Controllers\Admin;
|
|||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\StaffCard;
|
use App\Models\StaffCard;
|
||||||
use App\Models\StaffCardLog;
|
use App\Models\StaffCardLog;
|
||||||
|
use App\Services\StaffCardImportService;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
@ -141,4 +142,34 @@ class StaffCardController extends Controller
|
|||||||
'message' => __('Status updated successfully'),
|
'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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
136
app/Services/StaffCardImportService.php
Normal file
136
app/Services/StaffCardImportService.php
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\StaffCard;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Validator;
|
||||||
|
use OpenSpout\Reader\XLSX\Reader;
|
||||||
|
use OpenSpout\Writer\XLSX\Writer;
|
||||||
|
use OpenSpout\Common\Entity\Row;
|
||||||
|
use OpenSpout\Common\Entity\Cell;
|
||||||
|
|
||||||
|
class StaffCardImportService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Import staff cards from Excel file
|
||||||
|
*
|
||||||
|
* @param string $filePath
|
||||||
|
* @param int|null $companyId
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public function import(string $filePath, ?int $companyId = null): array
|
||||||
|
{
|
||||||
|
$reader = new Reader();
|
||||||
|
$reader->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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -14,6 +14,7 @@
|
|||||||
"laravel/framework": "^12.0",
|
"laravel/framework": "^12.0",
|
||||||
"laravel/sanctum": "^4.3",
|
"laravel/sanctum": "^4.3",
|
||||||
"laravel/tinker": "^2.10.1",
|
"laravel/tinker": "^2.10.1",
|
||||||
|
"openspout/openspout": "^5.7",
|
||||||
"simplesoftwareio/simple-qrcode": "^4.2",
|
"simplesoftwareio/simple-qrcode": "^4.2",
|
||||||
"spatie/laravel-permission": "^7.2"
|
"spatie/laravel-permission": "^7.2"
|
||||||
},
|
},
|
||||||
|
|||||||
95
composer.lock
generated
95
composer.lock
generated
@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "2889e194212440faeb9f8f3dd7513795",
|
"content-hash": "0df2fc3ffb40fcff1794055a9ec63017",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "bacon/bacon-qr-code",
|
"name": "bacon/bacon-qr-code",
|
||||||
@ -2894,6 +2894,99 @@
|
|||||||
],
|
],
|
||||||
"time": "2026-02-16T23:10:27+00:00"
|
"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",
|
"name": "phpoption/phpoption",
|
||||||
"version": "1.9.5",
|
"version": "1.9.5",
|
||||||
|
|||||||
11
lang/en.json
11
lang/en.json
@ -1964,5 +1964,14 @@
|
|||||||
"visit_gift": "Visit Gift",
|
"visit_gift": "Visit Gift",
|
||||||
"vs Yesterday": "vs Yesterday",
|
"vs Yesterday": "vs Yesterday",
|
||||||
"warehouses": "Warehouse Management",
|
"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"
|
||||||
}
|
}
|
||||||
11
lang/ja.json
11
lang/ja.json
@ -1964,5 +1964,14 @@
|
|||||||
"visit_gift": "来店ギフト",
|
"visit_gift": "来店ギフト",
|
||||||
"vs Yesterday": "昨日比",
|
"vs Yesterday": "昨日比",
|
||||||
"warehouses": "倉庫管理",
|
"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": "インポートに失敗しました"
|
||||||
}
|
}
|
||||||
@ -2012,5 +2012,14 @@
|
|||||||
"visit_gift": "來店禮",
|
"visit_gift": "來店禮",
|
||||||
"vs Yesterday": "較昨日",
|
"vs Yesterday": "較昨日",
|
||||||
"warehouses": "倉庫管理",
|
"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": "匯入失敗"
|
||||||
}
|
}
|
||||||
@ -8,13 +8,22 @@
|
|||||||
<x-page-header :title="__('Staff Identification Management')"
|
<x-page-header :title="__('Staff Identification Management')"
|
||||||
:subtitle="__('Manage employee IC cards and track usage logs.')">
|
:subtitle="__('Manage employee IC cards and track usage logs.')">
|
||||||
<template x-if="activeTab === 'staff_list'">
|
<template x-if="activeTab === 'staff_list'">
|
||||||
<button @click="openStaffModal()" type="button"
|
<div class="flex items-center gap-3">
|
||||||
class="btn-luxury-primary whitespace-nowrap flex items-center gap-2 transition-all duration-300">
|
<button @click="isImportModalOpen = true" type="button"
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
|
class="btn-luxury-secondary whitespace-nowrap flex items-center gap-2 transition-all duration-300">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
</svg>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
|
||||||
<span class="text-sm sm:text-base">{{ __('Add Staff Card') }}</span>
|
</svg>
|
||||||
</button>
|
<span class="text-sm sm:text-base">{{ __('Import Excel') }}</span>
|
||||||
|
</button>
|
||||||
|
<button @click="openStaffModal()" type="button"
|
||||||
|
class="btn-luxury-primary whitespace-nowrap flex items-center gap-2 transition-all duration-300">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||||
|
</svg>
|
||||||
|
<span class="text-sm sm:text-base">{{ __('Add Staff Card') }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</x-page-header>
|
</x-page-header>
|
||||||
|
|
||||||
@ -65,7 +74,107 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- Modals & Slide-overs --}}
|
{{-- Modals & Slide-overs --}}
|
||||||
<!-- Add/Edit Staff Modal -->
|
{{-- Import Staff Modal --}}
|
||||||
|
<div x-show="isImportModalOpen" class="fixed inset-0 z-[110] overflow-y-auto" x-cloak>
|
||||||
|
<div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
|
||||||
|
<div x-show="isImportModalOpen" x-transition:enter="ease-out duration-300"
|
||||||
|
x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
|
||||||
|
class="fixed inset-0 transition-opacity bg-slate-900/60 backdrop-blur-sm"
|
||||||
|
@click="isImportModalOpen = false"></div>
|
||||||
|
<span class="hidden sm:inline-block sm:align-middle sm:h-screen">​</span>
|
||||||
|
<div x-show="isImportModalOpen" x-transition:enter="ease-out duration-300"
|
||||||
|
x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||||
|
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
|
||||||
|
class="inline-block w-full max-w-lg p-8 my-8 overflow-hidden text-left align-middle transition-all transform bg-white dark:bg-slate-900 shadow-2xl rounded-3xl border border-slate-100 dark:border-white/10">
|
||||||
|
<div class="flex justify-between items-center mb-8">
|
||||||
|
<h3 class="text-2xl font-black text-slate-800 dark:text-white font-display tracking-tight">
|
||||||
|
{{ __('Import Staff Cards') }}
|
||||||
|
</h3>
|
||||||
|
<button @click="isImportModalOpen = false"
|
||||||
|
class="text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 transition-colors">
|
||||||
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form @submit.prevent="submitImportForm" class="space-y-6" enctype="multipart/form-data">
|
||||||
|
<div class="space-y-5 px-1">
|
||||||
|
@if(auth()->user()->isSystemAdmin())
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="flex items-center gap-2 text-[10px] font-black text-slate-400 uppercase tracking-[0.2em]">
|
||||||
|
{{ __('Affiliated Company') }} <span class="text-rose-500">*</span>
|
||||||
|
</label>
|
||||||
|
<x-searchable-select name="import_company_id" id="import-company-id" :options="$companies"
|
||||||
|
:placeholder="__('Select Company')"
|
||||||
|
@change="importFormFields.company_id = $event.target.value" required />
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="flex items-center gap-2 text-[10px] font-black text-slate-400 uppercase tracking-[0.2em]">
|
||||||
|
{{ __('Excel File') }} <span class="text-rose-500">*</span>
|
||||||
|
</label>
|
||||||
|
<div class="relative group">
|
||||||
|
<input type="file" id="import-file" @change="importFormFields.file = $event.target.files[0]"
|
||||||
|
accept=".xlsx,.xls,.csv" class="luxury-input w-full pr-12 cursor-pointer" required>
|
||||||
|
<div class="absolute right-4 top-1/2 -translate-y-1/2 text-slate-400">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-slate-400 mt-2 flex items-center gap-1">
|
||||||
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
{{ __('Only .xlsx, .xls, .csv files are supported (Max 5MB)') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-slate-50 dark:bg-white/5 rounded-2xl p-4 border border-slate-100 dark:border-white/5">
|
||||||
|
<h4 class="text-xs font-black text-slate-500 dark:text-slate-400 uppercase tracking-wider mb-2">
|
||||||
|
{{ __('Download Template') }}
|
||||||
|
</h4>
|
||||||
|
<p class="text-xs text-slate-400 mb-4 leading-relaxed">
|
||||||
|
{{ __('Please use our standard template to ensure data compatibility.') }}
|
||||||
|
</p>
|
||||||
|
<a href="{{ route('admin.data-config.staff-cards.template') }}"
|
||||||
|
class="inline-flex items-center gap-2 text-sm font-bold text-cyan-600 hover:text-cyan-700 transition-colors group">
|
||||||
|
<span>{{ __('Download Template') }}</span>
|
||||||
|
<svg class="w-4 h-4 transition-transform group-hover:translate-y-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 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>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-end gap-4 mt-12 pt-6 border-t border-slate-100 dark:border-white/5">
|
||||||
|
<button type="button" @click="isImportModalOpen = false"
|
||||||
|
class="px-6 py-2.5 text-sm font-black text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 uppercase tracking-widest transition-all">
|
||||||
|
{{ __('Cancel') }}
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="btn-luxury-primary px-10 py-3 shadow-lg bg-cyan-600 hover:bg-cyan-700 shadow-cyan-500/20"
|
||||||
|
:disabled="loading">
|
||||||
|
<template x-if="!loading">
|
||||||
|
<span>{{ __('Start Import') }}</span>
|
||||||
|
</template>
|
||||||
|
<template x-if="loading">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<svg class="animate-spin h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
<span>{{ __('Processing...') }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div x-show="isStaffModalOpen" class="fixed inset-0 z-[110] overflow-y-auto" x-cloak>
|
<div x-show="isStaffModalOpen" class="fixed inset-0 z-[110] overflow-y-auto" x-cloak>
|
||||||
<div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
|
<div class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
|
||||||
<div x-show="isStaffModalOpen" x-transition:enter="ease-out duration-300"
|
<div x-show="isStaffModalOpen" x-transition:enter="ease-out duration-300"
|
||||||
@ -201,10 +310,12 @@
|
|||||||
tabLoading: null,
|
tabLoading: null,
|
||||||
loading: false,
|
loading: false,
|
||||||
isStaffModalOpen: false,
|
isStaffModalOpen: false,
|
||||||
|
isImportModalOpen: false,
|
||||||
isDeleteConfirmOpen: false,
|
isDeleteConfirmOpen: false,
|
||||||
isStatusConfirmOpen: false,
|
isStatusConfirmOpen: false,
|
||||||
staffModalMode: 'create',
|
staffModalMode: 'create',
|
||||||
staffFormFields: { id: null, company_id: '', employee_id: '', name: '', card_uid: '', status: 'active', notes: '' },
|
staffFormFields: { id: null, company_id: '', employee_id: '', name: '', card_uid: '', status: 'active', notes: '' },
|
||||||
|
importFormFields: { company_id: '{{ auth()->user()->company_id ?? "" }}', file: null },
|
||||||
deleteFormAction: '',
|
deleteFormAction: '',
|
||||||
toggleFormAction: '',
|
toggleFormAction: '',
|
||||||
|
|
||||||
@ -417,6 +528,49 @@
|
|||||||
this.loading = false;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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::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::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('/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::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');
|
Route::patch('staff-cards/{staffCard}/toggle-status', [App\Http\Controllers\Admin\StaffCardController::class, 'toggleStatus'])->name('staff-cards.status.toggle');
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user