star-cloud/resources/views/admin/data-config/staff-cards/index.blade.php
sky121113 15b975efac [FEAT] 新增員工卡管理模組與 B012 API 文檔完善
1. 新增員工卡 (staff_cards) 與員工卡感應日誌 (staff_card_logs) 模型與遷移檔。
2. 實作 StaffCardController 與相關管理介面,支援狀態切換與 CRUD。
3. 擴充 routes/web.php 加入員工卡管理路由。
4. 更新 api-docs.php 文檔,補全 B012 (Unified Sync) 的商品同步回應範例。
5. 實作 PaymentTypeSeeder 並整合至 DatabaseSeeder 以標準化支付類型。
6. 優化銷售中心訂單標籤與倉庫機台庫存顯示邏輯。
7. 側邊欄選單加入員工卡管理入口。
8. 重構 TransactionService 以支援員工卡工作階段 (session_token) 驗證。
2026-05-05 14:06:43 +08:00

424 lines
21 KiB
PHP

@extends('layouts.admin')
@section('content')
<div class="space-y-2 pb-20" x-data="staffCardManager" data-cards="{{ json_encode($cards->items()) }}"
data-logs="{{ json_encode($logs->items()) }}" data-index-url="{{ route('admin.data-config.staff-cards.index') }}">
{{-- Page Header --}}
<x-page-header :title="__('Staff Identification Management')"
:subtitle="__('Manage employee IC cards and track usage logs.')">
<template x-if="activeTab === 'staff_list'">
<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>
</template>
</x-page-header>
{{-- Tab Navigation --}}
<x-tab-nav>
<x-tab-nav-item value="staff_list" :label="__('Staff List')" />
<x-tab-nav-item value="usage_logs" :label="__('Usage Logs')" />
</x-tab-nav>
{{-- Tab Contents --}}
{{-- Staff List Tab --}}
<div x-show="activeTab === 'staff_list'"
class="luxury-card rounded-3xl p-6 animate-luxury-in relative min-h-[300px]"
x-transition:enter="transition ease-out duration-300" x-transition:enter-start="opacity-0 translate-y-4"
x-transition:enter-end="opacity-100 translate-y-0" x-cloak>
{{-- Loading Overlay --}}
<x-luxury-spinner show="tabLoading === 'staff_list'" z-index="z-20">
<svg class="w-6 h-6 text-cyan-500 animate-pulse" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</x-luxury-spinner>
<div id="tab-staff_list-container" class="relative">
@include('admin.data-config.staff-cards.partials.tab-staff-list')
</div>
</div>
{{-- Usage Logs Tab --}}
<div x-show="activeTab === 'usage_logs'"
class="luxury-card rounded-3xl p-6 animate-luxury-in relative min-h-[300px]"
x-transition:enter="transition ease-out duration-300" x-transition:enter-start="opacity-0 translate-y-4"
x-transition:enter-end="opacity-100 translate-y-0" x-cloak>
{{-- Loading Overlay --}}
<x-luxury-spinner show="tabLoading === 'usage_logs'" z-index="z-20">
<svg class="w-6 h-6 text-cyan-500 animate-pulse" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</x-luxury-spinner>
<div id="tab-usage_logs-container" class="relative">
@include('admin.data-config.staff-cards.partials.tab-usage-logs')
</div>
</div>
{{-- Modals & Slide-overs --}}
<!-- Add/Edit Staff Modal -->
<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 x-show="isStaffModalOpen" 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="isStaffModalOpen = false"></div>
<span class="hidden sm:inline-block sm:align-middle sm:h-screen">&#8203;</span>
<div x-show="isStaffModalOpen" 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"
x-text="staffModalMode === 'create' ? '{{ __('Add Staff Card') }}' : '{{ __('Edit Staff Card') }}'">
</h3>
<button @click="isStaffModalOpen = 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="submitStaffForm" class="space-y-6">
<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="company_id" id="modal-company-id" :options="$companies"
:placeholder="__('Select Company')"
@change="staffFormFields.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]">
{{ __('Employee ID') }} <span class="text-rose-500">*</span>
</label>
<input type="text" x-model="staffFormFields.employee_id"
class="luxury-input w-full focus:ring-emerald-500/20 focus:border-emerald-500"
placeholder="{{ __('e.g., S001') }}" required>
</div>
<div class="space-y-2">
<label
class="flex items-center gap-2 text-[10px] font-black text-slate-400 uppercase tracking-[0.2em]">
{{ __('Staff Name') }} <span class="text-rose-500">*</span>
</label>
<input type="text" x-model="staffFormFields.name" class="luxury-input w-full"
placeholder="{{ __('e.g., John Doe') }}" required>
</div>
<div class="space-y-2">
<label
class="flex items-center gap-2 text-[10px] font-black text-slate-400 uppercase tracking-[0.2em]">
{{ __('IC Card UID') }} <span class="text-rose-500">*</span>
</label>
<input type="text" x-model="staffFormFields.card_uid" class="luxury-input w-full"
placeholder="{{ __('e.g., 12345678') }}" required>
</div>
<div class="space-y-2">
<label
class="flex items-center gap-2 text-[10px] font-black text-slate-400 uppercase tracking-[0.2em]">
{{ __('Notes') }}
</label>
<textarea x-model="staffFormFields.notes" class="luxury-input w-full h-24 resize-none"
placeholder="{{ __('Optional notes...') }}"></textarea>
</div>
<div class="space-y-2">
<label
class="flex items-center gap-2 text-[10px] font-black text-slate-400 uppercase tracking-[0.2em]">
{{ __('Status') }}
</label>
<div class="flex items-center gap-6 p-1">
<label class="flex items-center gap-2 cursor-pointer group">
<input type="radio" x-model="staffFormFields.status" value="active"
class="w-4 h-4 text-emerald-600 bg-slate-100 border-slate-300 focus:ring-emerald-500 focus:ring-2 dark:bg-slate-800 dark:border-slate-700">
<span
class="text-sm font-bold text-slate-600 dark:text-slate-400 group-hover:text-emerald-500 transition-colors">{{
__('Active') }}</span>
</label>
<label class="flex items-center gap-2 cursor-pointer group">
<input type="radio" x-model="staffFormFields.status" value="inactive"
class="w-4 h-4 text-rose-600 bg-slate-100 border-slate-300 focus:ring-rose-500 focus:ring-2 dark:bg-slate-800 dark:border-slate-700">
<span
class="text-sm font-bold text-slate-600 dark:text-slate-400 group-hover:text-rose-500 transition-colors">{{
__('Disabled') }}</span>
</label>
</div>
</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="isStaffModalOpen = 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"
:class="staffModalMode === 'create' ? 'bg-emerald-600 hover:bg-emerald-700 shadow-emerald-500/20' : 'bg-cyan-600 hover:bg-cyan-700 shadow-cyan-500/20'">
<span
x-text="staffModalMode === 'create' ? '{{ __('Create') }}' : '{{ __('Save Changes') }}'"></span>
</button>
</div>
</form>
</div>
</div>
</div>
{{-- Delete Confirm Modal --}}
<x-delete-confirm-modal :title="__('Confirm Deletion')"
:message="__('Are you sure you want to delete this staff card? This action cannot be undone.')" />
{{-- Status Toggle Modal --}}
<x-status-confirm-modal :title="__('Confirm Status Change')"
:message="__('Are you sure you want to change the status of this card? Disabled cards cannot be used for login.')" />
</div>
@endsection
@section('scripts')
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('staffCardManager', () => ({
activeTab: '{{ $tab }}',
tabLoading: null,
loading: false,
isStaffModalOpen: false,
isDeleteConfirmOpen: false,
isStatusConfirmOpen: false,
staffModalMode: 'create',
staffFormFields: { id: null, company_id: '', employee_id: '', name: '', card_uid: '', status: 'active', notes: '' },
deleteFormAction: '',
toggleFormAction: '',
init() {
this.bindPaginationLinks('#tab-staff_list-container', 'staff_list');
this.bindPaginationLinks('#tab-usage_logs-container', 'usage_logs');
this.$watch('activeTab', (val) => {
const url = new URL(window.location.origin + window.location.pathname);
url.searchParams.set('tab', val);
window.history.pushState({}, '', url);
});
this.$watch('loading', (val) => {
const bar = document.getElementById('top-loading-bar');
if (bar) {
if (val) bar.classList.add('loading');
else bar.classList.remove('loading');
}
});
},
async fetchTabData(tab, url = null) {
this.tabLoading = tab;
const container = document.getElementById('tab-' + tab + '-container');
if (!url) {
const form = container?.querySelector('form');
let params = new URLSearchParams();
params.set('tab', tab);
params.set('_ajax', '1');
if (form) {
const formData = new FormData(form);
formData.forEach((value, key) => {
if (value.trim() !== '') params.set(key, value);
});
}
url = `${window.location.pathname}?${params.toString()}`;
} else {
const urlObj = new URL(url, window.location.origin);
urlObj.searchParams.set('tab', tab);
urlObj.searchParams.set('_ajax', '1');
url = urlObj.toString();
}
try {
const response = await fetch(url, {
headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' }
});
const data = await response.json();
if (data.success) {
if (container) {
container.innerHTML = data.html;
if (window.Alpine) window.Alpine.initTree(container);
this.bindPaginationLinks('#tab-' + tab + '-container', tab);
}
const historyUrl = new URL(url, window.location.origin);
historyUrl.searchParams.delete('_ajax');
window.history.pushState({}, '', historyUrl);
}
} catch (error) {
console.error('Fetch error:', error);
if (window.showToast) window.showToast('{{ __("Failed to load data") }}', 'error');
} finally {
this.tabLoading = null;
}
},
bindPaginationLinks(containerSelector, tab) {
this.$nextTick(() => {
const container = document.querySelector(containerSelector);
if (!container) return;
if (window.HSStaticMethods && window.HSStaticMethods.autoInit) {
window.HSStaticMethods.autoInit();
}
// Remove old listener if exists to prevent duplicates
if (container._ajaxNavigateHandler) {
container.removeEventListener('ajax:navigate', container._ajaxNavigateHandler);
}
// Create new handler
container._ajaxNavigateHandler = (e) => {
e.preventDefault();
const url = e.detail.url;
if (url) {
this.fetchTabData(tab, url);
}
};
// Listen for the custom event emitted by luxury pagination
container.addEventListener('ajax:navigate', container._ajaxNavigateHandler);
});
},
handleFilterSubmit(tab) {
this.fetchTabData(tab);
},
openStaffModal(card = null) {
if (card) {
this.staffModalMode = 'edit';
this.staffFormFields = {
id: card.id,
company_id: card.company_id || '',
employee_id: card.employee_id,
name: card.name,
card_uid: card.card_uid,
status: card.status || 'active',
notes: card.notes
};
} else {
this.staffModalMode = 'create';
this.staffFormFields = {
id: null,
company_id: '{{ auth()->user()->company_id ?? "" }}',
employee_id: '',
name: '',
card_uid: '',
status: 'active',
notes: ''
};
}
this.isStaffModalOpen = true;
// Re-init Preline select or set value after modal opens
this.$nextTick(() => {
const select = HSSelect.getInstance('#modal-company-id', true);
if (select) {
select.setValue(this.staffFormFields.company_id || ' ');
}
});
},
async submitStaffForm() {
const url = this.staffModalMode === 'create'
? `{{ route('admin.data-config.staff-cards.store') }}`
: `{{ url('admin/data-config/staff-cards') }}/${this.staffFormFields.id}`;
const method = this.staffModalMode === 'create' ? 'POST' : 'PUT';
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content'),
'Accept': 'application/json',
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify({ ...this.staffFormFields, _method: method })
});
const data = await response.json();
if (data.success) {
this.isStaffModalOpen = false;
await this.fetchTabData('staff_list');
if (window.showToast) window.showToast(data.message, 'success');
} else {
if (window.showToast) window.showToast(data.message || 'Error', 'error');
}
} catch (error) {
console.error('Submit error:', error);
if (window.showToast) window.showToast('{{ __("Operation failed") }}', 'error');
}
},
confirmDelete(actionUrl) {
this.deleteFormAction = actionUrl;
this.isDeleteConfirmOpen = true;
},
async deleteItem() {
// This is called by the modal form if we want to use AJAX
// But the component has a standard form submit.
// We'll intercept it if we want, or just let it reload.
// Given the products example, they use fetch in submitConfirmedForm.
// Wait, delete-confirm-modal in components uses a FORM.
// I'll just let it submit and reload for now, or update it to AJAX.
},
toggleStatus(actionUrl) {
this.toggleFormAction = actionUrl;
this.isStatusConfirmOpen = true;
},
async submitConfirmedForm() {
if (this.isStatusConfirmOpen) {
this.isStatusConfirmOpen = false;
this.loading = true;
try {
const response = await fetch(this.toggleFormAction, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content'),
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
body: new URLSearchParams({ '_method': 'PATCH' })
});
const data = await response.json();
if (data.success) {
await this.fetchTabData(this.activeTab);
if (window.showToast) window.showToast(data.message, 'success');
}
} catch (error) {
console.error('Status toggle error:', error);
} finally {
this.loading = false;
}
}
}
}));
});
</script>
@endsection