Global toast notifications; replace all alert() calls

Add a useToast() composable + a single ToastHost mounted in AppLayout. Convert
every alert() across views/components (29 call sites, all error paths) to
toast.error, and use toast.success to confirm a warranty refresh. Kills the
native "localhost says" dialog and gives consistent, dismissable feedback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-07-09 16:56:25 -04:00
parent b90a13c7e5
commit d6142b10b4
29 changed files with 4774 additions and 4606 deletions

View File

@@ -1,187 +1,189 @@
<template>
<div>
<div class="page-header">
<h2>Business Units</h2>
<button class="btn btn-primary" @click="openModal()">+ Add Business Unit</button>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>Business Unit</th>
<th>Code</th>
<th>Description</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="bu in items" :key="bu.businessunitid">
<td>{{ bu.businessunit }}</td>
<td>{{ bu.code || '-' }}</td>
<td class="cell-truncate" :title="bu.description">{{ bu.description || '-' }}</td>
<td class="actions">
<button class="btn btn-secondary btn-sm" @click="openModal(bu)">Edit</button>
<button class="btn btn-danger btn-sm" @click="confirmDelete(bu)">Delete</button>
</td>
</tr>
<tr v-if="items.length === 0">
<td colspan="4" style="text-align: center; color: var(--text-light);">
No business units 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>{{ editing ? 'Edit Business Unit' : 'Add Business Unit' }}</h3>
</div>
<form @submit.prevent="save">
<div class="modal-body">
<div class="form-group">
<label for="businessunit">Business Unit Name *</label>
<input id="businessunit" v-model="form.businessunit" type="text" class="form-control" required />
</div>
<div class="form-group">
<label for="code">Code</label>
<input id="code" v-model="form.code" type="text" class="form-control" placeholder="e.g., ENGR, MFG" />
</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 Modal -->
<div v-if="showDeleteModal" class="modal-overlay" @click.self="showDeleteModal = false">
<div class="modal">
<div class="modal-header"><h3>Delete Business Unit</h3></div>
<div class="modal-body">
<p>Are you sure you want to delete <strong>{{ toDelete?.businessunit }}</strong>?</p>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" @click="showDeleteModal = false">Cancel</button>
<button class="btn btn-danger" @click="deleteItem">Delete</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { businessunitsApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
const items = ref([])
const loading = ref(true)
const page = ref(1)
const totalPages = ref(1)
const perPage = ref(20)
const showModal = ref(false)
const editing = ref(null)
const saving = ref(false)
const error = ref('')
const showDeleteModal = ref(false)
const toDelete = ref(null)
const form = ref({ businessunit: '', code: '', description: '' })
onMounted(() => loadData())
async function loadData() {
loading.value = true
try {
const response = await businessunitsApi.list({ page: page.value, perpage: perPage.value })
items.value = response.data.data || []
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (err) {
console.error('Error loading business units:', err)
} finally {
loading.value = false
}
}
function goToPage(p) { page.value = p; loadData() }
function changePerPage(newPerPage) {
perPage.value = newPerPage
page.value = 1
loadData()
}
function openModal(item = null) {
editing.value = item
form.value = item ? {
businessunit: item.businessunit || '',
code: item.code || '',
description: item.description || ''
} : { businessunit: '', code: '', description: '' }
error.value = ''
showModal.value = true
}
function closeModal() { showModal.value = false; editing.value = null }
async function save() {
error.value = ''
saving.value = true
try {
if (editing.value) {
await businessunitsApi.update(editing.value.businessunitid, form.value)
} else {
await businessunitsApi.create(form.value)
}
closeModal()
loadData()
} catch (err) {
error.value = err.response?.data?.message || 'Failed to save'
} finally {
saving.value = false
}
}
function confirmDelete(item) { toDelete.value = item; showDeleteModal.value = true }
async function deleteItem() {
try {
await businessunitsApi.delete(toDelete.value.businessunitid)
showDeleteModal.value = false
toDelete.value = null
loadData()
} catch (err) {
alert('Failed to delete')
}
}
</script>
<template>
<div>
<div class="page-header">
<h2>Business Units</h2>
<button class="btn btn-primary" @click="openModal()">+ Add Business Unit</button>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>Business Unit</th>
<th>Code</th>
<th>Description</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="bu in items" :key="bu.businessunitid">
<td>{{ bu.businessunit }}</td>
<td>{{ bu.code || '-' }}</td>
<td class="cell-truncate" :title="bu.description">{{ bu.description || '-' }}</td>
<td class="actions">
<button class="btn btn-secondary btn-sm" @click="openModal(bu)">Edit</button>
<button class="btn btn-danger btn-sm" @click="confirmDelete(bu)">Delete</button>
</td>
</tr>
<tr v-if="items.length === 0">
<td colspan="4" style="text-align: center; color: var(--text-light);">
No business units 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>{{ editing ? 'Edit Business Unit' : 'Add Business Unit' }}</h3>
</div>
<form @submit.prevent="save">
<div class="modal-body">
<div class="form-group">
<label for="businessunit">Business Unit Name *</label>
<input id="businessunit" v-model="form.businessunit" type="text" class="form-control" required />
</div>
<div class="form-group">
<label for="code">Code</label>
<input id="code" v-model="form.code" type="text" class="form-control" placeholder="e.g., ENGR, MFG" />
</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 Modal -->
<div v-if="showDeleteModal" class="modal-overlay" @click.self="showDeleteModal = false">
<div class="modal">
<div class="modal-header"><h3>Delete Business Unit</h3></div>
<div class="modal-body">
<p>Are you sure you want to delete <strong>{{ toDelete?.businessunit }}</strong>?</p>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" @click="showDeleteModal = false">Cancel</button>
<button class="btn btn-danger" @click="deleteItem">Delete</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { businessunitsApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
import { useToast } from '../../composables/toast'
const toast = useToast()
const items = ref([])
const loading = ref(true)
const page = ref(1)
const totalPages = ref(1)
const perPage = ref(20)
const showModal = ref(false)
const editing = ref(null)
const saving = ref(false)
const error = ref('')
const showDeleteModal = ref(false)
const toDelete = ref(null)
const form = ref({ businessunit: '', code: '', description: '' })
onMounted(() => loadData())
async function loadData() {
loading.value = true
try {
const response = await businessunitsApi.list({ page: page.value, perpage: perPage.value })
items.value = response.data.data || []
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (err) {
console.error('Error loading business units:', err)
} finally {
loading.value = false
}
}
function goToPage(p) { page.value = p; loadData() }
function changePerPage(newPerPage) {
perPage.value = newPerPage
page.value = 1
loadData()
}
function openModal(item = null) {
editing.value = item
form.value = item ? {
businessunit: item.businessunit || '',
code: item.code || '',
description: item.description || ''
} : { businessunit: '', code: '', description: '' }
error.value = ''
showModal.value = true
}
function closeModal() { showModal.value = false; editing.value = null }
async function save() {
error.value = ''
saving.value = true
try {
if (editing.value) {
await businessunitsApi.update(editing.value.businessunitid, form.value)
} else {
await businessunitsApi.create(form.value)
}
closeModal()
loadData()
} catch (err) {
error.value = err.response?.data?.message || 'Failed to save'
} finally {
saving.value = false
}
}
function confirmDelete(item) { toDelete.value = item; showDeleteModal.value = true }
async function deleteItem() {
try {
await businessunitsApi.delete(toDelete.value.businessunitid)
showDeleteModal.value = false
toDelete.value = null
loadData()
} catch (err) {
toast.error('Failed to delete')
}
}
</script>

View File

@@ -114,6 +114,8 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import { assetsApi, customFieldsApi } from '../../api'
import { useToast } from '../../composables/toast'
const toast = useToast()
const assetTypes = ref([])
const assettypeid = ref(null)
@@ -206,7 +208,7 @@ async function deleteField(f) {
await customFieldsApi.remove(f.fieldid)
loadFields()
} catch (err) {
alert(err.response?.data?.error?.message || err.response?.data?.message || 'Failed to delete')
toast.error(err.response?.data?.error?.message || err.response?.data?.message || 'Failed to delete')
}
}
</script>

View File

@@ -104,6 +104,8 @@
<script setup>
import { ref, onMounted } from 'vue'
import { dashboardDefaultsApi, businessUnitsApi } from '../../api'
import { useToast } from '../../composables/toast'
const toast = useToast()
const items = ref([])
const businessUnits = ref([])
@@ -181,7 +183,7 @@ async function deleteItem() {
toDelete.value = null
loadData()
} catch (err) {
alert('Failed to delete')
toast.error('Failed to delete')
}
}
</script>

View File

@@ -76,6 +76,8 @@ import { ref, computed, onMounted } from 'vue'
import { equipmentApi } from '../../api'
import ColorSwatchPicker from '@/components/ColorSwatchPicker.vue'
import { colorStyle } from '@/utils/colorStyle'
import { useToast } from '../../composables/toast'
const toast = useToast()
const items = ref([])
const showInactive = ref(false)
@@ -136,7 +138,7 @@ async function deleteType(t) {
await equipmentApi.types.remove(t.equipmenttypeid)
loadData()
} catch (err) {
alert(err.response?.data?.error?.message || err.response?.data?.message || 'Failed to delete')
toast.error(err.response?.data?.error?.message || err.response?.data?.message || 'Failed to delete')
}
}
</script>

View File

@@ -76,6 +76,8 @@ import { ref, computed, onMounted } from 'vue'
import { locationsApi } from '../../api'
import ColorSwatchPicker from '@/components/ColorSwatchPicker.vue'
import { colorStyle } from '@/utils/colorStyle'
import { useToast } from '../../composables/toast'
const toast = useToast()
const items = ref([])
const showInactive = ref(false)
@@ -136,7 +138,7 @@ async function deleteType(t) {
await locationsApi.types.remove(t.locationtypeid)
loadData()
} catch (err) {
alert(err.response?.data?.error?.message || err.response?.data?.message || 'Failed to delete')
toast.error(err.response?.data?.error?.message || err.response?.data?.message || 'Failed to delete')
}
}
</script>

