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>
389 lines
11 KiB
Vue
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>
|