gigafibre-fsm/apps/ops/src/modules/tech/pages/TechTasksPage.vue
louispaulb 1017cab649 refactor(ui): T1 audit — dédup formatters → useFormatters (0 changement d'affichage)
3 formateurs canoniques ajoutés (initials [2 premiers mots, '?'], fmtClock [«8:30»→«8h30»], formatDateLong)
+ remplacement des copies locales IDENTIQUES (vérifiées à l'impl) :
- initials : PlanificationPage + RouteMap
- fmtTime → fmtClock : JobCard + TechTasksPage
- formatDate → formatDateLong : ReportRevenueExplorer + ReportInternetCher
Les VRAIES variantes laissées telles quelles (staffInitials 1er+dernier ; fmtDate string ; fmtDur RendezVous ;
fmtMoney multi-devise ; formatDate Settings/ConversationPanel/Gifts date+heure). Build vert, leak 0.
Première tranche de l'audit workflow 26 pages.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 18:31:18 -04:00

750 lines
29 KiB
Vue

<template>
<q-page class="tech-surface">
<!-- Offline banner (fixed top) -->
<transition name="slide-down">
<div v-if="!isOnline" class="tg-banner">
<span class="material-icons" style="font-size:16px">wifi_off</span>
Hors ligne — les modifications seront synchronisées à la reconnexion
</div>
</transition>
<!-- Hero: greeting + progress arc -->
<section class="tg-hero">
<div class="tg-hero-inner">
<!-- Top row: avatar + name + refresh -->
<div class="hero-top">
<div class="hero-left">
<div class="tg-avatar">{{ initials }}</div>
<div>
<div class="t-caption t-ink-on-hero-dim" style="text-transform:capitalize">{{ todayLabel }}</div>
<div class="hero-name">{{ techName }}</div>
</div>
</div>
<button class="hero-refresh" @click="loadTasks" :disabled="loading" aria-label="Rafraîchir">
<span class="material-icons" :class="{ spinning: loading }">refresh</span>
</button>
</div>
<!-- Progress arc + count -->
<div class="hero-progress">
<div class="tg-arc">
<svg :width="ARC_SIZE" :height="ARC_SIZE" viewBox="0 0 120 120">
<circle class="tg-arc-track" cx="60" cy="60" :r="ARC_R" stroke-width="10" />
<circle class="tg-arc-fill" cx="60" cy="60" :r="ARC_R" stroke-width="10"
:stroke-dasharray="arcCircumference"
:stroke-dashoffset="arcOffset" />
</svg>
<div class="tg-arc-center">
<div class="arc-numerator">{{ doneCount }}</div>
<div class="arc-denom">/ {{ jobs.length || '—' }}</div>
</div>
</div>
<div class="hero-progress-text">
<div class="t-h2 t-ink-on-hero">{{ heroHeadline }}</div>
<div class="t-caption t-ink-on-hero-dim">{{ heroSubline }}</div>
</div>
</div>
</div>
</section>
<!-- Job list -->
<div class="tg-page tg-page--content">
<!-- In progress (always first — most likely what tech is doing) -->
<div v-if="inProgressJobs.length" class="section-head">
<span class="t-micro">En cours</span>
<span class="t-micro t-ink-400">{{ inProgressJobs.length }}</span>
</div>
<JobCard v-for="job in inProgressJobs" :key="job.name"
:job="job" status="work"
@click="openSheet(job)" />
<!-- Upcoming -->
<div v-if="upcomingJobs.length" class="section-head" :class="{ 'section-head--spaced': inProgressJobs.length }">
<span class="t-micro">À venir</span>
<span class="t-micro t-ink-400">{{ upcomingJobs.length }}</span>
</div>
<JobCard v-for="(job, idx) in upcomingJobs" :key="job.name"
:job="job" :status="upcomingStatus(job)" :index="idx + 1"
@click="openSheet(job)" />
<!-- Completed (collapsible) -->
<div v-if="completedJobs.length" class="section-head section-head--spaced"
@click="showCompleted = !showCompleted" style="cursor:pointer">
<span class="t-micro">Terminées</span>
<span class="t-micro t-ink-400">{{ completedJobs.length }}</span>
<span class="material-icons section-head-chevron" :class="{ 'is-open': showCompleted }">expand_more</span>
</div>
<template v-if="showCompleted">
<JobCard v-for="job in completedJobs" :key="job.name"
:job="job" status="go" done
@click="openSheet(job)" />
</template>
<!-- Empty state -->
<div v-if="!loading && jobs.length === 0" class="empty-state">
<div class="empty-icon"><span class="material-icons">event_available</span></div>
<div class="t-h2 t-ink-900" style="margin-top:16px">Aucune tâche aujourd'hui</div>
<div class="t-caption" style="margin-top:4px">Profite de la pause ☕</div>
</div>
<!-- Group-subscription feed: unassigned jobs the tech can claim. -->
<!-- Lives below the personal list so techs check it deliberately. -->
<div v-if="groupJobs.length" class="section-head section-head--spaced"
@click="showGroupJobs = !showGroupJobs" style="cursor:pointer">
<span class="material-icons" style="font-size:18px;vertical-align:-4px;margin-right:4px">groups</span>
<span class="t-micro">Tâches du groupe</span>
<span class="t-micro t-ink-400">{{ groupJobs.length }} disponible{{ groupJobs.length > 1 ? 's' : '' }}</span>
<span class="material-icons section-head-chevron" :class="{ 'is-open': showGroupJobs }">expand_more</span>
</div>
<template v-if="showGroupJobs">
<div v-for="job in groupJobs" :key="'grp-' + job.name" class="group-card">
<div class="group-card-row">
<div class="col" style="min-width:0">
<div class="t-caption t-ink-600">{{ job.assigned_group || 'Non assigné' }}
<span v-if="job.priority === 'urgent' || job.priority === 'high'"
class="tg-chip is-compact"
:class="job.priority === 'urgent' ? 'is-stop' : 'is-work'"
style="margin-left:6px">
{{ job.priority === 'urgent' ? 'Urgent' : 'Haute' }}
</span>
</div>
<div class="t-body t-ink-900" style="font-weight:600;margin-top:2px">{{ job.subject }}</div>
<div class="t-caption t-ink-500" style="margin-top:2px">
<template v-if="job.customer_name">{{ job.customer_name }}</template>
<template v-if="job.service_location_name"> · {{ job.service_location_name }}</template>
</div>
<div class="t-micro t-ink-400" style="margin-top:2px">
<template v-if="job.scheduled_date">
<span class="material-icons" style="font-size:12px;vertical-align:-2px">event</span>
{{ fmtDate(job.scheduled_date) }}{{ job.scheduled_time ? ' · ' + fmtTime(job.scheduled_time) : '' }}
</template>
<template v-else>Non planifié</template>
</div>
</div>
<button class="tg-btn is-primary is-compact" :disabled="claimingJob === job.name" @click="claimJob(job)">
<span v-if="claimingJob === job.name" class="material-icons spinning" style="font-size:16px">autorenew</span>
<span v-else class="material-icons" style="font-size:16px">pan_tool</span>
Prendre
</button>
</div>
</div>
</template>
<div v-if="!loading && jobs.length === 0 && groupJobs.length === 0" class="empty-state-hint">
<span class="t-caption t-ink-500">Aucune tâche disponible dans vos groupes non plus.</span>
</div>
</div>
<!-- Peek sheet: job detail -->
<transition name="fade">
<div v-if="sheetOpen" class="tg-peek-backdrop is-open" @click="sheetOpen = false" />
</transition>
<div class="tg-peek" :class="{ 'is-open': sheetOpen }" v-if="sheetJob">
<div class="tg-peek-handle" />
<div style="padding: 8px 20px 20px">
<!-- Title row -->
<div class="peek-title-row">
<div>
<div class="t-caption" style="color:var(--tg-700);font-weight:700">{{ sheetJob.name }}</div>
<div class="t-h2" style="margin-top:2px">{{ sheetJob.subject || 'Sans titre' }}</div>
</div>
<button class="peek-close" @click="sheetOpen = false" aria-label="Fermer">
<span class="material-icons">close</span>
</button>
</div>
<!-- Priority chip -->
<div v-if="sheetJob.priority === 'urgent' || sheetJob.priority === 'high'" style="margin-top:8px">
<span class="tg-chip" :class="sheetJob.priority === 'urgent' ? 'is-stop' : 'is-work'">
<span class="tg-chip-dot" />
{{ sheetJob.priority === 'urgent' ? 'Urgent' : 'Haute priorité' }}
</span>
</div>
<!-- Info rows -->
<div v-if="sheetJob.service_location_name" class="info-row">
<span class="material-icons info-icon" style="color:var(--sig-stop)">place</span>
<div class="col">
<div class="t-caption">Adresse</div>
<div class="t-body">{{ sheetJob.service_location_name }}</div>
</div>
<button class="tg-btn is-ghost is-compact" @click="openGps(sheetJob)">
<span class="material-icons" style="font-size:18px">navigation</span>
Carte
</button>
</div>
<div class="info-row-pair">
<div v-if="sheetJob.scheduled_time" class="info-row">
<span class="material-icons info-icon">schedule</span>
<div>
<div class="t-caption">Heure</div>
<div class="t-body">{{ fmtTime(sheetJob.scheduled_time) }}</div>
</div>
</div>
<div v-if="sheetJob.customer_name" class="info-row">
<span class="material-icons info-icon">person</span>
<div>
<div class="t-caption">Client</div>
<div class="t-body">{{ sheetJob.customer_name }}</div>
</div>
</div>
</div>
<!-- Description -->
<div v-if="sheetJob.description" class="info-row-stack">
<div class="t-caption">Notes</div>
<div class="t-body-sm" style="white-space:pre-wrap">{{ sheetJob.description }}</div>
</div>
<!-- Primary action (contextual to status) -->
<div style="display:flex;flex-direction:column;gap:10px;margin-top:20px">
<button v-if="isScheduled(sheetJob)"
class="tg-btn is-primary is-block"
@click="doStatus(sheetJob, 'In Progress')" :disabled="saving">
<span class="material-icons">directions_car</span>
En route
</button>
<button v-if="isInProgress(sheetJob)"
class="tg-btn is-success is-block"
@click="doStatus(sheetJob, 'Completed')" :disabled="saving">
<span class="material-icons">check_circle</span>
Marquer comme terminée
</button>
<!-- Testing shortcut: direct Completed from any non-done state -->
<button v-if="canTerminateDirect(sheetJob)"
class="tg-btn is-outline is-compact"
@click="doStatus(sheetJob, 'Completed')" :disabled="saving">
<span class="material-icons" style="font-size:18px">fast_forward</span>
Terminer directement (test)
</button>
<button v-if="sheetJob.status === 'Completed'"
class="tg-btn is-ghost is-block"
@click="doStatus(sheetJob, 'In Progress')" :disabled="saving">
<span class="material-icons">replay</span>
Rouvrir
</button>
<!-- Secondary actions -->
<div class="peek-actions-row">
<button class="tg-btn is-outline is-compact" @click="goScan(sheetJob)">
<span class="material-icons" style="font-size:18px">qr_code_scanner</span>
Scanner
</button>
<button class="tg-btn is-outline is-compact" @click="goDetail(sheetJob)">
<span class="material-icons" style="font-size:18px">open_in_full</span>
Détails
</button>
</div>
</div>
</div>
</div>
</q-page>
</template>
<script setup>
import { ref, onMounted, onUnmounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { Notify } from 'quasar'
import { BASE_URL } from 'src/config/erpnext'
import { HUB_URL } from 'src/config/hub'
import { fmtClock as fmtTime } from 'src/composables/useFormatters' // « 8:30 »→« 8h30 » (source unique)
import JobCard from '../components/JobCard.vue'
const props = defineProps({
token: { type: String, default: '' },
})
const router = useRouter()
const loading = ref(false)
const jobs = ref([])
const saving = ref(false)
const isOnline = ref(navigator.onLine)
const showCompleted = ref(false)
const magicTechId = ref('')
// Group subscription feed — unassigned claimable jobs.
// Showing them below the personal list acts as an opt-in subscription:
// techs with idle time scroll down and pick up extra work without a
// dispatcher round-trip.
const groupJobs = ref([])
const showGroupJobs = ref(true)
const claimingJob = ref('')
// Peek sheet state
const sheetOpen = ref(false)
const sheetJob = ref(null)
// Tech identity
const techName = ref('Technicien')
const initials = computed(() => {
const parts = techName.value.split(' ')
return parts.length >= 2
? (parts[0][0] + parts[parts.length - 1][0]).toUpperCase()
: techName.value.slice(0, 2).toUpperCase()
})
// ── Progress arc math ──────────────────────────────────────────────────────
const ARC_SIZE = 96
const ARC_R = 50
const arcCircumference = 2 * Math.PI * ARC_R
const arcPct = computed(() => jobs.value.length ? doneCount.value / jobs.value.length : 0)
const arcOffset = computed(() => arcCircumference * (1 - arcPct.value))
// ── Date + counts ──────────────────────────────────────────────────────────
const today = new Date().toISOString().slice(0, 10)
const todayLabel = computed(() =>
new Date().toLocaleDateString('fr-CA', { weekday: 'long', day: 'numeric', month: 'long' })
)
const doneCount = computed(() => jobs.value.filter(j => j.status === 'Completed').length)
const inProgressJobs = computed(() =>
jobs.value.filter(j => j.status === 'In Progress' || j.status === 'in_progress')
)
const upcomingJobs = computed(() =>
jobs.value
.filter(j => ['open', 'Scheduled', 'assigned', 'Open', 'Assigned'].includes(j.status))
.sort((a, b) => (a.scheduled_time || '').localeCompare(b.scheduled_time || ''))
)
const completedJobs = computed(() => jobs.value.filter(j => j.status === 'Completed'))
const heroHeadline = computed(() => {
if (!jobs.value.length) return 'Aucune tâche'
if (doneCount.value === jobs.value.length) return 'Journée terminée 🎉'
if (doneCount.value === 0) return `${upcomingJobs.value.length + inProgressJobs.value.length} à faire`
return `${doneCount.value} sur ${jobs.value.length} terminées`
})
const heroSubline = computed(() => {
const next = inProgressJobs.value[0] || upcomingJobs.value[0]
if (!next) return 'Profite de la pause ☕'
return `Prochain · ${fmtTime(next.scheduled_time) || 'dès que possible'}`
})
// ── Card status classification ─────────────────────────────────────────────
function upcomingStatus (job) {
if (job.priority === 'urgent') return 'stop'
if (job.priority === 'high') return 'work'
return 'tg'
}
function isScheduled (j) { return ['Scheduled', 'assigned', 'open', 'Open', 'Assigned'].includes(j.status) }
function isInProgress (j) { return ['In Progress', 'in_progress'].includes(j.status) }
function canTerminateDirect (j) {
// Raccourci de TEST seulement (dev) — saut direct open→Completed ; jamais livré en prod.
return import.meta.env.DEV && j.status && j.status !== 'Completed' && j.status !== 'Cancelled'
}
// fmtTime → composables/useFormatters (fmtClock)
function fmtDate (iso) {
if (!iso) return ''
try {
const d = new Date(iso + 'T00:00:00')
return d.toLocaleDateString('fr-CA', { weekday: 'short', day: 'numeric', month: 'short' })
} catch { return iso }
}
function openSheet (job) { sheetJob.value = job; sheetOpen.value = true }
// ── API calls ──────────────────────────────────────────────────────────────
async function apiFetch (url) {
const res = await fetch(BASE_URL + url)
if (!res.ok) throw new Error('API ' + res.status)
return (await res.json()).data || []
}
/**
* Update job status through targo-hub (not direct ERPNext PUT).
* The /dispatch/job-status endpoint is the canonical write path:
* 1. PUTs the status change to ERPNext
* 2. Walks the chain (unblocks depends_on children on Completed)
* 3. Broadcasts SSE so other clients refresh in real time
* Going direct to ERPNext would skip steps 2+3 and the chain wouldn't walk.
*/
async function apiSetStatus (jobName, status) {
const res = await fetch(`${HUB_URL}/dispatch/job-status`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ job: jobName, status }),
})
if (!res.ok) {
const err = await res.text().catch(() => 'Unknown error')
throw new Error(`Status update failed (${res.status}): ${err}`)
}
return res.json()
}
async function verifyMagicToken (token) {
try {
const res = await fetch(`${HUB_URL}/magic-link/verify?token=${encodeURIComponent(token)}`)
if (!res.ok) return null
const data = await res.json()
return data.ok ? data.tech_id : null
} catch { return null }
}
async function loadTasks () {
loading.value = true
try {
let techId = ''
if (props.token) {
techId = await verifyMagicToken(props.token)
if (!techId) {
Notify.create({ type: 'negative', message: 'Lien expiré ou invalide. Demandez un nouveau lien par SMS.', timeout: 6000 })
loading.value = false
return
}
magicTechId.value = techId
try {
const techDoc = await apiFetch('/api/resource/Dispatch Technician/' + encodeURIComponent(techId) + '?fields=["name","full_name"]')
if (techDoc && techDoc.full_name) techName.value = techDoc.full_name
else techName.value = techId
} catch { techName.value = techId }
} else {
// Authentik session fallback
try {
const me = await fetch(BASE_URL + '/api/method/frappe.auth.get_logged_user')
if (me.ok) {
const u = await me.json()
const userName = u.message || ''
if (userName && userName !== 'authenticated') {
try {
const techs = await apiFetch('/api/resource/Dispatch Technician?filters=[["user","=","' + userName + '"]]&fields=["name","full_name"]&limit_page_length=1')
if (techs.length) {
techId = techs[0].name
techName.value = techs[0].full_name || prettify(userName)
} else {
techName.value = prettify(userName)
}
} catch { techName.value = prettify(userName) }
}
}
} catch {}
}
// Filter: open/scheduled/in-progress/completed (everything except 'On Hold' which
// is the chain-gated pending state — those shouldn't show in the active list)
const statusFilter = ['open', 'Open', 'Scheduled', 'assigned', 'Assigned', 'In Progress', 'in_progress', 'Completed']
const filterArr = [
['scheduled_date', '=', today],
['status', 'in', statusFilter],
]
if (techId) filterArr.push(['assigned_tech', '=', techId])
const params = new URLSearchParams({
fields: JSON.stringify(['name', 'subject', 'status', 'customer', 'customer_name',
'service_location', 'service_location_name', 'scheduled_time', 'description',
'job_type', 'duration_h', 'priority', 'depends_on', 'step_order']),
filters: JSON.stringify(filterArr),
limit_page_length: 50,
order_by: 'scheduled_time asc',
})
jobs.value = await apiFetch('/api/resource/Dispatch Job?' + params)
// Load the group-subscription feed alongside the personal list. Fire-and-
// forget — if it fails we just hide the section instead of blocking the
// main UX.
await loadGroupJobs().catch(() => {})
} catch (e) {
Notify.create({ type: 'warning', message: 'Erreur chargement: ' + e.message })
} finally {
loading.value = false
}
}
/**
* Pull unassigned claimable jobs from the hub. The hub endpoint filters by
* status ∈ {open, Scheduled} and assigned_tech empty — it does NOT filter by
* the tech's own group yet (we don't track tech→group membership yet; that's
* a future tags/skills layer). In the meantime techs see the whole pool with
* the `assigned_group` badge visible so they can self-select what they can
* handle.
*/
async function loadGroupJobs () {
try {
const res = await fetch(`${HUB_URL}/dispatch/group-jobs`)
if (!res.ok) { groupJobs.value = []; return }
const data = await res.json()
groupJobs.value = (data.jobs || []).filter(j => j.assigned_group)
} catch {
groupJobs.value = []
}
}
async function claimJob (job) {
const techId = magicTechId.value
if (!techId) {
Notify.create({ type: 'warning', message: 'Identité technicien introuvable — connexion requise.' })
return
}
claimingJob.value = job.name
try {
const res = await fetch(`${HUB_URL}/dispatch/claim-job`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ job: job.name, tech_id: techId }),
})
const data = await res.json().catch(() => ({}))
if (!res.ok) {
if (res.status === 409) {
Notify.create({ type: 'warning', message: data.error || 'Déjà pris.', timeout: 4500 })
} else {
Notify.create({ type: 'negative', message: 'Échec: ' + (data.error || res.status) })
}
await loadTasks()
return
}
Notify.create({ type: 'positive', message: `Tâche prise en charge: ${job.subject}`, icon: 'check' })
// Optimistic: remove from the group list, reload personal list
groupJobs.value = groupJobs.value.filter(j => j.name !== job.name)
await loadTasks()
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur réseau: ' + e.message })
} finally {
claimingJob.value = ''
}
}
function prettify (email) {
return email.split('@')[0].replace(/[._]/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
}
async function doStatus (job, status) {
saving.value = true
try {
const result = await apiSetStatus(job.name, status)
job.status = status
const msgs = {
'In Progress': 'En route ! 🚗',
Completed: result?.unblocked?.length
? `Tâche terminée — ${result.unblocked.length} prochaine${result.unblocked.length > 1 ? 's' : ''} tâche${result.unblocked.length > 1 ? 's' : ''} débloquée${result.unblocked.length > 1 ? 's' : ''}`
: 'Tâche terminée ✓',
}
Notify.create({ type: 'positive', message: msgs[status] || status })
if (status === 'Completed') {
sheetOpen.value = false
// Reload to pick up unblocked siblings/children
await loadTasks()
}
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message })
} finally {
saving.value = false
}
}
function openGps (job) {
const loc = job.service_location_name || ''
window.open(`https://www.google.com/maps/dir/?api=1&destination=${encodeURIComponent(loc)}`, '_blank')
}
function goScan (job) {
sheetOpen.value = false
router.push({ path: '/j/scan', query: {
job: job.name, customer: job.customer, customer_name: job.customer_name,
location: job.service_location, location_name: job.service_location_name,
}})
}
function goDetail (job) {
sheetOpen.value = false
router.push({ path: '/j/job/' + job.name })
}
// ── SSE: real-time chain updates ───────────────────────────────────────────
// When another surface (dispatcher, another tech, webhook) completes a job,
// we receive `job-unblocked` events for any dependents that just opened up.
// Just refresh the list — cheaper than surgical state merging and avoids
// race conditions when multiple events arrive in quick succession.
let sseSource = null
function connectSSE () {
try {
sseSource = new EventSource(`${HUB_URL}/sse?topics=dispatch`)
sseSource.addEventListener('job-unblocked', (e) => {
try {
const data = JSON.parse(e.data)
// Only refresh if it's for us (or no tech specified — conservative refresh)
if (!data.tech || data.tech === magicTechId.value) {
loadTasks()
Notify.create({
type: 'info',
message: `Nouvelle tâche débloquée : ${data.subject || data.job}`,
icon: 'flag',
timeout: 4000,
})
}
} catch {}
})
sseSource.addEventListener('job-status', (e) => {
// Picks up status changes made by dispatchers or other techs
try {
const data = JSON.parse(e.data)
const local = jobs.value.find(j => j.name === data.job)
if (local && local.status !== data.status) local.status = data.status
} catch {}
})
sseSource.addEventListener('job-claimed', (e) => {
// Another tech took a job → drop it from our claimable feed so we
// don't try to grab something someone else already has.
try {
const data = JSON.parse(e.data)
if (data.tech !== magicTechId.value) {
groupJobs.value = groupJobs.value.filter(j => j.name !== data.job)
}
} catch {}
})
} catch {}
}
function disconnectSSE () {
if (sseSource) { sseSource.close(); sseSource = null }
}
onMounted(() => {
window.addEventListener('online', () => { isOnline.value = true })
window.addEventListener('offline', () => { isOnline.value = false })
loadTasks()
connectSSE()
})
onUnmounted(disconnectSSE)
</script>
<style lang="scss" scoped>
// Group subscription card — claimable jobs visually distinct from personal ones
.group-card {
margin-top: 10px;
padding: 12px 14px;
background: var(--tg-bg-card, #fff);
border: 1px dashed var(--tg-border, #d0d7de);
border-radius: 12px;
box-shadow: 0 1px 2px rgba(15, 23, 42, .04);
}
.group-card-row {
display: flex; align-items: center; gap: 12px;
}
.empty-state-hint {
margin-top: 16px;
text-align: center;
}
.tg-chip.is-compact {
padding: 2px 8px;
font-size: 11px;
border-radius: 999px;
display: inline-flex; align-items: center; gap: 4px;
}
// `.spinning` + `@keyframes spin` already defined below for the hero refresh button.
// Hero row layouts (the rest is from tech.scss global tokens)
.hero-top {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 22px;
}
.hero-left {
display: flex; align-items: center; gap: 12px;
}
.hero-name {
font: 700 1.125rem/1.2 system-ui, -apple-system, sans-serif;
color: #fff; letter-spacing: -0.005em;
}
.hero-refresh {
width: 40px; height: 40px;
border-radius: 50%;
border: 0; background: rgba(255,255,255,0.12);
color: #fff;
display: inline-flex; align-items: center; justify-content: center;
cursor: pointer;
transition: background 160ms;
&:active { background: rgba(255,255,255,0.22); }
&:disabled { opacity: 0.6; }
.spinning { animation: spin 0.9s linear infinite; }
}
@keyframes spin { to { transform: rotate(360deg); } }
.hero-progress {
display: flex; align-items: center; gap: 18px;
}
.hero-progress-text { flex: 1; min-width: 0; }
.arc-numerator {
font: 800 1.75rem/1 system-ui, -apple-system, sans-serif;
color: #fff; letter-spacing: -0.02em;
}
.arc-denom {
font: 600 0.75rem/1 system-ui, -apple-system, sans-serif;
color: rgba(255,255,255,0.65); margin-top: 2px;
}
// Content padding (hero handles top; q-page bottom-reserve handles tab-island)
.tg-page--content {
padding: 20px 16px 8px;
}
// Section header
.section-head {
display: flex; align-items: center; gap: 8px;
padding: 0 4px;
margin-bottom: 10px;
&--spaced { margin-top: 22px; }
.section-head-chevron {
margin-left: auto;
font-size: 20px; color: var(--ink-400);
transition: transform 200ms;
&.is-open { transform: rotate(180deg); }
}
}
// Peek sheet internals
.peek-title-row {
display: flex; align-items: flex-start; justify-content: space-between; gap: 12px;
}
.peek-close {
width: 36px; height: 36px;
border: 0; background: var(--surface-wash);
border-radius: 50%;
display: inline-flex; align-items: center; justify-content: center;
cursor: pointer; color: var(--ink-700);
&:active { background: var(--tg-100); }
}
.info-row {
display: flex; align-items: center; gap: 12px;
padding: 12px 0;
border-bottom: 1px solid rgba(15,23,42,0.05);
.col { flex: 1; min-width: 0; }
.info-icon { font-size: 20px; color: var(--ink-500); }
}
.info-row-pair {
display: grid; grid-template-columns: 1fr 1fr; gap: 16px;
.info-row { border-bottom: 1px solid rgba(15,23,42,0.05); padding: 12px 0; }
}
.info-row-stack {
padding: 12px 0;
border-bottom: 1px solid rgba(15,23,42,0.05);
.t-body-sm { margin-top: 4px; }
}
.peek-actions-row {
display: grid; grid-template-columns: 1fr 1fr; gap: 10px;
}
// Empty state
.empty-state {
text-align: center; padding: 60px 24px;
.empty-icon {
width: 72px; height: 72px;
border-radius: 50%;
background: var(--tg-50);
display: inline-flex; align-items: center; justify-content: center;
margin-bottom: 8px;
.material-icons { font-size: 36px; color: var(--tg-500); }
}
}
// Transitions
.fade-enter-active, .fade-leave-active { transition: opacity 200ms; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
.slide-down-enter-active, .slide-down-leave-active {
transition: transform 240ms cubic-bezier(0.32, 0.72, 0, 1), opacity 200ms;
}
.slide-down-enter-from, .slide-down-leave-to { transform: translateY(-100%); opacity: 0; }
</style>