View File

@@ -1,385 +1,387 @@
<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'
const locations = ref([])
const locationTypes = ref([])
const allLocations = ref([])
const loading = ref(true)
const search = ref('')
const page = ref(1)
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(() => {
page.value = 1
loadLocations()
}, 300)
}
function goToPage(p) {
page.value = p
loadLocations()
}
function changePerPage(newPerPage) {
perPage.value = newPerPage
page.value = 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 = err.response?.data?.message || '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)
alert('Failed to delete location')
}
}
</script>
<style scoped>
.form-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
}
</style>
<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'
const toast = useToast()
const locations = ref([])
const locationTypes = ref([])
const allLocations = ref([])
const loading = ref(true)
const search = ref('')
const page = ref(1)
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(() => {
page.value = 1
loadLocations()
}, 300)
}
function goToPage(p) {
page.value = p
loadLocations()
}
function changePerPage(newPerPage) {
perPage.value = newPerPage
page.value = 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 = err.response?.data?.message || '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>

View File

@@ -145,6 +145,8 @@
import { ref, onMounted } from 'vue'
import { machinetypesApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
import { useToast } from '../../composables/toast'
const toast = useToast()
const machineTypes = ref([])
const loading = ref(true)
@@ -256,7 +258,7 @@ async function deleteType() {
loadTypes()
} catch (err) {
console.error('Error deleting machine type:', err)
alert('Failed to delete machine type')
toast.error('Failed to delete machine type')
}
}

View File

@@ -1,368 +1,370 @@
<template>
<div>
<div class="page-header">
<h2>Models</h2>
<button class="btn btn-primary" @click="openModal()">+ Add Model</button>
</div>
<!-- Filters -->
<div class="filters">
<input
v-model="search"
type="text"
class="form-control"
placeholder="Search models..."
@input="debouncedSearch"
/>
<select v-model="vendorFilter" class="form-control" @change="loadModels">
<option value="">All Vendors</option>
<option v-for="v in vendors" :key="v.vendorid" :value="v.vendorid">
{{ v.vendor }}
</option>
</select>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>Model</th>
<th>Vendor</th>
<th>Type</th>
<th>Documentation</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="m in models" :key="m.modelnumberid">
<td>
<div>{{ m.modelnumber }}</div>
<small v-if="m.description" class="text-muted">{{ m.description }}</small>
</td>
<td>{{ m.vendor || '-' }}</td>
<td>{{ m.machinetype || '-' }}</td>
<td>
<a v-if="m.documentationurl" :href="m.documentationurl" target="_blank" class="btn btn-sm btn-link">
View Docs
</a>
<span v-else>-</span>
</td>
<td class="actions">
<button class="btn btn-secondary btn-sm" @click="openModal(m)">Edit</button>
<button class="btn btn-danger btn-sm" @click="confirmDelete(m)">Delete</button>
</td>
</tr>
<tr v-if="models.length === 0">
<td colspan="5" style="text-align: center; color: var(--text-light);">
No models 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 modal-lg">
<div class="modal-header">
<h3>{{ editingModel ? 'Edit Model' : 'Add Model' }}</h3>
</div>
<form @submit.prevent="saveModel">
<div class="modal-body">
<div class="form-row">
<div class="form-group">
<label for="modelnumber">Model Number *</label>
<input
id="modelnumber"
v-model="form.modelnumber"
type="text"
class="form-control"
required
/>
</div>
<div class="form-group">
<label for="vendorid">Vendor *</label>
<select id="vendorid" v-model="form.vendorid" class="form-control" required>
<option value="">Select vendor...</option>
<option v-for="v in vendors" :key="v.vendorid" :value="v.vendorid">
{{ v.vendor }}
</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="machinetypeid">Machine Type</label>
<select id="machinetypeid" v-model="form.machinetypeid" class="form-control">
<option value="">Select type...</option>
<option v-for="mt in machineTypes" :key="mt.machinetypeid" :value="mt.machinetypeid">
{{ mt.machinetype }}
</option>
</select>
</div>
<div class="form-group">
<label for="description">Description</label>
<input id="description" v-model="form.description" type="text" class="form-control" />
</div>
</div>
<div class="form-group">
<label for="documentationurl">Documentation URL</label>
<input
id="documentationurl"
v-model="form.documentationurl"
type="url"
class="form-control"
placeholder="https://..."
/>
</div>
<div class="form-group">
<label for="imageurl">Image URL</label>
<input
id="imageurl"
v-model="form.imageurl"
type="url"
class="form-control"
placeholder="https://..."
/>
</div>
<div class="form-group">
<label for="notes">Notes</label>
<textarea id="notes" v-model="form.notes" class="form-control" rows="2"></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 Model</h3>
</div>
<div class="modal-body">
<p>Are you sure you want to delete <strong>{{ modelToDelete?.modelnumber }}</strong>?</p>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" @click="showDeleteModal = false">Cancel</button>
<button class="btn btn-danger" @click="deleteModel">Delete</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { modelsApi, vendorsApi, machinetypesApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
const models = ref([])
const vendors = ref([])
const machineTypes = ref([])
const loading = ref(true)
const search = ref('')
const vendorFilter = ref('')
const page = ref(1)
const totalPages = ref(1)
const perPage = ref(20)
const showModal = ref(false)
const editingModel = ref(null)
const saving = ref(false)
const error = ref('')
const showDeleteModal = ref(false)
const modelToDelete = ref(null)
const form = ref({
modelnumber: '',
vendorid: '',
machinetypeid: '',
description: '',
documentationurl: '',
imageurl: '',
notes: ''
})
let searchTimeout = null
onMounted(async () => {
await Promise.all([
loadModels(),
loadVendors(),
loadMachineTypes()
])
})
async function loadModels() {
loading.value = true
try {
const params = { page: page.value, perpage: perPage.value }
if (search.value) params.search = search.value
if (vendorFilter.value) params.vendor = vendorFilter.value
const response = await modelsApi.list(params)
models.value = response.data.data || []
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (err) {
console.error('Error loading models:', err)
} finally {
loading.value = false
}
}
async function loadVendors() {
try {
const response = await vendorsApi.list({ perpage: 100 })
vendors.value = response.data.data || []
} catch (err) {
console.error('Error loading vendors:', err)
}
}
async function loadMachineTypes() {
try {
const response = await machinetypesApi.list({ perpage: 100 })
machineTypes.value = response.data.data || []
} catch (err) {
console.error('Error loading machine types:', err)
}
}
function debouncedSearch() {
clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
page.value = 1
loadModels()
}, 300)
}
function goToPage(p) {
page.value = p
loadModels()
}
function changePerPage(newPerPage) {
perPage.value = newPerPage
page.value = 1
loadModels()
}
function openModal(m = null) {
editingModel.value = m
if (m) {
form.value = {
modelnumber: m.modelnumber || '',
vendorid: m.vendorid || '',
machinetypeid: m.machinetypeid || '',
description: m.description || '',
documentationurl: m.documentationurl || '',
imageurl: m.imageurl || '',
notes: m.notes || ''
}
} else {
form.value = {
modelnumber: '',
vendorid: '',
machinetypeid: '',
description: '',
documentationurl: '',
imageurl: '',
notes: ''
}
}
error.value = ''
showModal.value = true
}
function closeModal() {
showModal.value = false
editingModel.value = null
}
async function saveModel() {
error.value = ''
saving.value = true
try {
const data = { ...form.value }
if (!data.vendorid) data.vendorid = null
if (!data.machinetypeid) data.machinetypeid = null
if (editingModel.value) {
await modelsApi.update(editingModel.value.modelnumberid, data)
} else {
await modelsApi.create(data)
}
closeModal()
loadModels()
} catch (err) {
console.error('Error saving model:', err)
error.value = err.response?.data?.message || 'Failed to save model'
} finally {
saving.value = false
}
}
function confirmDelete(m) {
modelToDelete.value = m
showDeleteModal.value = true
}
async function deleteModel() {
try {
await modelsApi.delete(modelToDelete.value.modelnumberid)
showDeleteModal.value = false
modelToDelete.value = null
loadModels()
} catch (err) {
console.error('Error deleting model:', err)
alert('Failed to delete model')
}
}
</script>
<style scoped>
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.modal-lg {
max-width: 600px;
}
.text-muted {
color: var(--text-light);
font-size: 0.85rem;
}
</style>
<template>
<div>
<div class="page-header">
<h2>Models</h2>
<button class="btn btn-primary" @click="openModal()">+ Add Model</button>
</div>
<!-- Filters -->
<div class="filters">
<input
v-model="search"
type="text"
class="form-control"
placeholder="Search models..."
@input="debouncedSearch"
/>
<select v-model="vendorFilter" class="form-control" @change="loadModels">
<option value="">All Vendors</option>
<option v-for="v in vendors" :key="v.vendorid" :value="v.vendorid">
{{ v.vendor }}
</option>
</select>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>Model</th>
<th>Vendor</th>
<th>Type</th>
<th>Documentation</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="m in models" :key="m.modelnumberid">
<td>
<div>{{ m.modelnumber }}</div>
<small v-if="m.description" class="text-muted">{{ m.description }}</small>
</td>
<td>{{ m.vendor || '-' }}</td>
<td>{{ m.machinetype || '-' }}</td>
<td>
<a v-if="m.documentationurl" :href="m.documentationurl" target="_blank" class="btn btn-sm btn-link">
View Docs
</a>
<span v-else>-</span>
</td>
<td class="actions">
<button class="btn btn-secondary btn-sm" @click="openModal(m)">Edit</button>
<button class="btn btn-danger btn-sm" @click="confirmDelete(m)">Delete</button>
</td>
</tr>
<tr v-if="models.length === 0">
<td colspan="5" style="text-align: center; color: var(--text-light);">
No models 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 modal-lg">
<div class="modal-header">
<h3>{{ editingModel ? 'Edit Model' : 'Add Model' }}</h3>
</div>
<form @submit.prevent="saveModel">
<div class="modal-body">
<div class="form-row">
<div class="form-group">
<label for="modelnumber">Model Number *</label>
<input
id="modelnumber"
v-model="form.modelnumber"
type="text"
class="form-control"
required
/>
</div>
<div class="form-group">
<label for="vendorid">Vendor *</label>
<select id="vendorid" v-model="form.vendorid" class="form-control" required>
<option value="">Select vendor...</option>
<option v-for="v in vendors" :key="v.vendorid" :value="v.vendorid">
{{ v.vendor }}
</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="machinetypeid">Machine Type</label>
<select id="machinetypeid" v-model="form.machinetypeid" class="form-control">
<option value="">Select type...</option>
<option v-for="mt in machineTypes" :key="mt.machinetypeid" :value="mt.machinetypeid">
{{ mt.machinetype }}
</option>
</select>
</div>
<div class="form-group">
<label for="description">Description</label>
<input id="description" v-model="form.description" type="text" class="form-control" />
</div>
</div>
<div class="form-group">
<label for="documentationurl">Documentation URL</label>
<input
id="documentationurl"
v-model="form.documentationurl"
type="url"
class="form-control"
placeholder="https://..."
/>
</div>
<div class="form-group">
<label for="imageurl">Image URL</label>
<input
id="imageurl"
v-model="form.imageurl"
type="url"
class="form-control"
placeholder="https://..."
/>
</div>
<div class="form-group">
<label for="notes">Notes</label>
<textarea id="notes" v-model="form.notes" class="form-control" rows="2"></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 Model</h3>
</div>
<div class="modal-body">
<p>Are you sure you want to delete <strong>{{ modelToDelete?.modelnumber }}</strong>?</p>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" @click="showDeleteModal = false">Cancel</button>
<button class="btn btn-danger" @click="deleteModel">Delete</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { modelsApi, vendorsApi, machinetypesApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
import { useToast } from '../../composables/toast'
const toast = useToast()
const models = ref([])
const vendors = ref([])
const machineTypes = ref([])
const loading = ref(true)
const search = ref('')
const vendorFilter = ref('')
const page = ref(1)
const totalPages = ref(1)
const perPage = ref(20)
const showModal = ref(false)
const editingModel = ref(null)
const saving = ref(false)
const error = ref('')
const showDeleteModal = ref(false)
const modelToDelete = ref(null)
const form = ref({
modelnumber: '',
vendorid: '',
machinetypeid: '',
description: '',
documentationurl: '',
imageurl: '',
notes: ''
})
let searchTimeout = null
onMounted(async () => {
await Promise.all([
loadModels(),
loadVendors(),
loadMachineTypes()
])
})
async function loadModels() {
loading.value = true
try {
const params = { page: page.value, perpage: perPage.value }
if (search.value) params.search = search.value
if (vendorFilter.value) params.vendor = vendorFilter.value
const response = await modelsApi.list(params)
models.value = response.data.data || []
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (err) {
console.error('Error loading models:', err)
} finally {
loading.value = false
}
}
async function loadVendors() {
try {
const response = await vendorsApi.list({ perpage: 100 })
vendors.value = response.data.data || []
} catch (err) {
console.error('Error loading vendors:', err)
}
}
async function loadMachineTypes() {
try {
const response = await machinetypesApi.list({ perpage: 100 })
machineTypes.value = response.data.data || []
} catch (err) {
console.error('Error loading machine types:', err)
}
}
function debouncedSearch() {
clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
page.value = 1
loadModels()
}, 300)
}
function goToPage(p) {
page.value = p
loadModels()
}
function changePerPage(newPerPage) {
perPage.value = newPerPage
page.value = 1
loadModels()
}
function openModal(m = null) {
editingModel.value = m
if (m) {
form.value = {
modelnumber: m.modelnumber || '',
vendorid: m.vendorid || '',
machinetypeid: m.machinetypeid || '',
description: m.description || '',
documentationurl: m.documentationurl || '',
imageurl: m.imageurl || '',
notes: m.notes || ''
}
} else {
form.value = {
modelnumber: '',
vendorid: '',
machinetypeid: '',
description: '',
documentationurl: '',
imageurl: '',
notes: ''
}
}
error.value = ''
showModal.value = true
}
function closeModal() {
showModal.value = false
editingModel.value = null
}
async function saveModel() {
error.value = ''
saving.value = true
try {
const data = { ...form.value }
if (!data.vendorid) data.vendorid = null
if (!data.machinetypeid) data.machinetypeid = null
if (editingModel.value) {
await modelsApi.update(editingModel.value.modelnumberid, data)
} else {
await modelsApi.create(data)
}
closeModal()
loadModels()
} catch (err) {
console.error('Error saving model:', err)
error.value = err.response?.data?.message || 'Failed to save model'
} finally {
saving.value = false
}
}
function confirmDelete(m) {
modelToDelete.value = m
showDeleteModal.value = true
}
async function deleteModel() {
try {
await modelsApi.delete(modelToDelete.value.modelnumberid)
showDeleteModal.value = false
modelToDelete.value = null
loadModels()
} catch (err) {
console.error('Error deleting model:', err)
toast.error('Failed to delete model')
}
}
</script>
<style scoped>
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.modal-lg {
max-width: 600px;
}
.text-muted {
color: var(--text-light);
font-size: 0.85rem;
}
</style>

View File

@@ -76,6 +76,8 @@ import { ref, computed, onMounted } from 'vue'
import { networkApi } from '../../api'
import ColorSwatchPicker from '@/components/ColorSwatchPicker.vue'
import { colorStyle } from '@/utils/colorStyle'
import { useToast } from '../../composables/toast'
const toast = useToast()
const items = ref([])
const showInactive = ref(false)
@@ -136,7 +138,7 @@ async function deleteType(t) {
await networkApi.types.remove(t.networkdevicetypeid)
loadData()
} catch (err) {
alert(err.response?.data?.error?.message || err.response?.data?.message || 'Failed to delete')
toast.error(err.response?.data?.error?.message || err.response?.data?.message || 'Failed to delete')
}
}
</script>

