gigafibre-fsm/apps/ops/src/modules/tech/pages/TechJobDetailPage.vue
louispaulb 512c4a5f1b feat(ops): dispatch auto complet + perf Boîte/rapports + fix session
Planificateur « Suggérer » : 4 stratégies (smart / meilleurs d'abord / équilibré /
juste ce qu'il faut), compétences+niveaux par tech (édition inline), niveau requis
par compétence + par job, carte des tournées (1 couleur/tech, domicile→arrêts,
sélecteur de jour), fenêtre de dispatch auj.+demain (dates sélectionnées), règle
week-end + placeholder « en attente du quart », clustering + lasso + filtre-date
sur la carte, accès rapide « À assigner » (badge).

Boîte : liste /conversations allégée (45 Mo → ~1 Mo, 17×) + messages chargés à
l'ouverture. Rapports : cache SWR sur revenue-explorer (22×). Session : keep-alive
+ timeout fetch global + authFetch durci → fin des rechargements manuels.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 08:49:35 -04:00

703 lines
27 KiB
Vue

<template>
<q-page class="tech-surface">
<!-- Collapsed topbar with translucent blur -->
<header class="detail-topbar">
<button class="topbar-btn" @click="$router.back()" aria-label="Retour">
<span class="material-icons">arrow_back</span>
</button>
<div class="topbar-title">
<div class="t-caption" style="color:var(--tg-700);font-weight:700">{{ job?.name || '…' }}</div>
<div class="topbar-subject">{{ job?.subject || 'Job' }}</div>
</div>
<button class="topbar-btn" @click="menuOpen = !menuOpen" aria-label="Menu">
<span class="material-icons">more_vert</span>
</button>
<!-- Popover menu -->
<transition name="fade">
<div v-if="menuOpen" class="topbar-menu" @click.self="menuOpen = false">
<div class="topbar-menu-card">
<button class="topbar-menu-item" @click="openInErp(); menuOpen = false">
<span class="material-icons">open_in_new</span> Ouvrir dans ERPNext
</button>
</div>
</div>
</transition>
</header>
<!-- Loading state -->
<div v-if="loading" class="loading-wrap">
<div class="loading-spinner" />
</div>
<template v-if="job && !loading">
<!-- Status hero strip -->
<div class="status-hero">
<span class="tg-chip" :class="statusChipClass">
<span class="tg-chip-dot" />
{{ statusLabel }}
</span>
<div v-if="job.priority === 'urgent' || job.priority === 'high'" style="margin-left:8px">
<span class="tg-chip" :class="job.priority === 'urgent' ? 'is-stop' : 'is-work'">
{{ job.priority === 'urgent' ? 'Urgent' : 'Haute priorité' }}
</span>
</div>
</div>
<div class="tg-page tg-page--content">
<!-- Location + GPS -->
<section v-if="locationAddress || job.customer_name" class="tg-card tg-card--wash section-card">
<div class="section-head-inline t-micro">Client & adresse</div>
<div v-if="job.customer_name" class="info-line">
<span class="material-icons info-icon">person</span>
<span class="t-body">{{ job.customer_name }}</span>
</div>
<div v-if="locationAddress" class="info-line">
<span class="material-icons info-icon" style="color:var(--sig-stop)">place</span>
<span class="t-body" style="flex:1">{{ locationAddress }}</span>
<button class="tg-btn is-ghost is-compact" @click="openGps">
<span class="material-icons" style="font-size:18px">navigation</span>
GPS
</button>
</div>
<div v-if="locationDetail?.contact_name" class="info-line" style="gap:8px">
<span class="material-icons info-icon">support_agent</span>
<span class="t-body-sm">
{{ locationDetail.contact_name }}
<span v-if="locationDetail.contact_phone"> · {{ locationDetail.contact_phone }}</span>
</span>
</div>
</section>
<!-- Job info (editable fields — kept compact) -->
<section class="tg-card section-card">
<div class="section-head-inline t-micro">Détails</div>
<label class="field-wrap">
<span class="field-label">Sujet</span>
<input v-model="job.subject" class="field-input"
@blur="saveField('subject', job.subject)" />
</label>
<div class="field-row-pair">
<label class="field-wrap">
<span class="field-label">Type</span>
<select v-model="job.job_type" class="field-input"
@change="saveField('job_type', job.job_type)">
<option v-for="t in jobTypes" :key="t.value" :value="t.value">{{ t.label }}</option>
</select>
</label>
<label class="field-wrap">
<span class="field-label">Priorité</span>
<select v-model="job.priority" class="field-input"
@change="saveField('priority', job.priority)">
<option v-for="p in priorities" :key="p.value" :value="p.value">{{ p.label }}</option>
</select>
</label>
</div>
<div class="field-row-pair">
<label class="field-wrap">
<span class="field-label">Heure</span>
<input v-model="job.scheduled_time" type="time" class="field-input"
@blur="saveField('scheduled_time', job.scheduled_time)" />
</label>
<label class="field-wrap">
<span class="field-label">Durée (h)</span>
<input v-model.number="job.duration_h" type="number" step="0.5" min="0.5" max="12" class="field-input"
@blur="saveField('duration_h', job.duration_h)" />
</label>
</div>
<label class="field-wrap">
<span class="field-label">Notes</span>
<textarea v-model="job.description" rows="3" class="field-input field-input--ta"
@blur="saveField('description', job.description)" />
</label>
</section>
<!-- Equipment -->
<section class="tg-card section-card">
<div class="section-head-row">
<span class="t-micro">Équipements · {{ equipment.length }}</span>
<button class="tg-btn is-text" @click="addEquipmentMenu = true">
<span class="material-icons" style="font-size:18px">add</span> Ajouter
</button>
</div>
<div v-if="loadingEquip" class="text-center" style="padding:24px">
<div class="loading-spinner loading-spinner--sm" />
</div>
<div v-else-if="equipment.length" class="equip-list">
<button v-for="eq in equipment" :key="eq.name" class="equip-row"
@click="$router.push({ name: 'tech-device', params: { serial: eq.serial_number } })">
<span class="material-icons equip-icon" :style="{ color: eqStatusColor(eq.status) }">
{{ eqIcon(eq.equipment_type) }}
</span>
<div class="equip-text">
<div class="t-body">{{ eq.equipment_type }} — {{ eq.brand }} {{ eq.model }}</div>
<div class="t-caption mono">{{ eq.serial_number }}</div>
</div>
<span class="tg-chip" :class="eqStatusChip(eq.status)" style="margin-left:auto">
{{ eq.status || '—' }}
</span>
</button>
</div>
<div v-else class="empty-inline t-caption">Aucun équipement lié</div>
</section>
</div>
<!-- ── Floating CTA pill (always in thumb reach) ─────────────────────── -->
<div class="tg-fab-cta">
<button v-if="isScheduled"
class="tg-btn is-primary is-block"
@click="updateStatus('In Progress')" :disabled="saving">
<span class="material-icons">directions_car</span>
En route vers le client
</button>
<button v-else-if="isInProgress"
class="tg-btn is-success is-block"
@click="updateStatus('Completed')" :disabled="saving">
<span class="material-icons">check_circle</span>
Marquer comme terminée
</button>
<button v-else-if="isCompleted"
class="tg-btn is-ghost is-block"
@click="updateStatus('In Progress')" :disabled="saving">
<span class="material-icons">replay</span>
Rouvrir la tâche
</button>
<!-- Testing shortcut: jump straight to Completed -->
<button v-if="canTerminateDirect"
class="tg-btn is-outline is-compact is-block"
style="margin-top:8px"
@click="updateStatus('Completed')" :disabled="saving">
<span class="material-icons" style="font-size:18px">fast_forward</span>
Terminer directement (test)
</button>
</div>
</template>
<!-- Add-equipment bottom sheet -->
<transition name="fade">
<div v-if="addEquipmentMenu" class="tg-peek-backdrop is-open" @click="addEquipmentMenu = false" />
</transition>
<div class="tg-peek" :class="{ 'is-open': addEquipmentMenu }">
<div class="tg-peek-handle" />
<div style="padding: 4px 20px 20px">
<div class="t-h2" style="margin-bottom:12px">Ajouter un équipement</div>
<button class="equip-option" @click="addEquipmentMenu = false; goScan()">
<span class="material-icons equip-option-icon" style="color:var(--tg-700)">qr_code_scanner</span>
<div>
<div class="t-body">Scanner un code-barres / QR</div>
<div class="t-caption">Caméra + détection auto du SN ou MAC</div>
</div>
</button>
<button class="equip-option" @click="addEquipmentMenu = false; searchEquipDialog = true">
<span class="material-icons equip-option-icon" style="color:var(--sig-work)">search</span>
<div>
<div class="t-body">Rechercher un équipement existant</div>
<div class="t-caption">Par numéro de série ou MAC</div>
</div>
</button>
<button class="equip-option" @click="addEquipmentMenu = false; createEquipDialog = true">
<span class="material-icons equip-option-icon" style="color:var(--sig-go)">add_circle</span>
<div>
<div class="t-body">Créer un nouvel équipement</div>
<div class="t-caption">Saisir manuellement</div>
</div>
</button>
</div>
</div>
<!-- Search existing equipment (basic dialog, stays Quasar for now) -->
<q-dialog v-model="searchEquipDialog">
<q-card style="min-width:340px;border-radius:20px">
<q-card-section class="text-h6">Rechercher un équipement</q-card-section>
<q-card-section>
<q-input v-model="eqSearchText" label="Numéro de série ou MAC" outlined dense autofocus
@keyup.enter="searchEquipment" debounce="400" @update:model-value="searchEquipment">
<template v-slot:append><q-icon name="search" /></template>
</q-input>
<q-list v-if="eqSearchResults.length" bordered separator class="q-mt-sm" style="max-height:250px;overflow-y:auto">
<q-item v-for="eq in eqSearchResults" :key="eq.name" clickable @click="linkEquipToJob(eq)">
<q-item-section>
<q-item-label>{{ eq.equipment_type }} — {{ eq.brand }} {{ eq.model }}</q-item-label>
<q-item-label caption class="mono">SN: {{ eq.serial_number }}</q-item-label>
<q-item-label caption v-if="eq.customer_name">Client: {{ eq.customer_name }}</q-item-label>
</q-item-section>
<q-item-section side><q-icon name="link" color="primary" /></q-item-section>
</q-item>
</q-list>
<div v-if="eqSearchText && !eqSearchResults.length && !eqSearching" class="text-caption text-grey q-mt-sm text-center">
Aucun résultat
</div>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Fermer" v-close-popup />
</q-card-actions>
</q-card>
</q-dialog>
<!-- Create new equipment -->
<q-dialog v-model="createEquipDialog">
<q-card style="min-width:340px;border-radius:20px">
<q-card-section class="text-h6">Nouvel équipement</q-card-section>
<q-card-section>
<q-input v-model="newEquip.serial_number" label="Numéro de série" outlined dense class="q-mb-sm" autofocus />
<q-select v-model="newEquip.equipment_type" :options="eqTypes" label="Type" outlined dense class="q-mb-sm" />
<q-input v-model="newEquip.brand" label="Marque" outlined dense class="q-mb-sm" />
<q-input v-model="newEquip.model" label="Modèle" outlined dense class="q-mb-sm" />
<q-input v-model="newEquip.mac_address" label="MAC (optionnel)" outlined dense class="q-mb-sm" />
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Annuler" v-close-popup />
<q-btn color="primary" label="Créer & Lier" :loading="creatingEquip" @click="createAndLinkEquip" />
</q-card-actions>
</q-card>
</q-dialog>
</q-page>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Notify } from 'quasar'
import { getDoc, listDocs, createDoc, updateDoc } from 'src/api/erp'
import { BASE_URL } from 'src/config/erpnext'
import { HUB_URL } from 'src/config/hub'
const route = useRoute()
const router = useRouter()
const jobName = computed(() => route.params.name)
const loading = ref(true)
const saving = ref(false)
const menuOpen = ref(false)
const job = ref(null)
const locationDetail = ref(null)
const equipment = ref([])
const loadingEquip = ref(false)
// Add-equipment dialog state
const addEquipmentMenu = ref(false)
const searchEquipDialog = ref(false)
const createEquipDialog = ref(false)
const eqSearchText = ref('')
const eqSearchResults = ref([])
const eqSearching = ref(false)
const creatingEquip = ref(false)
const eqTypes = ['ONT', 'Modem', 'Routeur', 'Décodeur TV', 'VoIP', 'Amplificateur', 'Splitter', 'Câble', 'Autre']
const newEquip = ref({ serial_number: '', equipment_type: 'ONT', brand: '', model: '', mac_address: '' })
const jobTypes = [
{ label: 'Installation', value: 'Installation' },
{ label: 'Réparation', value: 'Repair' },
{ label: 'Maintenance', value: 'Maintenance' },
{ label: 'Inspection', value: 'Inspection' },
{ label: 'Livraison', value: 'Delivery' },
{ label: 'Autre', value: 'Other' },
]
const priorities = [
{ label: 'Basse', value: 'low' },
{ label: 'Moyenne', value: 'medium' },
{ label: 'Haute', value: 'high' },
{ label: 'Urgente', value: 'urgent' },
]
// ── Status helpers ─────────────────────────────────────────────────────────
const isScheduled = computed(() => job.value && ['Scheduled', 'assigned', 'open', 'Open', 'Assigned'].includes(job.value.status))
const isInProgress = computed(() => job.value && ['In Progress', 'in_progress'].includes(job.value.status))
const isCompleted = computed(() => job.value?.status === 'Completed')
const canTerminateDirect = computed(() => // raccourci de TEST seulement (dev) — jamais en prod
import.meta.env.DEV && job.value && !isInProgress.value && !isCompleted.value && job.value.status !== 'Cancelled'
)
const statusLabel = computed(() => {
const map = {
Scheduled: 'Planifié', assigned: 'Assigné', open: 'À faire', Open: 'À faire', Assigned: 'Assigné',
'In Progress': 'En cours', in_progress: 'En cours',
Completed: 'Terminé', Cancelled: 'Annulé', 'On Hold': 'En attente',
}
return map[job.value?.status] || job.value?.status || ''
})
const statusChipClass = computed(() => {
const s = job.value?.status
if (isCompleted.value) return 'is-go'
if (isInProgress.value) return 'is-work'
if (s === 'Cancelled') return 'is-wait'
if (s === 'On Hold') return 'is-wait'
return 'is-info'
})
const locationAddress = computed(() => {
const loc = locationDetail.value
if (!loc) return job.value?.service_location_name || ''
return [loc.address_line, loc.city, loc.postal_code].filter(Boolean).join(', ')
})
// ── Equipment display helpers ──────────────────────────────────────────────
function eqIcon (type) {
const map = { ONT: 'settings_input_hdmi', Modem: 'router', Routeur: 'wifi', 'Décodeur TV': 'tv', VoIP: 'phone' }
return map[type] || 'memory'
}
function eqStatusColor (s) {
if (s === 'Actif') return 'var(--sig-go)'
if (s === 'Défectueux' || s === 'Perdu') return 'var(--sig-stop)'
return 'var(--ink-400)'
}
function eqStatusChip (s) {
if (s === 'Actif') return 'is-go'
if (s === 'Défectueux' || s === 'Perdu') return 'is-stop'
return 'is-wait'
}
// ── Load job + related data ────────────────────────────────────────────────
async function loadJob () {
loading.value = true
try {
job.value = await getDoc('Dispatch Job', jobName.value)
if (job.value.service_location) {
getDoc('Service Location', job.value.service_location)
.then(loc => { locationDetail.value = loc })
.catch(() => {})
}
loadEquipment()
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur chargement job: ' + e.message })
} finally {
loading.value = false
}
}
async function loadEquipment () {
if (!job.value?.service_location && !job.value?.customer) return
loadingEquip.value = true
try {
const filters = job.value.service_location
? { service_location: job.value.service_location }
: { customer: job.value.customer }
equipment.value = await listDocs('Service Equipment', {
filters,
fields: ['name', 'serial_number', 'equipment_type', 'brand', 'model', 'mac_address', 'status', 'customer_name'],
limit: 50,
})
} catch { equipment.value = [] }
finally { loadingEquip.value = false }
}
// ── Save / status ──────────────────────────────────────────────────────────
async function saveField (field, value) {
if (!job.value?.name) return
try { await updateDoc('Dispatch Job', job.value.name, { [field]: value }) }
catch (e) { Notify.create({ type: 'negative', message: 'Erreur sauvegarde: ' + e.message }) }
}
/**
* Status changes go through targo-hub /dispatch/job-status — that endpoint
* is the single place where the chain-walk lives (unblocks depends_on
* children on Completed, broadcasts SSE). Direct ERPNext PUTs would skip
* both and the chain wouldn't advance.
*/
async function updateStatus (status) {
saving.value = true
try {
const res = await fetch(`${HUB_URL}/dispatch/job-status`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ job: job.value.name, status }),
})
if (!res.ok) throw new Error(`Status update failed (${res.status})`)
const result = await res.json()
job.value.status = status
const msg = status === 'Completed'
? (result.unblocked?.length
? `Tâche terminée — ${result.unblocked.length} prochaine${result.unblocked.length > 1 ? 's' : ''} débloquée${result.unblocked.length > 1 ? 's' : ''}`
: 'Tâche terminée ✓')
: status === 'In Progress' ? 'En route ! 🚗'
: status === 'Scheduled' ? 'Tâche rouverte' : status
Notify.create({ type: 'positive', message: msg, icon: status === 'Completed' ? 'check_circle' : 'directions_car' })
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message })
} finally { saving.value = false }
}
// ── GPS & misc ─────────────────────────────────────────────────────────────
function openGps () {
const loc = locationDetail.value
if (loc?.latitude && loc?.longitude) {
window.open(`https://www.google.com/maps/dir/?api=1&destination=${loc.latitude},${loc.longitude}`, '_blank')
return
}
const addr = loc
? [loc.address_line, loc.city, loc.postal_code].filter(Boolean).join(', ')
: (job.value?.service_location_name || '')
window.open(`https://www.google.com/maps/dir/?api=1&destination=${encodeURIComponent(addr)}`, '_blank')
}
function openInErp () {
window.open(`${BASE_URL}/app/dispatch-job/${job.value.name}`, '_blank')
}
function goScan () {
router.push({
name: 'tech-scan',
query: {
job: job.value.name,
customer: job.value.customer,
customer_name: job.value.customer_name,
location: job.value.service_location,
location_name: job.value.service_location_name,
},
})
}
// ── Equipment search / create / link ───────────────────────────────────────
async function searchEquipment () {
const text = eqSearchText.value?.trim()
if (!text || text.length < 2) { eqSearchResults.value = []; return }
eqSearching.value = true
try {
let results = await listDocs('Service Equipment', {
filters: { serial_number: ['like', `%${text}%`] },
fields: ['name', 'serial_number', 'equipment_type', 'brand', 'model', 'mac_address', 'customer_name', 'status'],
limit: 10,
})
if (!results.length) {
results = await listDocs('Service Equipment', {
filters: { mac_address: ['like', `%${text}%`] },
fields: ['name', 'serial_number', 'equipment_type', 'brand', 'model', 'mac_address', 'customer_name', 'status'],
limit: 10,
})
}
eqSearchResults.value = results
} catch { eqSearchResults.value = [] }
finally { eqSearching.value = false }
}
async function linkEquipToJob (eq) {
try {
const updates = {}
if (job.value.customer) updates.customer = job.value.customer
if (job.value.service_location) updates.service_location = job.value.service_location
await updateDoc('Service Equipment', eq.name, updates)
equipment.value.push(eq)
searchEquipDialog.value = false
eqSearchText.value = ''
eqSearchResults.value = []
Notify.create({ type: 'positive', message: `${eq.equipment_type} lié au job`, icon: 'link' })
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message })
}
}
async function createAndLinkEquip () {
if (!newEquip.value.serial_number?.trim()) {
Notify.create({ type: 'warning', message: 'Numéro de série requis' })
return
}
creatingEquip.value = true
try {
const data = {
...newEquip.value,
status: 'Actif',
customer: job.value.customer || '',
service_location: job.value.service_location || '',
}
const doc = await createDoc('Service Equipment', data)
equipment.value.push(doc)
Notify.create({ type: 'positive', message: 'Équipement créé et lié', icon: 'check_circle' })
createEquipDialog.value = false
newEquip.value = { serial_number: '', equipment_type: 'ONT', brand: '', model: '', mac_address: '' }
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message })
} finally {
creatingEquip.value = false
}
}
onMounted(loadJob)
</script>
<style lang="scss" scoped>
// Floating FAB is 52px tall + 16px margin + 88px tab-island clearance.
// Reserve additional bottom room on this page so scroll reaches the final
// card without it being hidden under the CTA pill.
.tech-surface.q-page { padding-bottom: 188px !important; }
// Topbar (translucent, blur, sticky)
.detail-topbar {
position: sticky; top: 0; z-index: 10;
display: flex; align-items: center;
padding: calc(env(safe-area-inset-top) + 8px) 12px 8px;
background: var(--surface-over);
backdrop-filter: saturate(180%) blur(22px);
-webkit-backdrop-filter: saturate(180%) blur(22px);
border-bottom: 1px solid rgba(15,23,42,0.05);
}
.topbar-btn {
width: 40px; height: 40px;
border: 0; background: transparent;
border-radius: 50%;
display: inline-flex; align-items: center; justify-content: center;
cursor: pointer; color: var(--ink-700);
-webkit-tap-highlight-color: transparent;
&:active { background: var(--tg-50); }
}
.topbar-title {
flex: 1; min-width: 0; text-align: center;
padding: 0 4px;
}
.topbar-subject {
font: 700 0.9375rem/1.25 system-ui;
color: var(--ink-900);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.topbar-menu {
position: fixed; inset: 0;
z-index: 50;
background: rgba(15,23,42,0.2);
backdrop-filter: blur(2px);
display: flex; align-items: flex-start; justify-content: flex-end;
padding: 60px 8px 0;
}
.topbar-menu-card {
background: var(--surface-elev);
border-radius: var(--r-md);
box-shadow: var(--elev-3);
padding: 4px;
min-width: 220px;
}
.topbar-menu-item {
display: flex; align-items: center; gap: 10px;
width: 100%; padding: 10px 14px;
border: 0; background: transparent;
border-radius: var(--r-sm);
font: var(--t-body-sm);
text-align: left; cursor: pointer;
&:active { background: var(--tg-50); }
.material-icons { font-size: 18px; color: var(--ink-500); }
}
// Status hero strip
.status-hero {
display: flex; align-items: center;
padding: 14px 18px;
background: var(--surface-wash);
}
// Section card spacing
.section-card {
margin-bottom: 14px;
}
.section-head-inline {
margin-bottom: 10px;
}
.section-head-row {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 10px;
}
// Inline info lines (for location card)
.info-line {
display: flex; align-items: center; gap: 10px;
padding: 8px 0;
&:not(:last-child) { border-bottom: 1px solid rgba(15,23,42,0.05); }
.info-icon { font-size: 18px; color: var(--ink-500); }
}
// Form fields — custom, no Quasar chrome
.field-wrap {
display: block; margin-bottom: 12px;
&:last-child { margin-bottom: 0; }
}
.field-label {
display: block;
font: var(--t-caption);
color: var(--ink-500);
margin-bottom: 4px;
}
.field-input {
width: 100%;
min-height: 44px;
padding: 10px 14px;
border: 1.5px solid var(--ink-300);
border-radius: var(--r-sm);
background: var(--surface-elev);
font: var(--t-body);
color: var(--ink-900);
outline: none;
transition: border 160ms, box-shadow 160ms;
&:focus {
border-color: var(--tg-500);
box-shadow: 0 0 0 4px rgba(92,89,168,0.12);
}
}
.field-input--ta {
min-height: 80px; resize: vertical;
font: var(--t-body-sm);
}
.field-row-pair {
display: grid; grid-template-columns: 1fr 1fr; gap: 12px;
margin-bottom: 12px;
}
// Equipment list
.equip-list {
display: flex; flex-direction: column; gap: 8px;
}
.equip-row {
display: flex; align-items: center; gap: 12px;
padding: 12px;
border: 0;
background: var(--surface-wash);
border-radius: var(--r-md);
cursor: pointer;
text-align: left;
-webkit-tap-highlight-color: transparent;
transition: background 160ms;
&:active { background: var(--tg-100); }
}
.equip-icon { font-size: 22px; flex-shrink: 0; }
.equip-text { flex: 1; min-width: 0; }
.mono { font-family: 'SF Mono', 'Menlo', 'Fira Code', monospace; font-size: 0.85em; }
.equip-option {
display: flex; align-items: center; gap: 14px;
width: 100%; padding: 14px;
border: 0; background: transparent;
border-radius: var(--r-md);
text-align: left; cursor: pointer;
margin-bottom: 4px;
-webkit-tap-highlight-color: transparent;
&:active { background: var(--tg-50); }
}
.equip-option-icon { font-size: 28px; flex-shrink: 0; }
.empty-inline {
text-align: center; padding: 16px;
color: var(--ink-500);
}
// Loading
.loading-wrap {
display: flex; align-items: center; justify-content: center;
padding: 80px 0;
}
.loading-spinner {
width: 40px; height: 40px;
border: 3px solid var(--tg-100);
border-top-color: var(--tg-500);
border-radius: 50%;
animation: lspin 0.8s linear infinite;
&--sm { width: 24px; height: 24px; border-width: 2px; }
}
@keyframes lspin { to { transform: rotate(360deg); } }
.fade-enter-active, .fade-leave-active { transition: opacity 200ms; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
</style>