- config/dispatch-priority.js (NOUVEAU) : DISPATCH_PRIORITIES = SOURCE UNIQUE des 3 niveaux (Urgent/Moyenne/Basse, couleurs 🔴🟠⚪) — élimine 5 copies locales (UnifiedCreateModal, TaskNode, CreateOfferModal, TechJobDetailPage, PlanificationPage POOL_PRIOS) et corrige la dérive « Moyenne » bleue de CreateOfferModal - PlanificationPage/TechJobDetailPage : éditeur de compétences SkillSelect → /roster/job-skills ; FIX : SkillSelect émet une CSV (pas un tableau) → les handlers n'effaçaient plus les compétences ; panneau job (statut/reporter/réserver un tech) ; sommaire avant publication + auto-save + journal + annuler - UnifiedCreateModal/ProjectWizard/QuoteWizard/TaskNode : formulaire de création unifié, divulgation progressive, dédup client (courriel/téléphone) + message d'erreur convivial ; suppression de NewTicketDialog (fusionné) - IssueDetail : fil de ticket (nom d'expéditeur) ; app terrain lecture/commentaire - useConversationDisplay/useHelpers/wizard-constants : priorités alignées sur 3 niveaux Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
699 lines
27 KiB
Vue
699 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' },
|
|
]
|
|
// Priorité Dispatch Job — SOURCE UNIQUE (config/dispatch-priority).
|
|
import { DISPATCH_PRIORITIES as priorities } from 'src/config/dispatch-priority'
|
|
|
|
// ── 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>
|