Files
BarangaySystem/resources/js/Pages/PosAccessKeys.vue
2026-06-06 18:43:00 +08:00

373 lines
16 KiB
Vue

<script setup>
import { ref, onMounted, computed, h } from 'vue';
import { usePageTitle } from '../composables/Core/usePageTitle';
import { useModal } from '../composables/Core/useModal';
import { useAuth } from '../composables/Core/useAuth';
import BackButton from '../Components/Core/BackButton.vue';
import axios from 'axios';
usePageTitle('POS Access Keys');
const modal = useModal();
const { isUltimate, isSuperOperator, isOperator } = useAuth();
const isAdmin = computed(() => isUltimate.value || isSuperOperator.value || isOperator.value);
const keys = ref([]);
const stores = ref([]);
const isLoading = ref(true);
const isSaving = ref(false);
const newKey = ref({
name: '',
store_hash: '',
expires_at: ''
});
const searchQuery = ref('');
const filteredKeys = computed(() => {
if (!searchQuery.value) return keys.value;
const q = searchQuery.value.toLowerCase();
return keys.value.filter(k =>
k.name.toLowerCase().includes(q) ||
(k.store?.name || '').toLowerCase().includes(q) ||
(k.store?.owner?.name || '').toLowerCase().includes(q) ||
(k.store?.owner?.nickname || '').toLowerCase().includes(q)
);
});
const fetchKeys = async () => {
isLoading.value = true;
try {
const response = await axios.post('/api/pos/access-keys/list');
keys.value = response.data.data || [];
} catch (error) {
console.error('Failed to fetch keys:', error);
} finally {
isLoading.value = false;
}
};
const fetchStores = async () => {
try {
// Admin roles see all stores; store owner/manager see only their own
const endpoint = isAdmin.value ? '/ListStores/List/data' : '/ListStores/MyStores/data';
const response = await axios.post(endpoint);
stores.value = response.data || [];
} catch (error) {
console.error('Failed to fetch stores:', error);
}
};
const createKey = async () => {
if (!newKey.value.name || !newKey.value.store_hash) {
modal.open({ title: 'Error', body: 'Please fill in all fields' });
return;
}
isSaving.value = true;
try {
const payload = {
name: newKey.value.name,
store_hash: newKey.value.store_hash,
};
if (newKey.value.expires_at) {
payload.expires_at = newKey.value.expires_at;
}
const response = await axios.post('/api/pos/access-keys/create', payload);
if (response.data.success) {
modal.quickDismiss({ title: 'Success', body: 'Access key created' });
newKey.value = { name: '', store_hash: '', expires_at: '' };
await fetchKeys();
}
} catch (error) {
modal.open({ title: 'Error', body: 'Failed to create key' });
} finally {
isSaving.value = false;
}
};
const deleteKey = (hashkey) => {
modal.yesNoModal({
title: 'Confirm Delete',
body: 'Are you sure you want to delete this access key?',
onYes: async () => {
try {
await axios.post('/api/pos/access-keys/delete', { target: hashkey });
await fetchKeys();
} catch (error) {
modal.open({ title: 'Error', body: 'Failed to delete key' });
}
}
});
};
const toggleStatus = async (hashkey) => {
try {
await axios.post('/api/pos/access-keys/toggle', { target: hashkey });
await fetchKeys();
} catch (error) {
modal.open({ title: 'Error', body: 'Failed to update status' });
}
};
const copyToClipboard = (text) => {
navigator.clipboard.writeText(text);
modal.quickDismiss({ title: 'Copied', body: 'Access key copied to clipboard' });
};
const showQrCode = (accessKey) => {
const url = getPosUrl(accessKey);
modal.open({
title: 'POS Access QR Code',
body: h('div', { class: 'text-center p-3' }, [
h('div', { class: 'bg-white p-3 d-inline-block rounded shadow-sm border mb-3' }, [
h('img', {
src: `https://api.qrserver.com/v1/create-qr-code/?size=250x250&data=${encodeURIComponent(url)}`,
style: { width: '250px', height: '250px' },
alt: 'POS Access QR Code'
})
]),
h('div', { class: 'fw_6 text-dark mb-1' }, 'Scan to Access POS Terminal'),
h('div', { class: 'small text-muted mb-3' }, 'Use a mobile device or tablet to quickly open this terminal'),
h('div', { class: 'p-2 bg-light rounded small text-break border' }, url)
])
});
};
const getPosUrl = (accessKey) => {
const baseUrl = window.location.origin;
return `${baseUrl}/pos?key=${accessKey}`;
};
const canShare = ref(false);
const shareKey = async (accessKey, terminalName) => {
const url = getPosUrl(accessKey);
if (navigator.share) {
try {
await navigator.share({
title: 'POS Terminal Access',
text: `Scan or click to open POS Terminal: ${terminalName}`,
url: url
});
} catch (error) {
if (error.name !== 'AbortError') {
console.error('Error sharing:', error);
modal.open({ title: 'Error', body: 'Could not open share dialog.' });
}
}
}
};
const openPosInNewTab = (accessKey) => {
window.open(getPosUrl(accessKey), '_blank');
};
const isKeyExpired = (key) => {
if (!key.expires_at) return false;
return new Date(key.expires_at) < new Date();
};
const formatDate = (dateStr) => {
if (!dateStr) return 'Never';
return new Date(dateStr).toLocaleDateString('en-US', {
year: 'numeric', month: 'short', day: 'numeric',
hour: '2-digit', minute: '2-digit'
});
};
onMounted(() => {
fetchKeys();
fetchStores();
canShare.value = !!navigator.share;
});
</script>
<template>
<div class="container-fluid py-4">
<div class="d-flex align-items-center mb-4">
<BackButton />
<h2 class="mb-0 ms-3 fw_7">POS Access Key Management</h2>
</div>
<div class="row">
<!-- Create New Key -->
<div class="col-md-4 mb-4">
<div class="card shadow-sm border-0 rounded-xl">
<div class="card-header bg-white border-0 pt-4 px-4 pb-0">
<h5 class="fw_6 mb-0">Create New Terminal Key</h5>
</div>
<div class="card-body p-4">
<div class="mb-3">
<label class="form-label small fw_6 text-muted">Terminal Name (e.g. Counter 1)</label>
<input v-model="newKey.name" type="text" class="form-control rounded-pill" placeholder="Enter terminal name">
</div>
<div class="mb-3">
<label class="form-label small fw_6 text-muted">Select Store</label>
<select v-model="newKey.store_hash" class="form-select rounded-pill">
<option value="" disabled>Select a store</option>
<option v-for="store in stores" :key="store.hashkey" :value="store.hashkey">
{{ store.name }}
</option>
</select>
</div>
<div class="mb-4">
<label class="form-label small fw_6 text-muted">Expiry Date (optional)</label>
<input v-model="newKey.expires_at" type="datetime-local" class="form-control rounded-pill">
<div class="form-text small">Leave empty for no expiration</div>
</div>
<button @click="createKey" class="btn btn-primary w-100 rounded-pill py-2 fw_6" :disabled="isSaving">
{{ isSaving ? 'Creating...' : 'Generate New Key' }}
</button>
</div>
</div>
</div>
<!-- Keys List -->
<div class="col-md-8">
<div class="card shadow-sm border-0 rounded-xl mb-4">
<div class="card-body p-4">
<div class="d-flex align-items-center justify-content-between mb-0">
<h5 class="fw_6 mb-0">Active Terminal Keys</h5>
<div class="search-box position-relative" style="min-width: 250px;">
<i class="fas fa-search position-absolute top-50 start-0 translate-middle-y ms-3 text-muted opacity-50"></i>
<input v-model="searchQuery" type="text" class="form-control rounded-pill ps-5" placeholder="Search by name, store or owner...">
</div>
</div>
</div>
</div>
<div class="card shadow-sm border-0 rounded-xl">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="bg-light">
<tr>
<th class="ps-4 py-3 border-0">Terminal / Store</th>
<th class="py-3 border-0">Access Key</th>
<th class="py-3 border-0">Status</th>
<th class="py-3 border-0">Expires</th>
<th class="py-3 border-0">Last Used</th>
<th class="pe-4 py-3 border-0 text-end">Actions</th>
</tr>
</thead>
<tbody>
<tr v-if="isLoading">
<td colspan="6" class="text-center py-5">
<div class="spinner-border spinner-border-sm text-primary me-2"></div>
Loading keys...
</td>
</tr>
<tr v-else-if="filteredKeys.length === 0">
<td colspan="6" class="text-center py-5 text-muted">
{{ searchQuery ? 'No search results found.' : 'No POS access keys found.' }}
</td>
</tr>
<tr v-for="key in filteredKeys" :key="key.hashkey" :class="{ 'table-row-expired': isKeyExpired(key) }">
<td class="ps-4">
<div class="fw_6">{{ key.name }}</div>
<div class="small text-muted d-flex align-items-center">
<i class="fas fa-store me-1 opacity-50"></i> {{ key.store?.name || 'Unknown Store' }}
<template v-if="key.store?.owner">
<span class="mx-1 opacity-25">|</span>
<i class="fas fa-user-circle me-1 opacity-50"></i> {{ key.store.owner.nickname || key.store.owner.name }}
</template>
</div>
</td>
<td>
<div class="d-flex align-items-center gap-2">
<code class="bg-light px-2 py-1 rounded small">{{ key.access_key }}</code>
<button @click="copyToClipboard(key.access_key)" class="btn btn-sm btn-link p-0 text-muted" title="Copy Key">
<i class="far fa-copy"></i>
</button>
<button @click="copyToClipboard(getPosUrl(key.access_key))" class="btn btn-sm btn-link p-0 text-muted" title="Copy POS URL">
<i class="fas fa-link"></i>
</button>
<button @click="showQrCode(key.access_key)" class="btn btn-sm btn-link p-0 text-muted" title="View QR Code">
<i class="fas fa-qrcode"></i>
</button>
<button @click="openPosInNewTab(key.access_key)" class="btn btn-sm btn-link p-0 text-muted" title="Open POS Terminal">
<i class="fas fa-external-link-alt"></i>
</button>
<button v-if="canShare" @click="shareKey(key.access_key, key.name)" class="btn btn-sm btn-link p-0 text-muted" title="Share via Protocol">
<i class="fas fa-share-alt"></i>
</button>
</div>
</td>
<td>
<span @click="toggleStatus(key.hashkey)" :class="['badge rounded-pill cursor-pointer', key.status === 'active' ? 'bg-soft-success text-success' : 'bg-soft-danger text-danger']">
{{ key.status }}
</span>
<span v-if="isKeyExpired(key)" class="badge bg-warning text-dark rounded-pill ms-1">expired</span>
</td>
<td class="small text-muted">
{{ key.expires_at ? formatDate(key.expires_at) : 'No Expiry' }}
</td>
<td class="small text-muted">
{{ key.last_used_at ? formatDate(key.last_used_at) : 'Never' }}
</td>
<td class="pe-4 text-end">
<button @click="deleteKey(key.hashkey)" class="btn btn-sm btn-soft-danger rounded-circle">
<i class="fas fa-trash-alt"></i>
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="mt-4 p-4 bg-soft-info rounded-xl border border-info border-opacity-25">
<h6 class="fw_7"><i class="fas fa-info-circle me-2"></i> How to use:</h6>
<p class="small mb-0 text-muted">
Copy the <strong>POS URL</strong> and use it on the target POS machine.
The machine will be automatically logged into the POS interface for the assigned store using the generated access key.
Ensure the status is set to <strong>active</strong> for the key to work.
</p>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.rounded-xl {
border-radius: 1rem;
}
.bg-soft-success {
background-color: #e8f5e9;
}
.bg-soft-danger {
background-color: #ffebee;
}
.bg-soft-info {
background-color: #e3f2fd;
}
.text-success { color: #2e7d32; }
.text-danger { color: #c62828; }
.cursor-pointer { cursor: pointer; }
.btn-soft-danger {
background: #fff0f0;
color: #e74c3c;
border: none;
width: 32px;
height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.btn-soft-danger:hover {
background: #e74c3c;
color: white;
}
.table-row-expired {
opacity: 0.6;
background-color: #fff8e1;
}
.bg-warning {
background-color: #ffc107 !important;
}
</style>