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

@@ -177,6 +177,8 @@ import { Cog, Monitor, Printer, Globe, Package } from 'lucide-vue-next'
import { assetsApi, relationshipTypesApi } from '../api'
import { colorStyle } from '@/utils/colorStyle'
import { useAuthStore } from '../stores/auth'
import { useToast } from '../composables/toast'
const toast = useToast()
const props = defineProps({
assetId: {
@@ -349,7 +351,7 @@ async function saveRelationship() {
emit('updated')
} catch (error) {
console.error('Failed to create relationship:', error)
alert('Failed to create relationship: ' + (error.response?.data?.message || error.message))
toast.error('Failed to create relationship: ' + (error.response?.data?.message || error.message))
}
}
@@ -362,7 +364,7 @@ async function deleteRelationship(relationshipId) {
emit('updated')
} catch (error) {
console.error('Failed to delete relationship:', error)
alert('Failed to delete relationship')
toast.error('Failed to delete relationship')
}
}

View File

@@ -0,0 +1,75 @@
<template>
<div class="toast-host">
<transition-group name="toast">
<div
v-for="t in toast.items"
:key="t.id"
class="toast"
:class="`toast-${t.type}`"
@click="toast.dismiss(t.id)"
>
<span class="toast-message">{{ t.message }}</span>
<button class="toast-close" @click.stop="toast.dismiss(t.id)" aria-label="Dismiss">&times;</button>
</div>
</transition-group>
</div>
</template>
<script setup>
import { useToast } from '../composables/toast'
const toast = useToast()
</script>
<style scoped>
.toast-host {
position: fixed;
bottom: 1.25rem;
right: 1.25rem;
z-index: 3000;
display: flex;
flex-direction: column;
gap: 0.6rem;
max-width: min(420px, calc(100vw - 2.5rem));
}
.toast {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.75rem 0.9rem;
border-radius: 8px;
background: var(--bg-card);
color: var(--text);
border-left: 4px solid var(--secondary);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
cursor: pointer;
font-size: 0.9rem;
line-height: 1.35;
}
.toast-success { border-left-color: var(--success); }
.toast-error { border-left-color: var(--danger); }
.toast-info { border-left-color: var(--primary); }
.toast-message { flex: 1; }
.toast-close {
border: none;
background: none;
color: var(--text-light);
font-size: 1.1rem;
line-height: 1;
cursor: pointer;
padding: 0;
}
.toast-enter-active,
.toast-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.toast-enter-from,
.toast-leave-to {
opacity: 0;
transform: translateX(20px);
}
</style>

View File

@@ -0,0 +1,35 @@
// Global toast notifications. Import useToast() anywhere and call
// toast.success('...') / toast.error('...') / toast.info('...'). A single
// <ToastHost /> mounted in AppLayout renders the stack.
import { reactive } from 'vue'
const state = reactive({
items: [],
})
let nextId = 1
function dismiss(id) {
const index = state.items.findIndex(t => t.id === id)
if (index !== -1) state.items.splice(index, 1)
}
function push(message, type = 'info', duration = 5000) {
if (!message) return
const id = nextId++
state.items.push({ id, message, type })
if (duration > 0) {
setTimeout(() => dismiss(id), duration)
}
return id
}
export function useToast() {
return {
items: state.items,
dismiss,
success: (message, duration) => push(message, 'success', duration),
error: (message, duration) => push(message, 'error', duration ?? 8000),
info: (message, duration) => push(message, 'info', duration),
}
}

View File

@@ -69,12 +69,14 @@
</div>
<router-view />
</main>
<ToastHost />
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import ToastHost from '../components/ToastHost.vue'
import {
Sun, Moon, LayoutDashboard, Calendar, Map, Cog, Monitor,
Printer, Globe, Usb, AppWindow, BookOpen, BarChart3, Bell, Image, ShieldCheck

View File

@@ -109,6 +109,8 @@ import { Cog, Monitor, Printer, Globe, Package, MapPin } from 'lucide-vue-next'
import ShopFloorMap from '../components/ShopFloorMap.vue'
import { assetsApi } from '../api'
import { currentTheme } from '../stores/theme'
import { useToast } from '../composables/toast'
const toast = useToast()
const assets = ref([])
const loading = ref(true)
@@ -196,7 +198,7 @@ async function savePosition() {
pickedPosition.value = null
} catch (error) {
console.error('Failed to save position:', error)
alert('Failed to save position')
toast.error('Failed to save position')
}
}
@@ -222,7 +224,7 @@ async function clearPosition() {
pickedPosition.value = null
} catch (error) {
console.error('Failed to clear position:', error)
alert('Failed to clear position')
toast.error('Failed to clear position')
}
}

