gigafibre-fsm/apps/dispatch/src/pages/MobilePage.vue
louispaulb 7da22ff132 merge: import dispatch-app into apps/dispatch/ (17 commits preserved)
Integrates the Dispatch PWA (Vue/Quasar) into the gigafibre-fsm monorepo.
Full git history accessible via `git log -- apps/dispatch/`.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 08:08:51 -04:00

701 lines
34 KiB
Vue

<script setup>
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { useAuthStore } from 'src/stores/auth'
import { useDispatchStore } from 'src/stores/dispatch'
import { fetchTechnicians } from 'src/api/dispatch'
import { createEquipmentInstall } from 'src/api/service-request'
const auth = useAuthStore()
const store = useDispatchStore()
// ── UI state ────────────────────────────────────────────────────────────────
const phase = ref('loading') // 'loading' | 'login' | 'select-tech' | 'jobs'
const tab = ref('jobs') // 'jobs' | 'equipment' | 'map' | 'profile'
const showCompleted = ref(false)
const showToast = ref(false)
const toastMsg = ref('')
const detailJob = ref(null)
// Login form
const loginUser = ref('')
const loginPass = ref('')
const showPwd = ref(false)
// Tech selector
const techList = ref([])
const selTechId = ref('')
const selTech = computed(() => techList.value.find(t => t.name === selTechId.value) || null)
const techName = computed(() => selTech.value?.fullName || selTech.value?.name || '')
const COLORS = ['#6366f1','#10b981','#f59e0b','#8b5cf6','#06b6d4','#f43f5e','#f97316','#14b8a6']
const techColor = computed(() => {
const idx = techList.value.indexOf(selTech.value)
return COLORS[idx >= 0 ? idx % COLORS.length : 0]
})
const initials = computed(() =>
(techName.value || 'T').split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)
)
const today = computed(() =>
new Date().toLocaleDateString('fr-FR', { weekday: 'long', day: 'numeric', month: 'long' })
)
// ── Job lists ────────────────────────────────────────────────────────────────
const myJobs = computed(() => store.jobs)
const activeJob = computed(() => myJobs.value.find(j => j.status === 'in_progress') || null)
const upcomingJobs = computed(() =>
myJobs.value.filter(j => !j.completed && j.status !== 'in_progress' && j.status !== 'completed')
.sort((a, b) => (a.routeOrder || 99) - (b.routeOrder || 99))
)
const completedJobs = computed(() => myJobs.value.filter(j => j.status === 'completed'))
const stats = computed(() => [
{ lbl: 'Total', val: myJobs.value.length },
{ lbl: 'A faire', val: upcomingJobs.value.length + (activeJob.value ? 1 : 0) },
{ lbl: 'Faits', val: completedJobs.value.length },
])
// ── Auth + boot ──────────────────────────────────────────────────────────────
async function loadTechs () {
const raw = await fetchTechnicians()
techList.value = raw.map((t, idx) => ({
name: t.name,
fullName: t.full_name || t.name,
techId: t.technician_id || t.name,
user: t.user || null,
colorIdx: idx,
}))
const linked = techList.value.find(t => t.user === auth.user)
selTechId.value = linked ? linked.name : (techList.value[0]?.name || '')
}
async function boot () {
await auth.checkSession()
if (auth.user) {
loginUser.value = auth.user
await loadTechs()
phase.value = 'select-tech'
} else {
phase.value = 'login'
}
}
async function doLogin () {
await auth.doLogin(loginUser.value, loginPass.value)
if (auth.user) {
await loadTechs()
phase.value = 'select-tech'
}
}
async function doLogout () {
await auth.doLogout()
store.jobs = []
selTechId.value = ''
phase.value = 'login'
}
async function loadJobs () {
if (!selTechId.value || !selTech.value) return
await store.loadJobsForTech(selTech.value.techId)
phase.value = 'jobs'
}
// ── Actions ──────────────────────────────────────────────────────────────────
async function markComplete (job) {
if (!job || job.status === 'completed') return
await store.setJobStatus(job.id, 'completed')
job.status = 'completed'
toast(job.id + ' complété !')
}
async function markEnRoute (job) {
if (!job) return
myJobs.value.forEach(j => { if (j.status === 'in_progress') j.status = 'assigned' })
await store.setJobStatus(job.id, 'in_progress')
job.status = 'in_progress'
detailJob.value = null
toast('En route vers ' + job.id)
}
function toast (msg) {
toastMsg.value = msg
showToast.value = true
setTimeout(() => { showToast.value = false }, 2800)
}
function startTime (idx) {
let m = 8 * 60
if (activeJob.value) {
m += (parseInt(activeJob.value.legDur) || 0) + (parseFloat(activeJob.value.duration) || 1) * 60
}
for (let i = 0; i < idx; i++) {
const j = upcomingJobs.value[i]
m += (parseInt(j.legDur) || 0) + (parseFloat(j.duration) || 1) * 60
}
m += parseInt(upcomingJobs.value[idx]?.legDur) || 0
return String(Math.floor(m / 60)).padStart(2, '0') + 'h' + String(m % 60).padStart(2, '0')
}
function prioLbl (p) { return { high: 'Urgent', medium: 'Moyen', low: 'Faible' }[p] || p }
function prioStyle (p) {
return {
high: 'background:#fef2f2;color:#dc2626',
medium: 'background:#fffbeb;color:#d97706',
low: 'background:#f0fdf4;color:#16a34a',
}[p] || ''
}
function mapsUrl (addr) { return 'https://maps.google.com/?q=' + encodeURIComponent(addr) }
// ── Equipment / Barcode ───────────────────────────────────────────────────────
const EQUIPMENT_TYPES = ['Modem', 'Routeur', 'Décodeur TV', 'Téléphone IP', 'Câble coaxial', 'Amplificateur', 'Splitter', 'ONT/ONU', 'Autre']
const eqRequestName = ref('') // which service request we're working on
const eqItems = ref([]) // array of equipment items to submit
const eqSubmitting = ref(false)
const eqDone = ref(false)
const scannerActive = ref(false)
let _scanner = null
const eqJobs = computed(() =>
myJobs.value.filter(j => j.status !== 'completed')
)
function newEqItem (barcode = '') {
return { barcode, equipment_type: 'Modem', brand: '', model: '', notes: '', photo_base64: '', _id: Date.now() + Math.random() }
}
function addEqItem () {
eqItems.value.push(newEqItem())
}
function removeEqItem (item) {
eqItems.value = eqItems.value.filter(e => e._id !== item._id)
}
async function startScanner () {
scannerActive.value = true
await nextTick()
try {
const { Html5Qrcode } = await import('html5-qrcode')
_scanner = new Html5Qrcode('qr-reader')
await _scanner.start(
{ facingMode: 'environment' },
{ fps: 10, qrbox: { width: 260, height: 80 } },
(decoded) => {
stopScanner()
const existing = eqItems.value.find(e => e.barcode === decoded)
if (!existing) {
eqItems.value.push(newEqItem(decoded))
toast('Scanné : ' + decoded)
} else {
toast('Déjà dans la liste')
}
},
() => {}
)
} catch (e) {
scannerActive.value = false
toast('Caméra non disponible')
}
}
async function stopScanner () {
if (_scanner) {
await _scanner.stop().catch(() => {})
_scanner = null
}
scannerActive.value = false
}
function onPhotoChange (item, event) {
const file = event.target.files[0]
if (!file) return
const reader = new FileReader()
reader.onload = e => { item.photo_base64 = e.target.result }
reader.readAsDataURL(file)
}
async function submitEquipment () {
if (!eqRequestName.value || eqItems.value.length === 0) return
eqSubmitting.value = true
try {
for (const item of eqItems.value) {
await createEquipmentInstall({
request: eqRequestName.value,
barcode: item.barcode,
equipment_type: item.equipment_type,
brand: item.brand,
model: item.model,
notes: item.notes,
photo_base64: item.photo_base64,
})
}
const count = eqItems.value.length
eqItems.value = []
eqDone.value = true
toast(count + ' équipement(s) enregistré(s)')
} catch {
toast('Erreur lors de la soumission')
} finally {
eqSubmitting.value = false
}
}
onUnmounted(() => { stopScanner() })
onMounted(boot)
</script>
<template>
<div class="mobile-app">
<!-- Header -->
<div class="app-header">
<div class="app-header-bar">
<div>
<div class="app-header-sub">{{ today }}</div>
<div class="app-header-title">
<span v-if="phase === 'jobs'">{{ techName }}</span>
<span v-else-if="phase === 'select-tech'">Choisir un technicien</span>
<span v-else>Dispatch Mobile</span>
</div>
</div>
<div style="display:flex;align-items:center;gap:0.5rem;">
<span :class="auth.user ? 'badge badge-online' : 'badge badge-offline'">
{{ auth.user ? 'En ligne' : 'Hors ligne' }}
</span>
<button v-if="phase === 'jobs'" class="btn-icon"
@click="phase = 'select-tech'" title="Changer de tech">&#8646;</button>
<div v-if="phase === 'jobs'" class="avatar" :style="'background:' + techColor">
{{ initials }}
</div>
</div>
</div>
<div v-if="phase === 'jobs'" class="stats-strip">
<div v-for="s in stats" :key="s.lbl" class="stat-box">
<div class="stat-val">{{ s.val }}</div>
<div class="stat-lbl">{{ s.lbl }}</div>
</div>
</div>
</div>
<!-- Content -->
<div class="app-content">
<!-- Loading -->
<div v-if="phase === 'loading'" style="display:flex;flex-direction:column;align-items:center;padding:3rem 1rem;gap:1rem;">
<div class="spinner"></div>
<div style="color:#94a3b8;font-size:0.88rem;">Chargement...</div>
</div>
<!-- Login -->
<div v-else-if="phase === 'login'" class="login-wrap">
<div class="login-hero">
<div class="login-icon">&#9889;</div>
<div style="font-size:1.2rem;font-weight:700;color:#1e293b;">Connexion ERPNext</div>
<div style="color:#64748b;font-size:0.82rem;margin-top:0.3rem;">Entrez vos identifiants pour continuer</div>
</div>
<div class="login-card">
<label class="field-label">Utilisateur (email)</label>
<input v-model="loginUser" type="email" placeholder="admin@example.com"
class="field-input" :class="{ err: !!auth.error }" @keyup.enter="doLogin" />
<label class="field-label">Mot de passe</label>
<input v-model="loginPass" :type="showPwd ? 'text' : 'password'" placeholder="••••••••"
class="field-input" :class="{ err: !!auth.error }" @keyup.enter="doLogin" />
<div v-if="auth.error" class="error-msg">{{ auth.error }}</div>
<label class="show-pwd">
<input type="checkbox" v-model="showPwd" /> Afficher le mot de passe
</label>
<button class="btn-primary"
:disabled="!loginUser || !loginPass || auth.loading"
@click="doLogin">
{{ auth.loading ? 'Connexion...' : 'Se connecter' }}
</button>
</div>
</div>
<!-- Select tech -->
<div v-else-if="phase === 'select-tech'" class="login-wrap">
<div class="login-hero">
<div class="login-icon" style="font-size:1.6rem;">&#128119;</div>
<div style="font-size:1.2rem;font-weight:700;color:#1e293b;">Choisir un technicien</div>
<div style="color:#64748b;font-size:0.82rem;margin-top:0.3rem;">
Connecté&nbsp;: <strong>{{ auth.user }}</strong>
</div>
</div>
<div class="login-card">
<label class="field-label">Technicien</label>
<select v-model="selTechId" class="field-select">
<option value="" disabled>-- Choisir --</option>
<option v-for="t in techList" :key="t.name" :value="t.name">{{ t.fullName }}</option>
</select>
<button class="btn-primary" :disabled="!selTechId || store.loading" @click="loadJobs">
{{ store.loading ? 'Chargement...' : 'Voir les jobs &rarr;' }}
</button>
<button class="btn-secondary" @click="doLogout">Changer de compte</button>
</div>
</div>
<!-- Jobs -->
<template v-else-if="phase === 'jobs'">
<!-- En cours -->
<div v-if="activeJob">
<div class="section-label">En cours</div>
<div class="job-card active-card" :style="'border-left-color:' + techColor"
@click="detailJob = activeJob">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:0.5rem;">
<div style="display:flex;align-items:center;gap:0.5rem;">
<span class="prio-dot" :class="'prio-' + activeJob.priority"></span>
<span style="font-size:0.72rem;font-weight:700;color:#6366f1;">{{ activeJob.id }}</span>
</div>
<span class="badge badge-active">En cours</span>
</div>
<div style="font-size:0.97rem;font-weight:700;margin-bottom:0.3rem;">{{ activeJob.subject }}</div>
<div style="font-size:0.77rem;color:#64748b;margin-bottom:0.6rem;">&#128205; {{ activeJob.address }}</div>
<div style="display:flex;gap:0.5rem;align-items:center;flex-wrap:wrap;">
<span class="chip">&#9202; {{ activeJob.duration }}h</span>
<span v-if="activeJob.legDur" class="chip">&#128664; {{ activeJob.legDur }}min</span>
<span style="flex:1;"></span>
<button class="btn-green" style="flex:0;padding:4px 14px;font-size:0.75rem;border-radius:8px;"
@click.stop="markComplete(activeJob)">Terminer</button>
</div>
</div>
</div>
<!-- A venir -->
<div class="section-label">A venir ({{ upcomingJobs.length }})</div>
<div v-if="upcomingJobs.length === 0"
style="text-align:center;padding:1.5rem;color:#94a3b8;font-size:0.85rem;">
Aucun job à venir
</div>
<div v-for="(job, idx) in upcomingJobs" :key="job.id"
class="job-card" :style="'border-left-color:' + techColor" @click="detailJob = job">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:0.4rem;">
<div style="display:flex;align-items:center;gap:0.5rem;">
<div class="num-bubble" :style="'background:' + techColor">
{{ idx + (activeJob ? 2 : 1) }}
</div>
<span style="font-size:0.72rem;font-weight:600;color:#6366f1;">{{ job.id }}</span>
<span class="prio-dot" :class="'prio-' + job.priority"></span>
</div>
<span class="chip">{{ startTime(idx) }}</span>
</div>
<div style="font-size:0.92rem;font-weight:600;margin-bottom:0.25rem;">{{ job.subject }}</div>
<div style="font-size:0.75rem;color:#64748b;margin-bottom:0.5rem;">&#128205; {{ job.address }}</div>
<div style="display:flex;gap:0.4rem;flex-wrap:wrap;">
<span class="chip">&#9202; {{ job.duration }}h</span>
<span v-if="job.legDur" class="chip">&#128664; {{ job.legDur }}m</span>
</div>
</div>
<!-- Complétés -->
<div v-if="completedJobs.length > 0">
<div class="section-label" @click="showCompleted = !showCompleted">
Complétés ({{ completedJobs.length }})
<span style="font-size:0.9rem;">{{ showCompleted ? '&#8963;' : '&#8964;' }}</span>
</div>
<div v-if="showCompleted">
<div v-for="job in completedJobs" :key="job.id"
class="job-card done-card" @click="detailJob = job">
<div style="display:flex;align-items:center;gap:0.6rem;">
<div class="check-circle">&#10003;</div>
<div>
<div style="font-size:0.85rem;font-weight:600;text-decoration:line-through;color:#64748b;">
{{ job.subject }}
</div>
<div style="font-size:0.71rem;color:#94a3b8;">{{ job.id }}</div>
</div>
</div>
</div>
</div>
</div>
<!-- Aucun job -->
<div v-if="myJobs.length === 0" style="text-align:center;padding:3rem 1rem;">
<div style="font-size:3rem;margin-bottom:0.75rem;">&#128197;</div>
<div style="font-size:1rem;font-weight:600;color:#374151;margin-bottom:0.3rem;">Aucun job aujourd'hui</div>
<div style="color:#94a3b8;font-size:0.83rem;">Votre planning est vide.</div>
</div>
</template>
<!-- ── Equipment tab ──────────────────────────────────────────────────── -->
<template v-else-if="phase === 'jobs' && tab === 'equipment'">
<!-- Confirm done banner -->
<div v-if="eqDone" class="eq-done-banner">
&#10003; Équipements enregistrés avec succès !
<button @click="eqDone = false" style="margin-left:0.75rem;background:none;border:none;color:inherit;font-size:1rem;cursor:pointer;">&times;</button>
</div>
<!-- Request picker -->
<div class="section-label">Appel de service</div>
<select v-model="eqRequestName" class="field-select" style="margin-bottom:0.5rem;">
<option value="" disabled>-- Choisir un ticket --</option>
<option v-for="j in eqJobs" :key="j.id" :value="j.id">{{ j.id }} — {{ j.subject }}</option>
</select>
<!-- Scanner -->
<div class="section-label">Scanner un code-barres</div>
<div v-if="!scannerActive" style="margin-bottom:1rem;">
<button class="btn-indigo-full" @click="startScanner">
&#128247; Activer la caméra
</button>
</div>
<div v-else style="margin-bottom:1rem;">
<div id="qr-reader" class="qr-reader-box"></div>
<button class="btn-secondary" style="width:100%;margin-top:0.5rem;" @click="stopScanner">
Arrêter le scanner
</button>
</div>
<!-- Scanned items -->
<div class="section-label">Équipements ({{ eqItems.length }})</div>
<div v-if="eqItems.length === 0" style="text-align:center;padding:1.5rem;color:#94a3b8;font-size:0.85rem;">
Scannez un code-barres ou ajoutez manuellement.
</div>
<div v-for="item in eqItems" :key="item._id" class="eq-card">
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.75rem;">
<span style="font-size:0.72rem;font-weight:700;color:#6366f1;flex:1;">CODE-BARRES</span>
<button @click="removeEqItem(item)" style="background:none;border:none;color:#ef4444;font-size:1.1rem;cursor:pointer;">&times;</button>
</div>
<input v-model="item.barcode" placeholder="Code-barres ou numéro de série" class="eq-input" />
<label class="eq-label">Type d'équipement</label>
<select v-model="item.equipment_type" class="eq-select">
<option v-for="t in EQUIPMENT_TYPES" :key="t" :value="t">{{ t }}</option>
</select>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0.5rem;">
<div>
<label class="eq-label">Marque</label>
<input v-model="item.brand" placeholder="ex: Cisco" class="eq-input" />
</div>
<div>
<label class="eq-label">Modèle</label>
<input v-model="item.model" placeholder="ex: DPC3829" class="eq-input" />
</div>
</div>
<label class="eq-label">Notes</label>
<input v-model="item.notes" placeholder="Observations, port, emplacement..." class="eq-input" />
<label class="eq-label">Photo</label>
<div style="display:flex;align-items:center;gap:0.75rem;">
<label class="btn-photo">
&#128247; Prendre une photo
<input type="file" accept="image/*" capture="environment" style="display:none"
@change="onPhotoChange(item, $event)" />
</label>
<img v-if="item.photo_base64" :src="item.photo_base64" class="eq-thumb" />
</div>
</div>
<button class="btn-secondary" style="width:100%;margin-top:0.5rem;margin-bottom:0.75rem;" @click="addEqItem">
+ Ajouter manuellement
</button>
<button class="btn-green-full"
:disabled="!eqRequestName || eqItems.length === 0 || eqSubmitting"
@click="submitEquipment">
{{ eqSubmitting ? 'Enregistrement...' : 'Enregistrer les équipements (' + eqItems.length + ')' }}
</button>
</template>
</div>
<!-- Footer tabs -->
<div class="app-footer">
<button class="tab-btn" :class="{ active: tab === 'jobs' }" @click="tab = 'jobs'">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/>
</svg>
Jobs
</button>
<button class="tab-btn" :class="{ active: tab === 'map' }" @click="tab = 'map'">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polygon points="1 6 1 22 8 18 16 22 23 18 23 2 16 6 8 2 1 6"/>
<line x1="8" y1="2" x2="8" y2="18"/><line x1="16" y1="6" x2="16" y2="22"/>
</svg>
Carte
</button>
<button class="tab-btn" :class="{ active: tab === 'equipment' }"
@click="tab = 'equipment'; if(scannerActive) stopScanner()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2"/><path d="M7 7h.01M12 7h.01M17 7h.01M7 12h.01M12 12h.01M17 12h.01M7 17h.01M12 17h.01M17 17h.01"/>
</svg>
Équip.
</button>
<button class="tab-btn" :class="{ active: tab === 'profile' }" @click="tab = 'profile'">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/>
<circle cx="12" cy="7" r="4"/>
</svg>
Profil
</button>
</div>
<!-- Detail modal -->
<div v-if="detailJob" class="modal-backdrop" @click.self="detailJob = null">
<div class="modal-sheet">
<div class="modal-handle"><div class="modal-handle-bar"></div></div>
<div style="padding:1rem 1.25rem 0.25rem;display:flex;align-items:flex-start;justify-content:space-between;gap:0.5rem;">
<div>
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.35rem;">
<span style="font-size:0.75rem;font-weight:700;color:#6366f1;">{{ detailJob.id }}</span>
<span class="prio-dot" :class="'prio-' + detailJob.priority"></span>
<span style="font-size:0.68rem;font-weight:600;padding:2px 8px;border-radius:6px;"
:style="prioStyle(detailJob.priority)">{{ prioLbl(detailJob.priority) }}</span>
</div>
<div style="font-size:1.05rem;font-weight:700;color:#1e293b;">{{ detailJob.subject }}</div>
</div>
<button @click="detailJob = null"
style="background:none;border:none;font-size:1.4rem;color:#94a3b8;cursor:pointer;line-height:1;">&times;</button>
</div>
<div class="modal-row">
<div class="modal-row-icon">&#128205;</div>
<div style="flex:1;">
<div class="modal-row-label">Adresse</div>
<div class="modal-row-value">{{ detailJob.address }}</div>
</div>
<a :href="mapsUrl(detailJob.address)" target="_blank"
style="color:#6366f1;font-size:0.8rem;text-decoration:none;font-weight:600;">Carte</a>
</div>
<div class="modal-row">
<div class="modal-row-icon">&#9202;</div>
<div>
<div class="modal-row-label">Durée estimée</div>
<div class="modal-row-value">{{ detailJob.duration }}h</div>
</div>
</div>
<div v-if="detailJob.legDist" class="modal-row">
<div class="modal-row-icon">&#128664;</div>
<div>
<div class="modal-row-label">Trajet jusqu'au job</div>
<div class="modal-row-value">{{ detailJob.legDist }} km · {{ detailJob.legDur }} min</div>
</div>
</div>
<div class="modal-actions">
<button v-if="detailJob.status !== 'completed'" class="btn-indigo"
@click="markEnRoute(detailJob)">En route</button>
<button v-if="detailJob.status !== 'completed'" class="btn-green"
@click="markComplete(detailJob); detailJob = null">Terminer</button>
<button v-if="detailJob.status === 'completed'" disabled
style="flex:1;padding:0.7rem;background:#f1f5f9;color:#94a3b8;border:none;border-radius:10px;font-weight:700;font-family:inherit;">
Ticket complété
</button>
</div>
</div>
</div>
<!-- Toast -->
<div v-if="showToast" class="toast">&#10003; {{ toastMsg }}</div>
</div>
</template>
<style scoped>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
.mobile-app { height: 100vh; display: flex; flex-direction: column; background: #f1f5f9; color: #1e293b; font-family: 'Inter', system-ui, sans-serif; }
/* Layout */
.app-header { background: linear-gradient(135deg, #6366f1, #8b5cf6); color: white; flex-shrink: 0; }
.app-header-bar { display: flex; align-items: center; justify-content: space-between; padding: 0.9rem 1rem; }
.app-header-title { font-size: 1.1rem; font-weight: 700; }
.app-header-sub { font-size: 0.7rem; opacity: 0.75; margin-bottom: 2px; }
.app-content { flex: 1; overflow-y: auto; padding: 1rem; padding-bottom: 5rem; }
.app-footer { position: fixed; bottom: 0; left: 0; right: 0; background: white; border-top: 1px solid #e2e8f0; display: flex; z-index: 100; }
.tab-btn { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 2px; padding: 0.55rem 0; font-size: 0.65rem; font-weight: 600; color: #94a3b8; border: none; background: none; cursor: pointer; transition: color 0.15s; }
.tab-btn.active { color: #6366f1; }
.tab-btn svg { width: 20px; height: 20px; }
/* Stats */
.stats-strip { background: #4f46e5; display: flex; padding: 0.5rem 1rem; gap: 0.5rem; }
.stat-box { flex: 1; background: rgba(255,255,255,0.12); border-radius: 8px; padding: 0.4rem 0.5rem; text-align: center; }
.stat-val { font-size: 1.1rem; font-weight: 700; color: white; }
.stat-lbl { font-size: 0.6rem; color: rgba(255,255,255,0.75); margin-top: 1px; }
/* Badges */
.badge { display: inline-flex; align-items: center; font-size: 0.65rem; font-weight: 700; padding: 2px 8px; border-radius: 20px; }
.badge-online { background: rgba(74,222,128,0.25); color: #16a34a; }
.badge-offline { background: rgba(248,113,113,0.25); color: #dc2626; }
.badge-active { background: #e0e7ff; color: #4338ca; }
/* Login */
.login-wrap { max-width: 400px; margin: 0 auto; padding-top: 0.5rem; }
.login-hero { text-align: center; padding: 1.75rem 0 1.25rem; }
.login-icon { width: 68px; height: 68px; border-radius: 20px; background: linear-gradient(135deg,#6366f1,#8b5cf6); display: flex; align-items: center; justify-content: center; margin: 0 auto 1rem; font-size: 2rem; }
.login-card { background: white; border-radius: 16px; padding: 1.5rem; box-shadow: 0 4px 24px rgba(0,0,0,0.07); }
.field-label { font-size: 0.75rem; font-weight: 600; color: #475569; display: block; margin-bottom: 0.35rem; }
.field-input { width: 100%; padding: 0.7rem 0.9rem; font-size: 0.9rem; font-family: inherit; border: 1.5px solid #e2e8f0; border-radius: 10px; outline: none; transition: border-color 0.15s; margin-bottom: 0.85rem; }
.field-input:focus { border-color: #6366f1; }
.field-input.err { border-color: #ef4444; }
.show-pwd { display: flex; align-items: center; gap: 0.4rem; margin-bottom: 1rem; font-size: 0.78rem; color: #64748b; cursor: pointer; }
.show-pwd input { accent-color: #6366f1; }
.error-msg { font-size: 0.78rem; color: #ef4444; margin: -0.6rem 0 0.75rem; padding-left: 0.1rem; }
.field-select { width: 100%; padding: 0.7rem 0.9rem; font-size: 0.9rem; font-family: inherit; border: 1.5px solid #e2e8f0; border-radius: 10px; outline: none; background: white; margin-bottom: 1rem; cursor: pointer; }
.field-select:focus { border-color: #6366f1; }
/* Buttons */
.btn-primary { width: 100%; padding: 0.75rem; font-size: 0.92rem; font-weight: 700; font-family: inherit; background: #6366f1; color: white; border: none; border-radius: 10px; cursor: pointer; transition: background 0.15s; }
.btn-primary:hover { background: #4f46e5; }
.btn-primary:disabled { background: #c7d2fe; cursor: not-allowed; }
.btn-secondary { width: 100%; padding: 0.65rem; font-size: 0.85rem; font-weight: 600; font-family: inherit; background: transparent; color: #64748b; border: 1.5px solid #e2e8f0; border-radius: 10px; cursor: pointer; margin-top: 0.6rem; transition: all 0.15s; }
.btn-secondary:hover { border-color: #6366f1; color: #6366f1; }
.btn-green { flex: 1; padding: 0.7rem; font-size: 0.88rem; font-weight: 700; font-family: inherit; background: #22c55e; color: white; border: none; border-radius: 10px; cursor: pointer; }
.btn-indigo { flex: 1; padding: 0.7rem; font-size: 0.88rem; font-weight: 700; font-family: inherit; background: #6366f1; color: white; border: none; border-radius: 10px; cursor: pointer; }
.btn-icon { background: rgba(255,255,255,0.2); border: none; color: white; width: 34px; height: 34px; border-radius: 50%; cursor: pointer; font-size: 1.1rem; display: flex; align-items: center; justify-content: center; }
/* Cards */
.section-label { font-size: 0.68rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: #94a3b8; margin: 1.25rem 0 0.5rem; padding: 0 0.1rem; display: flex; align-items: center; gap: 0.4rem; cursor: pointer; }
.job-card { background: white; border-radius: 14px; padding: 0.9rem 1rem; margin-bottom: 0.7rem; box-shadow: 0 2px 8px rgba(0,0,0,0.06); border-left: 4px solid #e2e8f0; cursor: pointer; transition: transform 0.12s, box-shadow 0.12s; }
.job-card:active { transform: scale(0.985); }
.active-card { box-shadow: 0 4px 20px rgba(99,102,241,0.18); }
.done-card { opacity: 0.6; border-left-color: #22c55e !important; background: #f9fafb; }
.prio-dot { width: 9px; height: 9px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
.prio-high { background: #ef4444; }
.prio-medium { background: #f59e0b; }
.prio-low { background: #10b981; }
.chip { background: #f1f5f9; border-radius: 6px; padding: 2px 8px; font-size: 0.7rem; font-weight: 600; color: #475569; }
.num-bubble { width: 22px; height: 22px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 0.68rem; font-weight: 700; color: white; flex-shrink: 0; }
.avatar { width: 36px; height: 36px; border-radius: 50%; font-weight: 700; font-size: 0.85rem; color: white; display: flex; align-items: center; justify-content: center; }
.check-circle { width: 32px; height: 32px; border-radius: 50%; background: #22c55e; display: flex; align-items: center; justify-content: center; flex-shrink: 0; color: white; font-size: 1rem; }
/* Modal */
.modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.45); z-index: 200; display: flex; align-items: flex-end; }
.modal-sheet { background: white; border-radius: 18px 18px 0 0; width: 100%; max-width: 600px; margin: 0 auto; padding-bottom: env(safe-area-inset-bottom, 16px); animation: slideUp 0.22s ease; }
@keyframes slideUp { from { transform: translateY(100%); } to { transform: translateY(0); } }
.modal-handle { display: flex; justify-content: center; padding: 0.75rem 0 0; }
.modal-handle-bar { width: 40px; height: 4px; border-radius: 2px; background: #e2e8f0; }
.modal-row { display: flex; align-items: center; gap: 0.75rem; padding: 0.6rem 1.25rem; }
.modal-row-icon { color: #94a3b8; font-size: 1.1rem; width: 22px; text-align: center; }
.modal-row-label { font-size: 0.68rem; color: #94a3b8; }
.modal-row-value { font-size: 0.88rem; font-weight: 600; color: #1e293b; }
.modal-actions { display: flex; gap: 0.5rem; padding: 0.75rem 1.25rem 1.25rem; }
/* Toast */
.toast { position: fixed; top: 1rem; left: 50%; transform: translateX(-50%); background: #22c55e; color: white; border-radius: 12px; padding: 0.65rem 1.2rem; font-weight: 700; font-size: 0.88rem; z-index: 300; white-space: nowrap; box-shadow: 0 4px 16px rgba(0,0,0,0.15); animation: fadeIn 0.2s ease; }
@keyframes fadeIn { from { opacity: 0; transform: translateX(-50%) translateY(-8px); } to { opacity: 1; transform: translateX(-50%) translateY(0); } }
/* Spinner */
.spinner { width: 40px; height: 40px; border: 3px solid #e2e8f0; border-top-color: #6366f1; border-radius: 50%; animation: spin 0.7s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
/* Equipment tab */
.eq-done-banner { background: #dcfce7; color: #16a34a; border-radius: 10px; padding: 0.75rem 1rem; font-size: 0.85rem; font-weight: 600; margin-bottom: 1rem; display: flex; align-items: center; }
.eq-card { background: white; border-radius: 14px; padding: 1rem; margin-bottom: 0.75rem; box-shadow: 0 2px 8px rgba(0,0,0,0.06); }
.eq-label { display: block; font-size: 0.68rem; font-weight: 600; color: #64748b; margin: 0.55rem 0 0.2rem; }
.eq-input { width: 100%; padding: 0.55rem 0.75rem; font-size: 0.85rem; font-family: inherit; border: 1.5px solid #e2e8f0; border-radius: 8px; outline: none; margin-bottom: 0.1rem; }
.eq-input:focus { border-color: #6366f1; }
.eq-select { width: 100%; padding: 0.55rem 0.75rem; font-size: 0.85rem; font-family: inherit; border: 1.5px solid #e2e8f0; border-radius: 8px; outline: none; background: white; }
.eq-select:focus { border-color: #6366f1; }
.qr-reader-box { width: 100%; border-radius: 12px; overflow: hidden; border: 2px solid #6366f1; background: #000; min-height: 200px; }
.btn-photo { display: inline-flex; align-items: center; gap: 0.4rem; padding: 0.45rem 0.9rem; font-size: 0.78rem; font-weight: 600; font-family: inherit; background: #ede9fe; color: #6366f1; border: none; border-radius: 8px; cursor: pointer; }
.eq-thumb { width: 52px; height: 52px; object-fit: cover; border-radius: 8px; border: 2px solid #e2e8f0; }
.btn-indigo-full { width: 100%; padding: 0.72rem; font-size: 0.9rem; font-weight: 700; font-family: inherit; background: #6366f1; color: white; border: none; border-radius: 10px; cursor: pointer; }
.btn-green-full { width: 100%; padding: 0.72rem; font-size: 0.9rem; font-weight: 700; font-family: inherit; background: #22c55e; color: white; border: none; border-radius: 10px; cursor: pointer; }
.btn-green-full:disabled { background: #86efac; cursor: not-allowed; }
</style>