View File

@@ -1,224 +1,226 @@
<template>
<div>
<div class="page-header">
<h2>Operating Systems</h2>
<button class="btn btn-primary" @click="openModal()">+ Add OS</button>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>OS Name</th>
<th>Version</th>
<th>Architecture</th>
<th>End of Life</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="os in items" :key="os.osid">
<td>{{ os.osname }}</td>
<td>{{ os.osversion || '-' }}</td>
<td>{{ os.architecture || '-' }}</td>
<td>
<span v-if="os.endoflife" :class="{ 'text-danger': isPastEol(os.endoflife) }">
{{ os.endoflife }}
</span>
<span v-else>-</span>
</td>
<td class="actions">
<button class="btn btn-secondary btn-sm" @click="openModal(os)">Edit</button>
<button class="btn btn-danger btn-sm" @click="confirmDelete(os)">Delete</button>
</td>
</tr>
<tr v-if="items.length === 0">
<td colspan="5" style="text-align: center; color: var(--text-light);">
No operating systems 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>{{ editing ? 'Edit Operating System' : 'Add Operating System' }}</h3>
</div>
<form @submit.prevent="save">
<div class="modal-body">
<div class="form-group">
<label for="osname">OS Name *</label>
<input id="osname" v-model="form.osname" type="text" class="form-control" required placeholder="Windows 11" />
</div>
<div class="form-row">
<div class="form-group">
<label for="osversion">Version</label>
<input id="osversion" v-model="form.osversion" type="text" class="form-control" placeholder="23H2" />
</div>
<div class="form-group">
<label for="architecture">Architecture</label>
<select id="architecture" v-model="form.architecture" class="form-control">
<option value="">Select...</option>
<option value="x64">x64</option>
<option value="x86">x86</option>
<option value="ARM64">ARM64</option>
</select>
</div>
</div>
<div class="form-group">
<label for="endoflife">End of Life Date</label>
<input id="endoflife" v-model="form.endoflife" type="date" class="form-control" />
</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 Modal -->
<div v-if="showDeleteModal" class="modal-overlay" @click.self="showDeleteModal = false">
<div class="modal">
<div class="modal-header"><h3>Delete Operating System</h3></div>
<div class="modal-body">
<p>Are you sure you want to delete <strong>{{ toDelete?.osname }}</strong>?</p>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" @click="showDeleteModal = false">Cancel</button>
<button class="btn btn-danger" @click="deleteItem">Delete</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { operatingsystemsApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
const items = ref([])
const loading = ref(true)
const page = ref(1)
const totalPages = ref(1)
const perPage = ref(20)
const showModal = ref(false)
const editing = ref(null)
const saving = ref(false)
const error = ref('')
const showDeleteModal = ref(false)
const toDelete = ref(null)
const form = ref({ osname: '', osversion: '', architecture: '', endoflife: '' })
onMounted(() => loadData())
async function loadData() {
loading.value = true
try {
const response = await operatingsystemsApi.list({ page: page.value, perpage: perPage.value })
items.value = response.data.data || []
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (err) {
console.error('Error loading operating systems:', err)
} finally {
loading.value = false
}
}
function goToPage(p) { page.value = p; loadData() }
function changePerPage(newPerPage) {
perPage.value = newPerPage
page.value = 1
loadData()
}
function isPastEol(date) {
return new Date(date) < new Date()
}
function openModal(item = null) {
editing.value = item
form.value = item ? {
osname: item.osname || '',
osversion: item.osversion || '',
architecture: item.architecture || '',
endoflife: item.endoflife || ''
} : { osname: '', osversion: '', architecture: '', endoflife: '' }
error.value = ''
showModal.value = true
}
function closeModal() { showModal.value = false; editing.value = null }
async function save() {
error.value = ''
saving.value = true
try {
const data = { ...form.value }
if (!data.endoflife) data.endoflife = null
if (editing.value) {
await operatingsystemsApi.update(editing.value.osid, data)
} else {
await operatingsystemsApi.create(data)
}
closeModal()
loadData()
} catch (err) {
error.value = err.response?.data?.message || 'Failed to save'
} finally {
saving.value = false
}
}
function confirmDelete(item) { toDelete.value = item; showDeleteModal.value = true }
async function deleteItem() {
try {
await operatingsystemsApi.delete(toDelete.value.osid)
showDeleteModal.value = false
toDelete.value = null
loadData()
} catch (err) {
alert('Failed to delete')
}
}
</script>
<style scoped>
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.text-danger {
color: var(--danger);
font-weight: 500;
}
</style>
<template>
<div>
<div class="page-header">
<h2>Operating Systems</h2>
<button class="btn btn-primary" @click="openModal()">+ Add OS</button>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>OS Name</th>
<th>Version</th>
<th>Architecture</th>
<th>End of Life</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="os in items" :key="os.osid">
<td>{{ os.osname }}</td>
<td>{{ os.osversion || '-' }}</td>
<td>{{ os.architecture || '-' }}</td>
<td>
<span v-if="os.endoflife" :class="{ 'text-danger': isPastEol(os.endoflife) }">
{{ os.endoflife }}
</span>
<span v-else>-</span>
</td>
<td class="actions">
<button class="btn btn-secondary btn-sm" @click="openModal(os)">Edit</button>
<button class="btn btn-danger btn-sm" @click="confirmDelete(os)">Delete</button>
</td>
</tr>
<tr v-if="items.length === 0">
<td colspan="5" style="text-align: center; color: var(--text-light);">
No operating systems 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>{{ editing ? 'Edit Operating System' : 'Add Operating System' }}</h3>
</div>
<form @submit.prevent="save">
<div class="modal-body">
<div class="form-group">
<label for="osname">OS Name *</label>
<input id="osname" v-model="form.osname" type="text" class="form-control" required placeholder="Windows 11" />
</div>
<div class="form-row">
<div class="form-group">
<label for="osversion">Version</label>
<input id="osversion" v-model="form.osversion" type="text" class="form-control" placeholder="23H2" />
</div>
<div class="form-group">
<label for="architecture">Architecture</label>
<select id="architecture" v-model="form.architecture" class="form-control">
<option value="">Select...</option>
<option value="x64">x64</option>
<option value="x86">x86</option>
<option value="ARM64">ARM64</option>
</select>
</div>
</div>
<div class="form-group">
<label for="endoflife">End of Life Date</label>
<input id="endoflife" v-model="form.endoflife" type="date" class="form-control" />
</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 Modal -->
<div v-if="showDeleteModal" class="modal-overlay" @click.self="showDeleteModal = false">
<div class="modal">
<div class="modal-header"><h3>Delete Operating System</h3></div>
<div class="modal-body">
<p>Are you sure you want to delete <strong>{{ toDelete?.osname }}</strong>?</p>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" @click="showDeleteModal = false">Cancel</button>
<button class="btn btn-danger" @click="deleteItem">Delete</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { operatingsystemsApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
import { useToast } from '../../composables/toast'
const toast = useToast()
const items = ref([])
const loading = ref(true)
const page = ref(1)
const totalPages = ref(1)
const perPage = ref(20)
const showModal = ref(false)
const editing = ref(null)
const saving = ref(false)
const error = ref('')
const showDeleteModal = ref(false)
const toDelete = ref(null)
const form = ref({ osname: '', osversion: '', architecture: '', endoflife: '' })
onMounted(() => loadData())
async function loadData() {
loading.value = true
try {
const response = await operatingsystemsApi.list({ page: page.value, perpage: perPage.value })
items.value = response.data.data || []
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (err) {
console.error('Error loading operating systems:', err)
} finally {
loading.value = false
}
}
function goToPage(p) { page.value = p; loadData() }
function changePerPage(newPerPage) {
perPage.value = newPerPage
page.value = 1
loadData()
}
function isPastEol(date) {
return new Date(date) < new Date()
}
function openModal(item = null) {
editing.value = item
form.value = item ? {
osname: item.osname || '',
osversion: item.osversion || '',
architecture: item.architecture || '',
endoflife: item.endoflife || ''
} : { osname: '', osversion: '', architecture: '', endoflife: '' }
error.value = ''
showModal.value = true
}
function closeModal() { showModal.value = false; editing.value = null }
async function save() {
error.value = ''
saving.value = true
try {
const data = { ...form.value }
if (!data.endoflife) data.endoflife = null
if (editing.value) {
await operatingsystemsApi.update(editing.value.osid, data)
} else {
await operatingsystemsApi.create(data)
}
closeModal()
loadData()
} catch (err) {
error.value = err.response?.data?.message || 'Failed to save'
} finally {
saving.value = false
}
}
function confirmDelete(item) { toDelete.value = item; showDeleteModal.value = true }
async function deleteItem() {
try {
await operatingsystemsApi.delete(toDelete.value.osid)
showDeleteModal.value = false
toDelete.value = null
loadData()
} catch (err) {
toast.error('Failed to delete')
}
}
</script>
<style scoped>
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.text-danger {
color: var(--danger);
font-weight: 500;
}
</style>

View File

@@ -86,6 +86,8 @@ import { ref, computed, onMounted } from 'vue'
import { computersApi } from '../../api'
import ColorSwatchPicker from '@/components/ColorSwatchPicker.vue'
import { colorStyle } from '@/utils/colorStyle'
import { useToast } from '../../composables/toast'
const toast = useToast()
const pcTypes = ref([])
const showInactive = ref(false)
@@ -130,7 +132,7 @@ async function deleteType(pt) {
await computersApi.types.remove(pt.computertypeid)
loadData()
} catch (err) {
alert(err.response?.data?.error?.message || err.response?.data?.message || 'Failed to delete')
toast.error(err.response?.data?.error?.message || err.response?.data?.message || 'Failed to delete')
}
}

View File

@@ -97,6 +97,8 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import { printersApi } from '../../api'
import { useToast } from '../../composables/toast'
const toast = useToast()
const items = ref([])
const models = ref([])
@@ -171,7 +173,7 @@ async function deleteDriver(d) {
await printersApi.drivers.delete(d.driverid)
loadData()
} catch (err) {
alert(err.response?.data?.error?.message || err.response?.data?.message || 'Failed to delete')
toast.error(err.response?.data?.error?.message || err.response?.data?.message || 'Failed to delete')
}
}
</script>

View File

@@ -76,6 +76,8 @@ import { ref, computed, onMounted } from 'vue'
import { printersApi } from '../../api'
import ColorSwatchPicker from '@/components/ColorSwatchPicker.vue'
import { colorStyle } from '@/utils/colorStyle'
import { useToast } from '../../composables/toast'
const toast = useToast()
const items = ref([])
const showInactive = ref(false)
@@ -136,7 +138,7 @@ async function deleteType(t) {
await printersApi.types.remove(t.printertypeid)
loadData()
} catch (err) {
alert(err.response?.data?.error?.message || err.response?.data?.message || 'Failed to delete')
toast.error(err.response?.data?.error?.message || err.response?.data?.message || 'Failed to delete')
}
}
</script>

View File

@@ -76,6 +76,8 @@ import { ref, computed, onMounted } from 'vue'
import { relationshipTypesApi } from '../../api'
import ColorSwatchPicker from '@/components/ColorSwatchPicker.vue'
import { colorStyle } from '@/utils/colorStyle'
import { useToast } from '../../composables/toast'
const toast = useToast()
const items = ref([])
const showInactive = ref(false)
@@ -136,7 +138,7 @@ async function deleteType(t) {
await relationshipTypesApi.remove(t.relationshiptypeid)
loadData()
} catch (err) {
alert(err.response?.data?.error?.message || err.response?.data?.message || 'Failed to delete')
toast.error(err.response?.data?.error?.message || err.response?.data?.message || 'Failed to delete')
}
}
</script>

