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

655 lines
20 KiB
Vue

<script setup>
import { ref, onMounted, computed, h } from 'vue'
import axios from 'axios'
import { useNavigate } from '../composables/Core/useNavigate'
import { useModal } from '../composables/Core/useModal'
import { useUserStore } from '../stores/user'
import { usePageTitle } from '../composables/Core/usePageTitle'
import { useUserSettings } from '../composables/useUserSettings'
import TableDensityToggle from '../Components/Core/TableDensityToggle.vue'
import SearchBar from '../Components/Core/Search/SearchBar.vue'
import usePageData from '../composables/usePageData'
import { useUserTypeLabels } from '../utils/userTypeLabels'
const { label: userTypeLabel, badgeClass: userTypeBadgeClass } = useUserTypeLabels()
const { navigate } = useNavigate()
const modal = useModal()
const userStore = useUserStore()
const { settings, updateSetting } = useUserSettings();
const { data: pageUsers, fetchPageData: fetchUsersData } = usePageData()
usePageTitle('User Management')
const users = ref([])
const loading = ref(false)
const error = ref(null)
const searchQuery = ref('')
// Password reset state
const resetPasswordUser = ref(null)
const showResetPasswordModal = ref(false)
const newPassword = ref('')
const confirmNewPassword = ref('')
const resetPasswordLoading = ref(false)
const isUltimate = computed(() => userStore.acctType === 'ult')
const getUserTypeClass = (type) => userTypeBadgeClass(type)
const getUserTypeName = (type) => {
const name = userTypeLabel(type).toUpperCase()
return name.includes(' ') ? name.replace(/ /g, '<br>') : name
}
// Initialize component
onMounted(() => {
fetchUsers()
})
// Check if a user has a null password
const isNullPasswordUser = (user) => {
return user.password_null === true || !user.password || user.password === ''
}
// Skip 91-176 (Reset methods)
// Fetch all users
const fetchUsers = async () => {
loading.value = true
error.value = null
const result = await fetchUsersData('/admin/users/list', {}, 'GET')
// Robust extraction: Handle both { users: [] } object and raw [] array
const extractedUsers = result?.data?.users || result?.data
if (extractedUsers && Array.isArray(extractedUsers)) {
users.value = extractedUsers
} else {
console.error('[UserList] Unexpected data format from /admin/users/list:', result?.data)
error.value = 'Failed to load users'
}
loading.value = false
}
// Enable user
const enableUser = (userHash) => {
modal.continueCancelModal({
title: 'Enable User',
body: 'Are you sure you want to enable this user?',
continueText: 'Enable',
continueClass: 'btn btn-success w-50 py-2 rounded-3 shadow-sm fw-bold',
onContinue: async () => {
try {
const response = await axios.post('/admin/user/enable', {
target_user: userHash
})
if (response.data) {
fetchUsers()
}
} catch (err) {
console.error('Error enabling user:', err)
modal.open({
title: 'Error',
body: 'Failed to enable user',
footer: h('button', { class: 'btn btn-primary mt-3', onClick: modal.close }, 'OK')
})
}
}
})
}
// Disable user
const disableUser = (userHash) => {
modal.continueCancelModal({
title: 'Disable User',
body: 'Are you sure you want to disable this user?',
continueText: 'Disable',
continueClass: 'btn btn-danger w-50 py-2 rounded-3 shadow-sm fw-bold',
onContinue: async () => {
try {
const response = await axios.post('/admin/user/disable', {
target_user: userHash
})
if (response.data) {
fetchUsers()
}
} catch (err) {
console.error('Error disabling user:', err)
modal.open({
title: 'Error',
body: 'Failed to disable user',
footer: h('button', { class: 'btn btn-primary mt-3', onClick: modal.close }, 'OK')
})
}
}
})
}
// View user details
const viewUser = async (userHash) => {
navigate({ page: 'EditUser', props: { id: userHash } })
}
// Navigate to create new user
const goToCreateUser = () => {
navigate({ page: 'CreateUser' })
}
// Open reset password modal
const openResetPasswordModal = (user) => {
resetPasswordUser.value = user
newPassword.value = ''
confirmNewPassword.value = ''
showResetPasswordModal.value = true
}
// Close reset password modal
const closeResetPasswordModal = () => {
showResetPasswordModal.value = false
resetPasswordUser.value = null
newPassword.value = ''
confirmNewPassword.value = ''
}
// Submit password reset
const submitPasswordReset = async () => {
// Validate user is selected before proceeding
if (!resetPasswordUser?.value) {
modal.open({
title: 'Error',
body: 'No user selected for password reset.',
footer: null
})
return
}
if (!newPassword.value || newPassword.value.length < 6) {
modal.open({
title: 'Error',
body: 'Password must be at least 6 characters.',
footer: h('button', { class: 'btn btn-primary mt-3', onClick: modal.close }, 'OK')
})
return
}
if (newPassword.value !== confirmNewPassword.value) {
modal.open({
title: 'Error',
body: 'Passwords do not match.',
footer: h('button', { class: 'btn btn-primary mt-3', onClick: modal.close }, 'OK')
})
return
}
try {
resetPasswordLoading.value = true
const response = await axios.post('/admin/user/password/reset', {
target_user: resetPasswordUser.value.hashkey,
user_new_password: newPassword.value
})
if (response.data === true || (response.data && response.data.success)) {
const userLabel = resetPasswordUser.value?.username || resetPasswordUser.value?.fullname || resetPasswordUser.value?.name || resetPasswordUser.value?.mobile_number || 'User'
closeResetPasswordModal()
modal.open({
title: 'Success',
body: `Password reset successfully for ${userLabel}`,
footer: h('button', { class: 'btn btn-primary mt-3', onClick: modal.close }, 'OK')
})
} else {
modal.open({
title: 'Error',
body: 'Failed to reset password.',
footer: h('button', { class: 'btn btn-primary mt-3', onClick: modal.close }, 'OK')
})
}
} catch (err) {
console.error('Error resetting password:', err)
const errorMessage = err.response?.data?.message || err.message || 'Failed to reset password'
modal.open({
title: 'Error',
body: `Failed to reset password: ${errorMessage}`,
footer: h('button', { class: 'btn btn-secondary mt-3', onClick: modal.close }, 'Close')
})
} finally {
resetPasswordLoading.value = false
}
}
const tableDensity = computed({
get: () => settings.value.table_density || 'comfortable',
set: (val) => updateSetting('table_density', val)
});
const tableDensityClass = computed(() => {
return {
'density-comfortable': tableDensity.value === 'comfortable',
'density-compact': tableDensity.value === 'compact',
'density-ultra': tableDensity.value === 'ultra-compact'
}
})
// Filtered users based on search query
const filteredUsers = computed(() => {
if (!searchQuery.value) return users.value
const q = searchQuery.value.toLowerCase()
return users.value.filter(user => {
const searchText = [
user.mobile_number,
user.name,
user.fullname,
user.nickname,
user.username,
user.acct_type
].filter(Boolean).join(' ').toLowerCase()
return searchText.includes(q)
})
})
</script>
<template>
<div class="user-list-page pb-5">
<br><br>
<div class="tf-container">
<!-- Header Toolbar -->
<div class="row g-3 mb-4">
<div class="col-12 col-md-6">
<h4 class="fw_6 mb-0">Users</h4>
</div>
<div class="col-12 col-md-6 d-flex justify-content-md-end align-items-center gap-2">
<TableDensityToggle v-model="tableDensity" />
<button
@click="goToCreateUser"
class="btn btn-primary d-flex align-items-center gap-2 rounded-pill px-4"
>
<i class="fas fa-plus small"></i> Create User
</button>
<button
v-if="isUltimate"
@click="navigate({ page: 'BatchAddUsers' })"
class="btn btn-outline-primary d-flex align-items-center gap-2 rounded-pill px-4"
>
<i class="fas fa-file-import small"></i> Batch Add
</button>
</div>
</div>
<!-- Search Bar -->
<SearchBar
v-model="searchQuery"
id="user-search"
placeholder="Search users by name, mobile, username..."
class="mb-4"
/>
<!-- Loading State -->
<div v-if="loading" class="text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<p class="mt-2">Loading users...</p>
</div>
<!-- Error State -->
<div v-else-if="error" class="alert alert-danger">{{ error }}</div>
<!-- Empty State -->
<div v-else-if="users.length === 0" class="text-center py-5">
<p>No users found</p>
<button @click="goToCreateUser" class="btn btn-primary mt-2">Create First User</button>
</div>
<!-- No Results State -->
<div v-else-if="filteredUsers.length === 0" class="text-center py-5">
<i class="fas fa-search fa-3x text-muted mb-3"></i>
<p class="text-muted">No users match your search criteria</p>
<button @click="searchQuery = ''" class="btn btn-outline-primary mt-2">Clear Search</button>
</div>
<!-- Users Table -->
<div v-else class="card border-0 shadow-sm rounded-20" :data-table-density="tableDensity">
<div class="table-responsive">
<table class="table table-striped table-hover density-table mb-0" :class="tableDensityClass">
<thead>
<tr>
<th class="bg-transparent">ID</th>
<th class="bg-transparent">Mobile Number</th>
<th class="bg-transparent">Name</th>
<th class="bg-transparent">Username</th>
<th class="bg-transparent">Type</th>
<th class="bg-transparent">Status</th>
<th class="bg-transparent">Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="user in filteredUsers" :key="user.hashkey" :class="{ 'bg-warning-lights': isNullPasswordUser(user) }">
<td>{{ user.id }}</td>
<td>{{ user.mobile_number || 'N/A' }}</td>
<td class="text-primary-emphasis fw_6">
{{ isUltimate ? (user.fullname || user.name || user.nickname || 'N/A') : '******' }}
</td>
<td>{{ user.username || '-' }}</td>
<td>
<span class="badge" :class="getUserTypeClass(user.acct_type)" v-html="getUserTypeName(user.acct_type)" />
</td>
<td>
<span class="badge" :class="user.is_active ? 'bg-success' : 'bg-danger'">
{{ user.is_active ? 'Active' : 'Inactive' }}
</span>
</td>
<td>
<div class="d-flex flex-wrap gap-1" style="max-width: 20vw;">
<button
@click="viewUser(user.hashkey)"
class="btn btn-sm btn-outline-primary p-0 d-flex align-items-center justify-content-center"
style="width: 26px; height: 26px;"
title="Edit"
>
<img src="https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/58312da31e26.svg" alt="Edit" width="14" height="14">
</button>
<button
@click="openResetPasswordModal(user)"
class="btn btn-sm btn-outline-info p-0 d-flex align-items-center justify-content-center"
style="width: 26px; height: 26px;"
title="Reset Password"
>
<i class="fas fa-key" style="font-size: 14px;"></i>
</button>
<button
v-if="!user.is_active"
@click="enableUser(user.hashkey)"
class="btn btn-sm btn-outline-success p-0 d-flex align-items-center justify-content-center"
style="width: 26px; height: 26px;"
title="Enable User"
>
<img src="https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/7144054b3045.svg" alt="Enable" width="14" height="14">
</button>
<button
v-else
@click="disableUser(user.hashkey)"
class="btn btn-sm btn-outline-warning p-0 d-flex align-items-center justify-content-center"
style="width: 26px; height: 26px;"
title="Disable User"
>
<img src="https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/7006df54c8aa.svg" alt="Disable" width="14" height="14">
</button>
<button
@click="navigate({ page: 'ManageUser', props: { target: user.hashkey } })"
class="btn btn-sm btn-outline-secondary p-0 d-flex align-items-center justify-content-center"
style="width: 26px; height: 26px;"
title="Manage User Data"
>
<i class="fas fa-cog" style="font-size: 14px;"></i>
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Pagination -->
<nav v-if="filteredUsers.length > 0" aria-label="Page navigation">
<ul class="pagination justify-content-center">
<li class="page-item disabled">
<span class="page-link">1 / {{ Math.ceil(filteredUsers.length / 20) }}</span>
</li>
</ul>
</nav>
</div>
<!-- Reset Password Modal -->
<Teleport to="body">
<div v-if="showResetPasswordModal" class="modal fade show d-block" role="dialog" @click.self="closeResetPasswordModal">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content reset-password-modal">
<div class="modal-header">
<h5 class="modal-title">
<i class="fas fa-key me-2"></i>Reset Password
</h5>
<button type="button" class="btn-close" @click="closeResetPasswordModal"></button>
</div>
<div class="modal-body">
<div class="user-info-banner mb-3">
<div class="d-flex align-items-center gap-2">
<i class="fas fa-user-circle fa-2x text-muted"></i>
<div>
<strong>{{ resetPasswordUser?.name || resetPasswordUser?.nickname || 'User' }}</strong>
<div class="text-muted small">{{ resetPasswordUser?.mobile_number }}</div>
</div>
</div>
</div>
<div class="mb-3">
<label for="resetNewPassword" class="form-label">New Password</label>
<div class="input-group">
<span class="input-group-text">
<i class="fas fa-lock"></i>
</span>
<input
type="password"
id="resetNewPassword"
class="form-control"
placeholder="Min 6 characters"
v-model.trim="newPassword"
:disabled="resetPasswordLoading"
>
</div>
</div>
<div class="mb-3">
<label for="resetConfirmPassword" class="form-label">Confirm Password</label>
<div class="input-group">
<span class="input-group-text">
<i class="fas fa-lock"></i>
</span>
<input
type="password"
id="resetConfirmPassword"
class="form-control"
placeholder="Confirm new password"
v-model.trim="confirmNewPassword"
:disabled="resetPasswordLoading"
>
</div>
<div v-if="confirmNewPassword && newPassword !== confirmNewPassword" class="text-danger small mt-1">
<i class="fas fa-exclamation-circle me-1"></i>Passwords do not match
</div>
<div v-else-if="confirmNewPassword && newPassword === confirmNewPassword && newPassword.length >= 6" class="text-success small mt-1">
<i class="fas fa-check-circle me-1"></i>Passwords match
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="closeResetPasswordModal" :disabled="resetPasswordLoading">
Cancel
</button>
<button
type="button"
class="btn btn-primary"
@click="submitPasswordReset"
:disabled="resetPasswordLoading || !newPassword || newPassword.length < 6 || newPassword !== confirmNewPassword"
>
<span v-if="resetPasswordLoading">
<i class="fas fa-spinner fa-spin me-1"></i>Resetting...
</span>
<span v-else>
<i class="fas fa-key me-1"></i>Reset Password
</span>
</button>
</div>
</div>
</div>
</div>
<div v-if="showResetPasswordModal" class="modal-backdrop fade show"></div>
</Teleport>
</div>
</template>
<style scoped>
.user-list-page {
background: var(--bg-primary);
min-height: 100vh;
transition: background 0.3s ease;
}
.create-user-icon {
width: 12px !important;
height: 12px !important;
}
.btn-primary {
background-color: #0085ff;
border-color: #0085ff;
}
.btn-primary:hover {
background-color: #006edb;
border-color: #006edb;
}
.btn-group .btn + .btn {
margin-left: 5px;
}
.icon-white {
filter: brightness(0) invert(1);
}
.btn-group .btn img {
vertical-align: middle;
transition: transform 0.2s ease;
}
.btn-group .btn:hover img {
transform: scale(1.15);
}
.table {
border-radius: 12px !important;
overflow: hidden;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
background-color: var(--bg-card);
border: none;
color: var(--text-primary);
}
.table thead th {
background-color: var(--bg-tertiary);
color: var(--accent-color) !important;
border-bottom: 2px solid var(--border-color);
font-weight: 600;
text-transform: uppercase;
font-size: 0.85rem;
letter-spacing: 0.5px;
}
/* Table Density Styles */
.density-table thead th,
.density-table tbody td {
transition: padding 0.2s ease;
}
/* Comfortable - Default */
.density-comfortable thead th {
padding: 1rem;
}
.density-comfortable tbody td {
padding: 1rem;
}
/* Compact */
.density-compact thead th {
padding: 0.625rem 0.75rem;
}
.density-compact tbody td {
padding: 0.5rem 0.75rem;
}
/* Ultra Compact */
.density-ultra thead th {
padding: 0.375rem 0.5rem;
font-size: 0.7rem;
}
.density-ultra tbody td {
padding: 0.25rem 0.5rem;
font-size: 0.8rem;
}
.badge {
padding: 6px 12px;
border-radius: 20px;
font-weight: 500;
font-size: 0.75rem;
}
/* Reset Password Modal Styles */
.modal {
z-index: 1050;
background: rgba(0, 0, 0, 0.5);
}
.modal-backdrop {
z-index: 1040;
}
.modal-dialog {
z-index: 1060;
}
.reset-password-modal {
border-radius: 16px;
border: none;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15);
}
.reset-password-modal .modal-header {
border-bottom: 1px solid var(--border-color);
padding: 1rem 1.25rem;
}
.reset-password-modal .modal-title {
color: var(--accent-color);
font-weight: 600;
}
.reset-password-modal .modal-footer {
border-top: 1px solid var(--border-color);
padding: 1rem 1.25rem;
}
.user-info-banner {
background: var(--bg-secondary);
border-radius: 10px;
padding: 12px 16px;
border: 1px solid var(--border-color);
}
.btn-outline-info {
color: #0085ff;
border-color: #0085ff;
}
.btn-outline-info:hover {
background-color: #0085ff;
border-color: #0085ff;
color: white;
}
</style>