View File

@@ -85,6 +85,8 @@ import { useAuthStore } from '../stores/auth'
import { loadMapConfig, state as mapConfig } from '../composables/mapConfig'
import { exportMapPdf } from '../utils/mapPdf'
import { assetTypeLabel, assetDetailRoute } from '../utils/assetTypes'
import { useToast } from '../composables/toast'
const toast = useToast()
const router = useRouter()
const authStore = useAuthStore()
@@ -254,7 +256,7 @@ async function exportPdf() {
})
} catch (e) {
console.error('Map PDF export failed:', e)
alert('Failed to export map PDF. See console for details.')
toast.error('Failed to export map PDF. See console for details.')
} finally {
exporting.value = false
}

View File

@@ -142,6 +142,8 @@
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { employeesApi, usbApi, notificationsApi } from '@/api'
import { useToast } from '../../composables/toast'
const toast = useToast()
const route = useRoute()
@@ -244,7 +246,7 @@ async function checkinDevice(device) {
await loadCheckoutHistory()
} catch (err) {
console.error('Error checking in device:', err)
alert('Failed to check in device')
toast.error('Failed to check in device')
}
}

View File

@@ -199,6 +199,8 @@ import AssetRelationships from '../../components/AssetRelationships.vue'
import CustomFieldsSection from '../../components/CustomFieldsSection.vue'
import WarrantyPanel from '../../components/WarrantyPanel.vue'
import { useIdentifierFlags } from '../../composables/identifierSettings'
import { useToast } from '../../composables/toast'
const toast = useToast()
const { isEnabled } = useIdentifierFlags()
@@ -267,7 +269,7 @@ async function confirmDelete() {
router.push('/network')
} catch (error) {
console.error('Error deleting device:', error)
alert('Failed to delete device')
toast.error('Failed to delete device')
}
}
}

View File

@@ -101,6 +101,8 @@
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)
@@ -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

@@ -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

@@ -211,6 +211,8 @@
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([])
@@ -371,7 +373,7 @@ async function deleteLocation() {
loadLocations()
} catch (err) {
console.error('Error deleting location:', err)
alert('Failed to delete location')
toast.error('Failed to delete location')
}
}
</script>

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

@@ -183,6 +183,8 @@
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([])
@@ -345,7 +347,7 @@ async function deleteModel() {
loadModels()
} catch (err) {
console.error('Error deleting model:', err)
alert('Failed to delete model')
toast.error('Failed to delete model')
}
}
</script>

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

@@ -119,6 +119,8 @@
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)
@@ -206,7 +208,7 @@ async function deleteItem() {
toDelete.value = null
loadData()
} catch (err) {
alert('Failed to delete')
toast.error('Failed to delete')
}
}
</script>

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

@@ -139,6 +139,8 @@ 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)
@@ -251,7 +253,7 @@ async function deleteStatus() {
} catch (err) {
console.error('Error deleting status:', err)
error.value = err.response?.data?.message || 'Failed to delete status'
alert(error.value)
toast.error(error.value)
}
}

View File

@@ -285,6 +285,8 @@ import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { networkApi, locationsApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
import { useToast } from '../../composables/toast'
const toast = useToast()
const route = useRoute()
@@ -489,7 +491,7 @@ async function deleteSubnet() {
loadSubnets()
} catch (err) {
console.error('Error deleting subnet:', err)
alert(err.response?.data?.message || 'Failed to delete subnet')
toast.error(err.response?.data?.message || 'Failed to delete subnet')
}
}
</script>

