Files
shopdb-flask/frontend/src/views/settings/LocationsList.vue
cproudlock f6dcaef4c0
All checks were successful
CI / backend (push) Successful in 1m25s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 7s
Persist list pagination and search in the URL
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>
2026-07-12 14:19:50 -04:00

389 lines
11 KiB
Vue

<template>
<div>
<div class="page-header">
<h2>Locations</h2>
<button class="btn btn-primary" @click="openModal()">+ Add Location</button>
</div>
<!-- Search -->
<div class="filters">
<input
v-model="search"
type="text"
class="form-control"
placeholder="Search locations..."
@input="debouncedSearch"
/>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>Location Name</th>
<th>Type</th>
<th>Building</th>
<th>Floor</th>
<th>Room</th>
<th>Description</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="loc in locations" :key="loc.locationid">
<td>{{ loc.locationname }}</td>
<td>{{ loc.locationtypename || '-' }}</td>
<td>{{ loc.building || '-' }}</td>
<td>{{ loc.floor || '-' }}</td>
<td>{{ loc.room || '-' }}</td>
<td class="cell-truncate" :title="loc.description">{{ loc.description || '-' }}</td>
<td class="actions">
<button
class="btn btn-secondary btn-sm"
@click="openModal(loc)"
>
Edit
</button>
<button
class="btn btn-danger btn-sm"
@click="confirmDelete(loc)"
>
Delete
</button>
</td>
</tr>
<tr v-if="locations.length === 0">
<td colspan="7" style="text-align: center; color: var(--text-light);">
No locations 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>{{ editingLocation ? 'Edit Location' : 'Add Location' }}</h3>
</div>
<form @submit.prevent="saveLocation">
<div class="modal-body">
<div class="form-group">
<label for="locationname">Location Name *</label>
<input
id="locationname"
v-model="form.locationname"
type="text"
class="form-control"
required
/>
</div>
<div class="form-row">
<div class="form-group">
<label for="building">Building</label>
<input
id="building"
v-model="form.building"
type="text"
class="form-control"
/>
</div>
<div class="form-group">
<label for="floor">Floor</label>
<input
id="floor"
v-model="form.floor"
type="text"
class="form-control"
/>
</div>
<div class="form-group">
<label for="room">Room</label>
<input
id="room"
v-model="form.room"
type="text"
class="form-control"
/>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="locationtypeid">Type</label>
<select id="locationtypeid" v-model="form.locationtypeid" class="form-control">
<option value="">Select type...</option>
<option v-for="t in locationTypes" :key="t.locationtypeid" :value="t.locationtypeid">
{{ t.locationtype }}
</option>
</select>
</div>
<div class="form-group">
<label for="parentlocationid">Parent Location</label>
<select id="parentlocationid" v-model="form.parentlocationid" class="form-control">
<option value="">None</option>
<option
v-for="l in parentOptions"
:key="l.locationid"
:value="l.locationid"
>
{{ l.locationname }}
</option>
</select>
</div>
</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 class="form-group">
<label for="mapimage">Map Image URL</label>
<input
id="mapimage"
v-model="form.mapimage"
type="text"
class="form-control"
placeholder="Optional floor plan image URL"
/>
</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 Location</h3>
</div>
<div class="modal-body">
<p>Are you sure you want to delete <strong>{{ locationToDelete?.locationname }}</strong>?</p>
<p style="color: var(--text-light); font-size: 0.875rem;">
This may affect machines assigned to this location.
</p>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" @click="showDeleteModal = false">Cancel</button>
<button class="btn btn-danger" @click="deleteLocation">Delete</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { locationsApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
import { useToast } from '../../composables/toast'
import { apiError } from '../../utils/apiError'
import { useListQuery } from '@/composables/listQuery'
const toast = useToast()
const locations = ref([])
const locationTypes = ref([])
const allLocations = ref([])
const loading = ref(true)
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadLocations })
const totalPages = ref(1)
const perPage = ref(20)
const showModal = ref(false)
const editingLocation = ref(null)
const saving = ref(false)
const error = ref('')
const showDeleteModal = ref(false)
const locationToDelete = ref(null)
const form = ref({
locationname: '',
building: '',
floor: '',
room: '',
description: '',
locationtypeid: '',
parentlocationid: '',
mapimage: ''
})
// parent options = all locations except the one being edited (no self-parent)
const parentOptions = computed(() =>
allLocations.value.filter(l => l.locationid !== editingLocation.value?.locationid)
)
let searchTimeout = null
onMounted(async () => {
try {
const [typesRes, allRes] = await Promise.all([
locationsApi.types.list(),
locationsApi.list({ perpage: 100 })
])
locationTypes.value = typesRes.data.data || []
allLocations.value = allRes.data.data || []
} catch (err) {
console.error('Error loading location types:', err)
}
loadLocations()
})
async function loadLocations() {
loading.value = true
try {
const params = {
page: page.value,
perpage: perPage.value
}
if (search.value) params.search = search.value
const response = await locationsApi.list(params)
locations.value = response.data.data || []
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (err) {
console.error('Error loading locations:', err)
} finally {
loading.value = false
}
}
function debouncedSearch() {
clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
setSearch(search.value)
loadLocations()
}, 300)
}
function goToPage(p) {
setPage(p)
loadLocations()
}
function changePerPage(newPerPage) {
perPage.value = newPerPage
setPage(1)
loadLocations()
}
function openModal(loc = null) {
editingLocation.value = loc
if (loc) {
form.value = {
locationname: loc.locationname || '',
building: loc.building || '',
floor: loc.floor || '',
room: loc.room || '',
description: loc.description || '',
locationtypeid: loc.locationtypeid || '',
parentlocationid: loc.parentlocationid || '',
mapimage: loc.mapimage || ''
}
} else {
form.value = {
locationname: '',
building: '',
floor: '',
room: '',
description: '',
locationtypeid: '',
parentlocationid: '',
mapimage: ''
}
}
error.value = ''
showModal.value = true
}
function closeModal() {
showModal.value = false
editingLocation.value = null
}
async function saveLocation() {
error.value = ''
saving.value = true
try {
const payload = {
...form.value,
locationtypeid: form.value.locationtypeid || null,
parentlocationid: form.value.parentlocationid || null
}
if (editingLocation.value) {
await locationsApi.update(editingLocation.value.locationid, payload)
} else {
await locationsApi.create(payload)
}
closeModal()
loadLocations()
} catch (err) {
console.error('Error saving location:', err)
error.value = apiError(err, 'Failed to save location')
} finally {
saving.value = false
}
}
function confirmDelete(loc) {
locationToDelete.value = loc
showDeleteModal.value = true
}
async function deleteLocation() {
try {
await locationsApi.delete(locationToDelete.value.locationid)
showDeleteModal.value = false
locationToDelete.value = null
loadLocations()
} catch (err) {
console.error('Error deleting location:', err)
toast.error('Failed to delete location')
}
}
</script>
<style scoped>
.form-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
}
</style>