List pages kept the current page in local state, so clicking into an asset and hitting Back remounted the list at page 1. A shared useListQuery composable now mirrors the page (and search term) into the URL query via router.replace across all 18 list pages, so Back restores the page you were on and lists are deep-linkable. Page 1 with no search stays a bare path; changing a filter resets to page 1; unrelated query keys are preserved. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
320 lines
8.3 KiB
Vue
320 lines
8.3 KiB
Vue
<template>
|
|
<div>
|
|
<div class="page-header">
|
|
<h2>Asset Statuses</h2>
|
|
<button class="btn btn-primary" @click="openModal()">+ Add Status</button>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<div v-if="loading" class="loading">Loading...</div>
|
|
|
|
<template v-else>
|
|
<div class="table-container">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Status</th>
|
|
<th>Color</th>
|
|
<th>Description</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="s in statuses" :key="s.statusid">
|
|
<td>
|
|
<span class="status-badge" :style="getStatusStyle(s.color)">
|
|
{{ s.status }}
|
|
</span>
|
|
</td>
|
|
<td>
|
|
<span class="color-preview" :style="{ backgroundColor: s.color || '#6c757d' }"></span>
|
|
{{ s.color || 'default' }}
|
|
</td>
|
|
<td class="cell-truncate" :title="s.description">{{ s.description || '-' }}</td>
|
|
<td class="actions">
|
|
<button
|
|
class="btn btn-secondary btn-sm"
|
|
@click="openModal(s)"
|
|
>
|
|
Edit
|
|
</button>
|
|
<button
|
|
class="btn btn-danger btn-sm"
|
|
@click="confirmDelete(s)"
|
|
>
|
|
Delete
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
<tr v-if="statuses.length === 0">
|
|
<td colspan="4" style="text-align: center; color: var(--text-light);">
|
|
No statuses found
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<!-- Pagination -->
|
|
<PaginationBar
|
|
:page="page"
|
|
:totalPages="totalPages"
|
|
:perPage="perPage"
|
|
@update:page="goToPage"
|
|
@update:perPage="changePerPage"
|
|
/>
|
|
</template>
|
|
</div>
|
|
|
|
<!-- Add/Edit Modal -->
|
|
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
|
|
<div class="modal">
|
|
<div class="modal-header">
|
|
<h3>{{ editingStatus ? 'Edit Status' : 'Add Status' }}</h3>
|
|
</div>
|
|
<form @submit.prevent="saveStatus">
|
|
<div class="modal-body">
|
|
<div class="form-group">
|
|
<label for="status">Status Name *</label>
|
|
<input
|
|
id="status"
|
|
v-model="form.status"
|
|
type="text"
|
|
class="form-control"
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label>Color</label>
|
|
<ColorSwatchPicker v-model="form.color" />
|
|
<small class="form-hint">Used in UI to visually distinguish statuses</small>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="description">Description</label>
|
|
<textarea
|
|
id="description"
|
|
v-model="form.description"
|
|
class="form-control"
|
|
rows="3"
|
|
></textarea>
|
|
</div>
|
|
|
|
<div v-if="error" class="error-message">{{ error }}</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
|
|
<button type="submit" class="btn btn-primary" :disabled="saving">
|
|
{{ saving ? 'Saving...' : 'Save' }}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Delete Confirmation Modal -->
|
|
<div v-if="showDeleteModal" class="modal-overlay" @click.self="showDeleteModal = false">
|
|
<div class="modal">
|
|
<div class="modal-header">
|
|
<h3>Delete Status</h3>
|
|
</div>
|
|
<div class="modal-body">
|
|
<p>Are you sure you want to delete <strong>{{ statusToDelete?.status }}</strong>?</p>
|
|
<p style="color: var(--text-light); font-size: 0.875rem;">
|
|
Cannot delete if assets are using this status.
|
|
</p>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button class="btn btn-secondary" @click="showDeleteModal = false">Cancel</button>
|
|
<button class="btn btn-danger" @click="deleteStatus">Delete</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, onMounted } from 'vue'
|
|
import { assetsApi } from '../../api'
|
|
import PaginationBar from '../../components/PaginationBar.vue'
|
|
import ColorSwatchPicker from '@/components/ColorSwatchPicker.vue'
|
|
import { useToast } from '../../composables/toast'
|
|
import { apiError } from '../../utils/apiError'
|
|
import { useListQuery } from '@/composables/listQuery'
|
|
const toast = useToast()
|
|
|
|
const statuses = ref([])
|
|
const loading = ref(true)
|
|
const { page, setPage } = useListQuery({ onChange: loadStatuses })
|
|
const totalPages = ref(1)
|
|
const perPage = ref(20)
|
|
|
|
const showModal = ref(false)
|
|
const editingStatus = ref(null)
|
|
const saving = ref(false)
|
|
const error = ref('')
|
|
|
|
const showDeleteModal = ref(false)
|
|
const statusToDelete = ref(null)
|
|
|
|
const form = ref({
|
|
status: '',
|
|
color: '#6c757d',
|
|
description: ''
|
|
})
|
|
|
|
onMounted(() => {
|
|
loadStatuses()
|
|
})
|
|
|
|
async function loadStatuses() {
|
|
loading.value = true
|
|
try {
|
|
const params = {
|
|
page: page.value,
|
|
perpage: perPage.value
|
|
}
|
|
|
|
const response = await assetsApi.statuses.list(params)
|
|
statuses.value = response.data.data || []
|
|
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
|
|
} catch (err) {
|
|
console.error('Error loading statuses:', err)
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
function goToPage(p) {
|
|
setPage(p)
|
|
loadStatuses()
|
|
}
|
|
|
|
function changePerPage(newPerPage) {
|
|
perPage.value = newPerPage
|
|
setPage(1)
|
|
loadStatuses()
|
|
}
|
|
|
|
function openModal(s = null) {
|
|
editingStatus.value = s
|
|
if (s) {
|
|
form.value = {
|
|
status: s.status || '',
|
|
color: s.color || '#6c757d',
|
|
description: s.description || ''
|
|
}
|
|
} else {
|
|
form.value = {
|
|
status: '',
|
|
color: '#6c757d',
|
|
description: ''
|
|
}
|
|
}
|
|
error.value = ''
|
|
showModal.value = true
|
|
}
|
|
|
|
function closeModal() {
|
|
showModal.value = false
|
|
editingStatus.value = null
|
|
}
|
|
|
|
async function saveStatus() {
|
|
error.value = ''
|
|
saving.value = true
|
|
|
|
try {
|
|
if (editingStatus.value) {
|
|
await assetsApi.statuses.update(editingStatus.value.statusid, form.value)
|
|
} else {
|
|
await assetsApi.statuses.create(form.value)
|
|
}
|
|
closeModal()
|
|
loadStatuses()
|
|
} catch (err) {
|
|
console.error('Error saving status:', err)
|
|
error.value = apiError(err, 'Failed to save status')
|
|
} finally {
|
|
saving.value = false
|
|
}
|
|
}
|
|
|
|
function confirmDelete(s) {
|
|
statusToDelete.value = s
|
|
showDeleteModal.value = true
|
|
}
|
|
|
|
async function deleteStatus() {
|
|
try {
|
|
await assetsApi.statuses.delete(statusToDelete.value.statusid)
|
|
showDeleteModal.value = false
|
|
statusToDelete.value = null
|
|
loadStatuses()
|
|
} catch (err) {
|
|
console.error('Error deleting status:', err)
|
|
error.value = apiError(err, 'Failed to delete status')
|
|
toast.error(error.value)
|
|
}
|
|
}
|
|
|
|
function getStatusStyle(color) {
|
|
const bgColor = color || '#6c757d'
|
|
return {
|
|
backgroundColor: bgColor,
|
|
color: isLightColor(bgColor) ? '#000' : '#fff'
|
|
}
|
|
}
|
|
|
|
function isLightColor(color) {
|
|
if (!color) return false
|
|
const hex = color.replace('#', '')
|
|
const r = parseInt(hex.substr(0, 2), 16)
|
|
const g = parseInt(hex.substr(2, 2), 16)
|
|
const b = parseInt(hex.substr(4, 2), 16)
|
|
const brightness = (r * 299 + g * 587 + b * 114) / 1000
|
|
return brightness > 128
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.status-badge {
|
|
padding: 0.25rem 0.75rem;
|
|
border-radius: 4px;
|
|
font-size: 0.875rem;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.color-preview {
|
|
display: inline-block;
|
|
width: 16px;
|
|
height: 16px;
|
|
border-radius: 3px;
|
|
margin-right: 0.5rem;
|
|
vertical-align: middle;
|
|
border: 1px solid var(--border-color);
|
|
}
|
|
|
|
.color-input-row {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
align-items: center;
|
|
}
|
|
|
|
.color-picker {
|
|
width: 50px;
|
|
height: 38px;
|
|
padding: 2px;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.form-hint {
|
|
color: var(--text-light);
|
|
font-size: 0.75rem;
|
|
margin-top: 0.25rem;
|
|
}
|
|
</style>
|