View File

@@ -187,6 +187,8 @@
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)
@@ -326,7 +328,7 @@ async function deleteVLAN() {
loadVLANs()
} catch (err) {
console.error('Error deleting VLAN:', err)
alert(err.response?.data?.message || 'Failed to delete VLAN')
toast.error(err.response?.data?.message || 'Failed to delete VLAN')
}
}
</script>

View File

@@ -168,6 +168,8 @@ import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { usbApi } from '../../api'
import Modal from '../../components/Modal.vue'
import { useToast } from '../../composables/toast'
const toast = useToast()
const route = useRoute()
@@ -217,7 +219,7 @@ async function doCheckout() {
await loadDevice()
} catch (error) {
console.error('Checkout error:', error)
alert(error.response?.data?.message || 'Checkout failed')
toast.error(error.response?.data?.message || 'Checkout failed')
}
}
@@ -228,7 +230,7 @@ async function doCheckin() {
await loadDevice()
} catch (error) {
console.error('Checkin error:', error)
alert(error.response?.data?.message || 'Check in failed')
toast.error(error.response?.data?.message || 'Check in failed')
}
}

View File

@@ -147,6 +147,8 @@ import { usbApi } from '../../api'
import Modal from '../../components/Modal.vue'
import EmployeeSearch from '../../components/EmployeeSearch.vue'
import PaginationBar from '../../components/PaginationBar.vue'
import { useToast } from '../../composables/toast'
const toast = useToast()
const devices = ref([])
const loading = ref(true)
@@ -241,7 +243,7 @@ async function doCheckout() {
loadDevices()
} catch (error) {
console.error('Checkout error:', error)
alert(error.response?.data?.message || 'Checkout failed')
toast.error(error.response?.data?.message || 'Checkout failed')
}
}
@@ -252,7 +254,7 @@ async function doCheckin() {
loadDevices()
} catch (error) {
console.error('Checkin error:', error)
alert(error.response?.data?.message || 'Check in failed')
toast.error(error.response?.data?.message || 'Check in failed')
}
}

View File

@@ -186,6 +186,8 @@
import { ref, onMounted } from 'vue'
import { vendorsApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
import { useToast } from '../../composables/toast'
const toast = useToast()
const vendors = ref([])
const loading = ref(true)
@@ -321,7 +323,7 @@ async function deleteVendor() {
loadVendors()
} catch (err) {
console.error('Error deleting vendor:', err)
alert('Failed to delete vendor')
toast.error('Failed to delete vendor')
}
}
</script>

View File

@@ -5,6 +5,7 @@
<button class="btn btn-primary" @click="openModal()">+ Add Warranty</button>
</div>
<div class="filters">
<label>Status
<select v-model="statusFilter" class="form-control" @change="loadData">
@@ -138,6 +139,9 @@
import { ref, onMounted } from 'vue'
import { colorStyle } from '@/utils/colorStyle'
import { warrantyApi, assetsApi } from '../../api'
import { useToast } from '../../composables/toast'
const toast = useToast()
const items = ref([])
const loading = ref(true)
@@ -261,7 +265,7 @@ async function deleteWarranty(w) {
await warrantyApi.remove(w.warrantyid)
loadData()
} catch (err) {
alert(apiError(err, 'Failed to delete'))
toast.error(apiError(err, 'Failed to delete'))
}
}
@@ -270,9 +274,11 @@ async function refresh(w) {
const response = await warrantyApi.refresh(w.warrantyid)
loadData()
const updated = response.data?.data
if (updated) alert(`Refreshed: ${updated.vendor} - ${updated.servicelevel || 'coverage'} ends ${updated.enddate || 'unknown'}`)
if (updated) {
toast.success(`${updated.vendor}: ${updated.servicelevel || 'coverage'} ends ${updated.enddate || 'unknown'}`)
}
} catch (err) {
alert(apiError(err, 'Refresh failed'))
toast.error(apiError(err, 'Refresh failed'))
}
}
</script>