View File

@@ -1,315 +1,317 @@
<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'
const statuses = ref([])
const loading = ref(true)
const page = ref(1)
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) {
page.value = p
loadStatuses()
}
function changePerPage(newPerPage) {
perPage.value = newPerPage
page.value = 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 = err.response?.data?.message || '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 = err.response?.data?.message || 'Failed to delete status'
alert(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>
<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'
const toast = useToast()
const statuses = ref([])
const loading = ref(true)
const page = ref(1)
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) {
page.value = p
loadStatuses()
}
function changePerPage(newPerPage) {
perPage.value = newPerPage
page.value = 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 = err.response?.data?.message || '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 = err.response?.data?.message || '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>

File diff suppressed because it is too large Load Diff

View File

@@ -1,374 +1,376 @@
<template>
<div>
<div class="page-header">
<h2>VLANs</h2>
<button class="btn btn-primary" @click="openModal()">+ Add VLAN</button>
</div>
<!-- Search -->
<div class="filters">
<input
v-model="search"
type="text"
class="form-control"
placeholder="Search VLANs..."
@input="debouncedSearch"
/>
<select v-model="typeFilter" class="form-control" @change="loadVLANs">
<option value="">All Types</option>
<option value="data">Data</option>
<option value="voice">Voice</option>
<option value="management">Management</option>
<option value="guest">Guest</option>
<option value="iot">IoT</option>
</select>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>VLAN #</th>
<th>Name</th>
<th>Type</th>
<th>Description</th>
<th>Subnets</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="vlan in vlans" :key="vlan.vlanid">
<td class="mono">{{ vlan.vlannumber }}</td>
<td>{{ vlan.name }}</td>
<td>
<span v-if="vlan.vlantype" class="badge" :class="getTypeClass(vlan.vlantype)">
{{ vlan.vlantype }}
</span>
<span v-else>-</span>
</td>
<td class="cell-truncate" :title="vlan.description">{{ vlan.description || '-' }}</td>
<td>
<router-link
:to="{ path: '/settings/subnets', query: { vlanid: vlan.vlanid } }"
class="subnet-link"
>
View Subnets
</router-link>
</td>
<td class="actions">
<button
class="btn btn-secondary btn-sm"
@click="openModal(vlan)"
>
Edit
</button>
<button
class="btn btn-danger btn-sm"
@click="confirmDelete(vlan)"
>
Delete
</button>
</td>
</tr>
<tr v-if="vlans.length === 0">
<td colspan="6" style="text-align: center; color: var(--text-light);">
No VLANs 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>{{ editingVLAN ? 'Edit VLAN' : 'Add VLAN' }}</h3>
</div>
<form @submit.prevent="saveVLAN">
<div class="modal-body">
<div class="form-row">
<div class="form-group">
<label for="vlannumber">VLAN Number *</label>
<input
id="vlannumber"
v-model.number="form.vlannumber"
type="number"
class="form-control"
min="1"
max="4094"
required
/>
</div>
<div class="form-group">
<label for="name">Name *</label>
<input
id="name"
v-model="form.name"
type="text"
class="form-control"
required
/>
</div>
</div>
<div class="form-group">
<label for="vlantype">VLAN Type</label>
<select id="vlantype" v-model="form.vlantype" class="form-control">
<option value="">Select Type</option>
<option value="data">Data</option>
<option value="voice">Voice</option>
<option value="management">Management</option>
<option value="guest">Guest</option>
<option value="iot">IoT</option>
</select>
</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 VLAN</h3>
</div>
<div class="modal-body">
<p>Are you sure you want to delete VLAN <strong>{{ vlanToDelete?.vlannumber }} ({{ vlanToDelete?.name }})</strong>?</p>
<p style="color: var(--text-light); font-size: 0.875rem;">
VLANs with associated subnets cannot be deleted.
</p>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" @click="showDeleteModal = false">Cancel</button>
<button class="btn btn-danger" @click="deleteVLAN">Delete</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { networkApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
const vlans = ref([])
const loading = ref(true)
const search = ref('')
const typeFilter = ref('')
const page = ref(1)
const totalPages = ref(1)
const perPage = ref(20)
const showModal = ref(false)
const editingVLAN = ref(null)
const saving = ref(false)
const error = ref('')
const showDeleteModal = ref(false)
const vlanToDelete = ref(null)
const form = ref({
vlannumber: null,
name: '',
vlantype: '',
description: ''
})
let searchTimeout = null
onMounted(() => {
loadVLANs()
})
async function loadVLANs() {
loading.value = true
try {
const params = {
page: page.value,
perpage: perPage.value
}
if (search.value) params.search = search.value
if (typeFilter.value) params.type = typeFilter.value
const response = await networkApi.vlans.list(params)
vlans.value = response.data.data || []
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (err) {
console.error('Error loading VLANs:', err)
} finally {
loading.value = false
}
}
function debouncedSearch() {
clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
page.value = 1
loadVLANs()
}, 300)
}
function goToPage(p) {
page.value = p
loadVLANs()
}
function changePerPage(newPerPage) {
perPage.value = newPerPage
page.value = 1
loadVLANs()
}
function getTypeClass(type) {
switch (type) {
case 'data': return 'badge-info'
case 'voice': return 'badge-success'
case 'management': return 'badge-warning'
case 'guest': return 'badge-secondary'
case 'iot': return 'badge-primary'
default: return 'badge-info'
}
}
function openModal(vlan = null) {
editingVLAN.value = vlan
if (vlan) {
form.value = {
vlannumber: vlan.vlannumber,
name: vlan.name || '',
vlantype: vlan.vlantype || '',
description: vlan.description || ''
}
} else {
form.value = {
vlannumber: null,
name: '',
vlantype: '',
description: ''
}
}
error.value = ''
showModal.value = true
}
function closeModal() {
showModal.value = false
editingVLAN.value = null
}
async function saveVLAN() {
error.value = ''
saving.value = true
try {
if (editingVLAN.value) {
await networkApi.vlans.update(editingVLAN.value.vlanid, form.value)
} else {
await networkApi.vlans.create(form.value)
}
closeModal()
loadVLANs()
} catch (err) {
console.error('Error saving VLAN:', err)
error.value = err.response?.data?.message || 'Failed to save VLAN'
} finally {
saving.value = false
}
}
function confirmDelete(vlan) {
vlanToDelete.value = vlan
showDeleteModal.value = true
}
async function deleteVLAN() {
try {
await networkApi.vlans.delete(vlanToDelete.value.vlanid)
showDeleteModal.value = false
vlanToDelete.value = null
loadVLANs()
} catch (err) {
console.error('Error deleting VLAN:', err)
alert(err.response?.data?.message || 'Failed to delete VLAN')
}
}
</script>
<style scoped>
.mono {
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
}
.form-row {
display: grid;
grid-template-columns: 1fr 2fr;
gap: 1rem;
}
.filters {
display: flex;
gap: 0.75rem;
margin-bottom: 1rem;
}
.filters select {
width: auto;
min-width: 150px;
}
.subnet-link {
color: var(--link);
text-decoration: none;
}
.subnet-link:hover {
text-decoration: underline;
}
.badge-primary {
background: var(--primary);
color: white;
}
.badge-secondary {
background: var(--secondary);
color: var(--text);
}
</style>
<template>
<div>
<div class="page-header">
<h2>VLANs</h2>
<button class="btn btn-primary" @click="openModal()">+ Add VLAN</button>
</div>
<!-- Search -->
<div class="filters">
<input
v-model="search"
type="text"
class="form-control"
placeholder="Search VLANs..."
@input="debouncedSearch"
/>
<select v-model="typeFilter" class="form-control" @change="loadVLANs">
<option value="">All Types</option>
<option value="data">Data</option>
<option value="voice">Voice</option>
<option value="management">Management</option>
<option value="guest">Guest</option>
<option value="iot">IoT</option>
</select>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>VLAN #</th>
<th>Name</th>
<th>Type</th>
<th>Description</th>
<th>Subnets</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="vlan in vlans" :key="vlan.vlanid">
<td class="mono">{{ vlan.vlannumber }}</td>
<td>{{ vlan.name }}</td>
<td>
<span v-if="vlan.vlantype" class="badge" :class="getTypeClass(vlan.vlantype)">
{{ vlan.vlantype }}
</span>
<span v-else>-</span>
</td>
<td class="cell-truncate" :title="vlan.description">{{ vlan.description || '-' }}</td>
<td>
<router-link
:to="{ path: '/settings/subnets', query: { vlanid: vlan.vlanid } }"
class="subnet-link"
>
View Subnets
</router-link>
</td>
<td class="actions">
<button
class="btn btn-secondary btn-sm"
@click="openModal(vlan)"
>
Edit
</button>
<button
class="btn btn-danger btn-sm"
@click="confirmDelete(vlan)"
>
Delete
</button>
</td>
</tr>
<tr v-if="vlans.length === 0">
<td colspan="6" style="text-align: center; color: var(--text-light);">
No VLANs 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>{{ editingVLAN ? 'Edit VLAN' : 'Add VLAN' }}</h3>
</div>
<form @submit.prevent="saveVLAN">
<div class="modal-body">
<div class="form-row">
<div class="form-group">
<label for="vlannumber">VLAN Number *</label>
<input
id="vlannumber"
v-model.number="form.vlannumber"
type="number"
class="form-control"
min="1"
max="4094"
required
/>
</div>
<div class="form-group">
<label for="name">Name *</label>
<input
id="name"
v-model="form.name"
type="text"
class="form-control"
required
/>
</div>
</div>
<div class="form-group">
<label for="vlantype">VLAN Type</label>
<select id="vlantype" v-model="form.vlantype" class="form-control">
<option value="">Select Type</option>
<option value="data">Data</option>
<option value="voice">Voice</option>
<option value="management">Management</option>
<option value="guest">Guest</option>
<option value="iot">IoT</option>
</select>
</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 VLAN</h3>
</div>
<div class="modal-body">
<p>Are you sure you want to delete VLAN <strong>{{ vlanToDelete?.vlannumber }} ({{ vlanToDelete?.name }})</strong>?</p>
<p style="color: var(--text-light); font-size: 0.875rem;">
VLANs with associated subnets cannot be deleted.
</p>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" @click="showDeleteModal = false">Cancel</button>
<button class="btn btn-danger" @click="deleteVLAN">Delete</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { networkApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
import { useToast } from '../../composables/toast'
const toast = useToast()
const vlans = ref([])
const loading = ref(true)
const search = ref('')
const typeFilter = ref('')
const page = ref(1)
const totalPages = ref(1)
const perPage = ref(20)
const showModal = ref(false)
const editingVLAN = ref(null)
const saving = ref(false)
const error = ref('')
const showDeleteModal = ref(false)
const vlanToDelete = ref(null)
const form = ref({
vlannumber: null,
name: '',
vlantype: '',
description: ''
})
let searchTimeout = null
onMounted(() => {
loadVLANs()
})
async function loadVLANs() {
loading.value = true
try {
const params = {
page: page.value,
perpage: perPage.value
}
if (search.value) params.search = search.value
if (typeFilter.value) params.type = typeFilter.value
const response = await networkApi.vlans.list(params)
vlans.value = response.data.data || []
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (err) {
console.error('Error loading VLANs:', err)
} finally {
loading.value = false
}
}
function debouncedSearch() {
clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
page.value = 1
loadVLANs()
}, 300)
}
function goToPage(p) {
page.value = p
loadVLANs()
}
function changePerPage(newPerPage) {
perPage.value = newPerPage
page.value = 1
loadVLANs()
}
function getTypeClass(type) {
switch (type) {
case 'data': return 'badge-info'
case 'voice': return 'badge-success'
case 'management': return 'badge-warning'
case 'guest': return 'badge-secondary'
case 'iot': return 'badge-primary'
default: return 'badge-info'
}
}
function openModal(vlan = null) {
editingVLAN.value = vlan
if (vlan) {
form.value = {
vlannumber: vlan.vlannumber,
name: vlan.name || '',
vlantype: vlan.vlantype || '',
description: vlan.description || ''
}
} else {
form.value = {
vlannumber: null,
name: '',
vlantype: '',
description: ''
}
}
error.value = ''
showModal.value = true
}
function closeModal() {
showModal.value = false
editingVLAN.value = null
}
async function saveVLAN() {
error.value = ''
saving.value = true
try {
if (editingVLAN.value) {
await networkApi.vlans.update(editingVLAN.value.vlanid, form.value)
} else {
await networkApi.vlans.create(form.value)
}
closeModal()
loadVLANs()
} catch (err) {
console.error('Error saving VLAN:', err)
error.value = err.response?.data?.message || 'Failed to save VLAN'
} finally {
saving.value = false
}
}
function confirmDelete(vlan) {
vlanToDelete.value = vlan
showDeleteModal.value = true
}
async function deleteVLAN() {
try {
await networkApi.vlans.delete(vlanToDelete.value.vlanid)
showDeleteModal.value = false
vlanToDelete.value = null
loadVLANs()
} catch (err) {
console.error('Error deleting VLAN:', err)
toast.error(err.response?.data?.message || 'Failed to delete VLAN')
}
}
</script>
<style scoped>
.mono {
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
}
.form-row {
display: grid;
grid-template-columns: 1fr 2fr;
gap: 1rem;
}
.filters {
display: flex;
gap: 0.75rem;
margin-bottom: 1rem;
}
.filters select {
width: auto;
min-width: 150px;
}
.subnet-link {
color: var(--link);
text-decoration: none;
}
.subnet-link:hover {
text-decoration: underline;
}
.badge-primary {
background: var(--primary);
color: white;
}
.badge-secondary {
background: var(--secondary);
color: var(--text);
}
</style>