Migrate BottomPanel + modals to Quasar native components

- BottomPanel: q-toolbar, q-badge, q-checkbox, q-linear-progress, q-chip, q-btn
- JobEditModal: q-dialog, q-card, q-input, q-select for all form fields
- WoCreateModal: same q-dialog pattern, unified with edit modal
- quasar.config: enable dark mode, brand colors, all needed components
- Scoped styles in each component (no longer in parent CSS)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-03-24 13:45:09 -04:00
parent 5e6f20d871
commit 0a10ea9c82
4 changed files with 279 additions and 184 deletions

View File

@ -43,9 +43,31 @@ module.exports = configure(function (ctx) {
},
framework: {
config: {},
// Only load what we actually use — add more as needed
plugins: ['Notify', 'Loading', 'LocalStorage'],
config: {
dark: true,
brand: {
primary: '#6366f1',
secondary: '#10b981',
accent: '#f59e0b',
dark: '#0d0f18',
'dark-page': '#0d0f18',
positive: '#10b981',
negative: '#ef4444',
info: '#3b82f6',
warning: '#f59e0b',
},
},
plugins: ['Notify', 'Loading', 'LocalStorage', 'Dialog'],
components: [
'QTable', 'QTh', 'QTr', 'QTd', 'QCheckbox',
'QDialog', 'QCard', 'QCardSection', 'QCardActions',
'QDrawer', 'QMenu', 'QList', 'QItem', 'QItemSection', 'QItemLabel',
'QSplitter', 'QSeparator',
'QInput', 'QSelect', 'QBtn', 'QBtnGroup', 'QIcon',
'QBadge', 'QChip', 'QTooltip', 'QLinearProgress',
'QToolbar', 'QToolbarTitle', 'QSpace',
'QTab', 'QTabs', 'QTabPanels', 'QTabPanel',
],
},
animations: [],

View File

@ -1,6 +1,6 @@
<script setup>
import { inject } from 'vue'
import { fmtDur, shortAddr, prioLabel, prioClass, prioColor, dayLoadColor, ICON } from 'src/composables/useHelpers'
import { ref, computed, inject } from 'vue'
import { fmtDur, shortAddr, prioLabel, prioClass, prioColor, dayLoadColor } from 'src/composables/useHelpers'
const props = defineProps({
open: Boolean,
@ -12,7 +12,7 @@ const props = defineProps({
})
const emit = defineEmits([
'update:open', 'update:height', 'resize-start',
'update:open', 'resize-start',
'toggle-select', 'select-all', 'clear-select', 'batch-assign',
'auto-distribute', 'open-criteria',
'row-click', 'row-dblclick', 'row-dragstart',
@ -21,100 +21,138 @@ const emit = defineEmits([
const store = inject('store')
const TECH_COLORS = inject('TECH_COLORS')
const jobColor = inject('jobColor')
const btColW = inject('btColW')
const startColResize = inject('startColResize')
// Flatten groups into rows with date separators
const flatRows = computed(() => {
const rows = []
for (const g of (props.groups || [])) {
rows.push({ _separator: true, label: g.label, count: g.jobs.length })
for (const job of g.jobs) rows.push(job)
}
return rows
})
const columns = [
{ name: 'prio', label: '', field: 'priority', align: 'center', style: 'width:12px;padding:0 4px', headerStyle: 'width:12px' },
{ name: 'subject', label: 'Nom', field: 'subject', align: 'left', sortable: true },
{ name: 'address', label: 'Adresse', field: row => shortAddr(row.address) || '—', align: 'left', sortable: true },
{ name: 'duration', label: 'Durée', field: 'duration', align: 'left', sortable: true, style: 'width:140px' },
{ name: 'priority', label: 'Priorité', field: 'priority', align: 'left', sortable: true, style: 'width:80px' },
{ name: 'tags', label: 'Skills / Tags', field: 'tags', align: 'left' },
]
const selectedIds = computed({
get: () => [...props.selected],
set: () => {},
})
function isSelected (id) { return props.selected.has(id) }
</script>
<template>
<div v-if="open" class="sb-bottom-panel" :style="'height:'+height+'px'">
<div class="sb-bottom-resize" @mousedown.prevent="emit('resize-start', $event)"></div>
<div class="sb-bottom-hdr">
<span class="sb-bottom-title">
Jobs non assignées
<span class="sbf-count">{{ unscheduledCount }}</span>
</span>
<button v-if="unscheduledCount" class="sbf-auto-btn" @click="emit('auto-distribute')" title="Répartir automatiquement"> Répartir auto</button>
<button class="sbf-auto-btn" style="border-color:rgba(255,255,255,0.12)" @click="emit('open-criteria')" title="Critères de dispatch"> Critères</button>
<!-- Batch assign bar -->
<template v-if="selected.size">
<span class="sb-bottom-sel-count">{{ selected.size }} sélectionnée{{ selected.size>1?'s':'' }}</span>
<span class="sb-bottom-sel-lbl"></span>
<button v-for="t in store.technicians" :key="t.id" class="sb-multi-tech"
:style="'border-color:'+TECH_COLORS[t.colorIdx]" :title="t.fullName"
@click="emit('batch-assign', t.id)">
{{ t.fullName.split(' ').map(n=>n[0]).join('').toUpperCase().slice(0,2) }}
</button>
<button class="sb-bottom-sel-clear" @click="emit('clear-select')"></button>
</template>
<div style="flex:1"></div>
<button v-if="unscheduledCount" class="sb-bottom-sel-all" @click="emit('select-all')" title="Tout sélectionner"> Tout</button>
<button class="sb-bottom-close" @click="emit('update:open', false)"></button>
</div>
<div class="sb-bottom-body"
<div v-if="open" class="sb-bottom-panel" :style="'height:'+height+'px'"
:class="{ 'sbf-drop-active': dropActive }"
@dragover.prevent="$emit('drop-unassign', $event, 'over')"
@dragleave="$emit('drop-unassign', $event, 'leave')"
@drop="$emit('drop-unassign', $event, 'drop')">
<div v-if="dropActive" class="sbf-drop-hint" style="margin:4px"> Désaffecter ici</div>
<table class="sb-bottom-table">
<thead>
<tr>
<th class="sb-bt-chk" style="width:28px"></th>
<th class="sb-bt-prio" style="width:12px"></th>
<th class="sb-bt-name" :style="'width:'+btColW('name',200)"><span>Nom</span><div class="sb-col-resize" @mousedown="startColResize($event,'name')"></div></th>
<th class="sb-bt-addr" :style="'width:'+btColW('addr',180)"><span>Adresse</span><div class="sb-col-resize" @mousedown="startColResize($event,'addr')"></div></th>
<th class="sb-bt-dur" :style="'width:'+btColW('dur',130)"><span>Durée</span><div class="sb-col-resize" @mousedown="startColResize($event,'dur')"></div></th>
<th class="sb-bt-prio-lbl" style="width:70px">Priorité</th>
<th class="sb-bt-tags">Skills / Tags</th>
</tr>
</thead>
</table>
<div class="sb-bottom-resize" @mousedown.prevent="emit('resize-start', $event)"></div>
<!-- Header toolbar -->
<q-toolbar class="sb-bottom-hdr" dense>
<span class="sb-bottom-title">
Jobs non assignées
<q-badge color="primary" :label="unscheduledCount" />
</span>
<q-btn v-if="unscheduledCount" flat dense size="sm" color="primary" icon="bolt" label="Répartir auto" @click="emit('auto-distribute')" />
<q-btn flat dense size="sm" icon="tune" label="Critères" @click="emit('open-criteria')" />
<template v-if="selected.size">
<q-separator vertical inset class="q-mx-sm" />
<span class="text-primary text-weight-bold" style="font-size:0.72rem">{{ selected.size }} sélectionnée{{ selected.size>1?'s':'' }}</span>
<span class="q-mx-xs text-grey-6"></span>
<q-btn v-for="t in store.technicians" :key="t.id" round dense size="xs" class="q-mx-xs"
:style="'border:2px solid '+TECH_COLORS[t.colorIdx]+';color:#fff;background:transparent'"
@click="emit('batch-assign', t.id)">
<q-tooltip>{{ t.fullName }}</q-tooltip>
{{ t.fullName.split(' ').map(n=>n[0]).join('').toUpperCase().slice(0,2) }}
</q-btn>
<q-btn flat dense size="xs" icon="close" color="grey" @click="emit('clear-select')" />
</template>
<q-space />
<q-btn v-if="unscheduledCount" flat dense size="sm" icon="select_all" label="Tout" @click="emit('select-all')" />
<q-btn flat dense size="sm" icon="close" @click="emit('update:open', false)" />
</q-toolbar>
<!-- Drop hint -->
<div v-if="dropActive" class="sbf-drop-hint" style="margin:4px;text-align:center"> Désaffecter ici</div>
<!-- Table body -->
<div class="sb-bottom-scroll">
<template v-for="group in groups" :key="group.date||'nodate'">
<div class="sb-bottom-date-sep">
<span class="sb-bottom-date-label">{{ group.label }}</span>
<span class="sb-bottom-date-count">{{ group.jobs.length }}</span>
<q-badge color="grey-8" :label="group.jobs.length" />
</div>
<table class="sb-bottom-table">
<q-markup-table flat dense dark separator="cell" class="sb-bottom-qtable">
<tbody>
<tr v-for="job in group.jobs" :key="job.id"
class="sb-bottom-row" :class="{ 'sb-bottom-row-sel': selected.has(job.id) }"
class="sb-bottom-row cursor-pointer"
:class="{ 'sb-bottom-row-sel': isSelected(job.id) }"
draggable="true"
@dragstart="emit('row-dragstart', $event, job)"
@click="emit('row-click', job, $event)"
@dblclick.stop="emit('row-dblclick', job)">
<td class="sb-bt-chk" style="width:28px" @click.stop="emit('toggle-select', job.id, $event)">
<span class="sb-bt-checkbox" :class="{ checked: selected.has(job.id) }"></span>
<td style="width:28px;padding:0 4px" @click.stop="emit('toggle-select', job.id, $event)">
<q-checkbox dense :model-value="isSelected(job.id)" @update:model-value="emit('toggle-select', job.id, $event)" color="primary" size="xs" />
</td>
<td class="sb-bt-prio" style="width:12px">
<span class="sb-bt-prio-dot" :style="'background:'+prioColor(job.priority)" :title="prioLabel(job.priority)"></span>
<td style="width:12px;padding:0 4px">
<div class="sb-bt-prio-dot" :style="'background:'+prioColor(job.priority)"></div>
</td>
<td class="sb-bt-name" :style="'width:'+btColW('name',200)">
<td class="sb-bt-name-cell">
<span class="sb-bt-name-text">{{ job.subject }}</span>
</td>
<td class="sb-bt-addr" :style="'width:'+btColW('addr',180)">{{ shortAddr(job.address) || '—' }}</td>
<td class="sb-bt-dur" :style="'width:'+btColW('dur',130)">
<td class="text-grey-5" style="font-size:0.65rem">{{ shortAddr(job.address) || '—' }}</td>
<td style="width:140px">
<div class="sb-bt-dur-wrap">
<div class="sb-bt-dur-bar">
<div class="sb-bt-dur-fill" :style="{ width: Math.min(100,(parseFloat(job.duration)||0)/8*100)+'%', background: dayLoadColor((parseFloat(job.duration)||0)/8) }"></div>
</div>
<span class="sb-bt-dur-lbl">{{ fmtDur(job.duration) }}</span>
<q-linear-progress :value="Math.min(1,(parseFloat(job.duration)||0)/8)" :color="(parseFloat(job.duration)||0)/8 > 0.75 ? 'negative' : (parseFloat(job.duration)||0)/8 > 0.5 ? 'warning' : 'positive'" track-color="grey-9" size="4px" rounded class="q-mr-sm" style="flex:1;min-width:30px" />
<span class="text-grey-5" style="font-size:0.62rem;font-variant-numeric:tabular-nums;min-width:28px">{{ fmtDur(job.duration) }}</span>
</div>
</td>
<td class="sb-bt-prio-lbl" style="width:70px">
<span :class="prioClass(job.priority)" class="sb-bt-prio-tag">{{ prioLabel(job.priority) }}</span>
<td style="width:80px">
<q-badge :color="job.priority==='high'?'negative':job.priority==='medium'?'warning':'grey-7'" :label="prioLabel(job.priority)" dense />
</td>
<td class="sb-bt-tags">
<span v-for="t in (job.tags||[])" :key="t" class="sb-bt-skill-chip">{{ t }}</span>
<span v-if="!(job.tags||[]).length" class="sb-bt-no-tag"></span>
<td>
<q-chip v-for="t in (job.tags||[])" :key="t" dense size="sm" color="grey-9" text-color="grey-3" class="q-mr-xs">{{ t }}</q-chip>
<span v-if="!(job.tags||[]).length" class="text-grey-8" style="font-size:0.6rem"></span>
</td>
</tr>
</tbody>
</table>
</q-markup-table>
</template>
<div v-if="!unscheduledCount" class="sbf-empty" style="padding:1rem;text-align:center">Aucune job non assignée</div>
</div>
<div v-if="!unscheduledCount" class="text-center text-grey-6 q-pa-lg">Aucune job non assignée</div>
</div>
</div>
</template>
<style scoped>
.sb-bottom-panel { flex-shrink:0; border-top:1px solid rgba(255,255,255,0.06); display:flex; flex-direction:column; background:#111422; position:relative; overflow:hidden; }
.sb-bottom-resize { position:absolute; top:0; left:0; right:0; height:4px; z-index:10; cursor:row-resize; }
.sb-bottom-resize:hover { background:rgba(99,102,241,0.35); }
.sb-bottom-hdr { background:#111422; border-bottom:1px solid rgba(255,255,255,0.06); min-height:34px; }
.sb-bottom-title { font-size:0.62rem; font-weight:800; text-transform:uppercase; letter-spacing:0.07em; color:#7b80a0; display:flex; align-items:center; gap:6px; margin-right:8px; }
.sb-bottom-scroll { flex:1; overflow-y:auto; overflow-x:auto; }
.sb-bottom-scroll::-webkit-scrollbar { width:4px; height:4px; }
.sb-bottom-scroll::-webkit-scrollbar-thumb { background:rgba(255,255,255,0.1); }
.sb-bottom-date-sep { display:flex; align-items:center; gap:6px; padding:4px 12px; background:rgba(99,102,241,0.06); border-bottom:1px solid rgba(255,255,255,0.06); position:sticky; top:0; z-index:2; }
.sb-bottom-date-label { font-size:0.62rem; font-weight:800; color:#6366f1; text-transform:uppercase; letter-spacing:0.05em; }
.sb-bottom-qtable { background:transparent !important; }
.sb-bottom-qtable td { padding:4px 8px; font-size:0.72rem; border-bottom:1px solid rgba(255,255,255,0.04) !important; }
.sb-bottom-row { transition:background 0.1s; }
.sb-bottom-row:hover { background:rgba(255,255,255,0.04); }
.sb-bottom-row-sel { background:rgba(99,102,241,0.1) !important; }
.sb-bt-prio-dot { width:8px; height:8px; border-radius:50%; }
.sb-bt-name-cell { max-width:220px; }
.sb-bt-name-text { font-weight:600; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; display:block; max-width:220px; }
.sb-bt-dur-wrap { display:flex; align-items:center; gap:6px; }
</style>

View File

@ -1,10 +1,9 @@
<script setup>
import { inject } from 'vue'
import { ICON } from 'src/composables/useHelpers'
import { computed, inject } from 'vue'
import TagInput from 'src/components/TagInput.vue'
const props = defineProps({ modelValue: Object }) // { job, subject, address, note, duration, priority, tags, latitude, longitude }
const emit = defineEmits(['update:modelValue', 'confirm', 'cancel'])
const props = defineProps({ modelValue: Object })
const emit = defineEmits(['update:modelValue', 'confirm'])
const store = inject('store')
const MAPBOX_TOKEN = inject('MAPBOX_TOKEN')
@ -14,27 +13,32 @@ const searchAddr = inject('searchAddr')
const addrResults = inject('addrResults')
const selectAddr = inject('selectAddr')
function close () { emit('update:modelValue', null); emit('cancel') }
const show = computed({
get: () => !!props.modelValue,
set: v => { if (!v) emit('update:modelValue', null) },
})
</script>
<template>
<div v-if="modelValue" class="sb-overlay" @click.self="close">
<div class="sb-modal sb-modal-wo">
<div class="sb-modal-hdr">
<span> Modifier la job</span>
<button class="sb-rp-close" @click="close"></button>
</div>
<div class="sb-modal-body sb-wo-body">
<q-dialog v-model="show" persistent>
<q-card dark class="sb-modal-card" style="min-width:500px;max-width:720px">
<q-card-section class="row items-center q-pb-sm">
<div class="text-subtitle2 text-weight-bold"> Modifier la job</div>
<q-space />
<q-btn icon="close" flat round dense v-close-popup />
</q-card-section>
<q-separator dark />
<q-card-section v-if="modelValue" class="sb-modal-body-row">
<div class="sb-wo-form">
<div class="sb-form-row">
<label class="sb-form-lbl">Titre</label>
<input class="sb-form-input" v-model="modelValue.subject" autofocus />
</div>
<div class="sb-form-row">
<q-input v-model="modelValue.subject" label="Titre" dark dense outlined class="q-mb-sm" />
<div class="q-mb-sm">
<label class="sb-form-lbl">Adresse</label>
<div class="sb-addr-wrap">
<input class="sb-form-input" v-model="modelValue.address" placeholder="123 rue Exemple"
@input="searchAddr(modelValue.address)" @blur="setTimeout(()=>addrResults.length=0,200)" autocomplete="off" />
<q-input v-model="modelValue.address" dense outlined dark placeholder="123 rue Exemple"
@update:model-value="searchAddr(modelValue.address)" debounce="300" />
<div v-if="addrResults.length" class="sb-addr-dropdown">
<div v-for="a in addrResults" :key="a.address_full" class="sb-addr-item"
@mousedown.prevent="selectAddr(a, modelValue)">
@ -45,36 +49,52 @@ function close () { emit('update:modelValue', null); emit('cancel') }
</div>
</div>
</div>
<div class="sb-form-row">
<label class="sb-form-lbl">Note</label>
<textarea class="sb-form-input" v-model="modelValue.note" rows="2" placeholder="Ex: chien dangereux, sonner 2x…" style="resize:vertical"></textarea>
<q-input v-model="modelValue.note" label="Note" dark dense outlined type="textarea" rows="2"
placeholder="Ex: chien dangereux, sonner 2x…" class="q-mb-sm" />
<div class="row q-col-gutter-sm q-mb-sm">
<div class="col-6">
<q-input v-model.number="modelValue.duration" label="Durée (h)" dark dense outlined type="number" min="0.25" max="24" step="0.25" />
</div>
<div class="sb-form-row">
<label class="sb-form-lbl">Durée (h)</label>
<input type="number" class="sb-form-input" v-model.number="modelValue.duration" min="0.25" max="24" step="0.25" />
<div class="col-6">
<q-select v-model="modelValue.priority" label="Priorité" dark dense outlined emit-value map-options
:options="[{label:'Basse',value:'low'},{label:'Moyenne',value:'medium'},{label:'Haute',value:'high'}]" />
</div>
<div class="sb-form-row">
<label class="sb-form-lbl">Priorité</label>
<select class="sb-form-sel" v-model="modelValue.priority">
<option value="low">Basse</option>
<option value="medium">Moyenne</option>
<option value="high">Haute</option>
</select>
</div>
<div class="sb-form-row">
<div class="q-mb-sm">
<label class="sb-form-lbl">Tags / Skills</label>
<TagInput v-model="modelValue.tags" :all-tags="store.allTags" :get-color="getTagColor" @create="onCreateTag" />
</div>
</div>
<div v-if="modelValue.latitude" class="sb-wo-minimap">
<img :src="'https://api.mapbox.com/styles/v1/mapbox/dark-v11/static/pin-s+6366f1('+modelValue.longitude+','+modelValue.latitude+')/'+modelValue.longitude+','+modelValue.latitude+',14,0/280x280@2x?access_token='+MAPBOX_TOKEN"
alt="Carte" class="sb-minimap-img" />
</div>
</div>
<div class="sb-modal-ftr">
<button class="sbf-primary-btn" @click="emit('confirm')"> Enregistrer</button>
<button class="sb-rp-btn" @click="close">Annuler</button>
</div>
</div>
</div>
</q-card-section>
<q-separator dark />
<q-card-actions align="right" dark>
<q-btn flat label="Annuler" v-close-popup />
<q-btn color="primary" label="Enregistrer" @click="emit('confirm')" />
</q-card-actions>
</q-card>
</q-dialog>
</template>
<style scoped>
.sb-modal-body-row { display:flex; gap:1rem; }
.sb-wo-form { flex:1; min-width:0; }
.sb-wo-minimap { width:240px; flex-shrink:0; }
.sb-minimap-img { width:100%; border-radius:8px; border:1px solid rgba(255,255,255,0.06); }
.sb-form-lbl { display:block; font-size:0.62rem; font-weight:700; text-transform:uppercase; letter-spacing:0.05em; color:#7b80a0; margin-bottom:4px; }
.sb-addr-wrap { position:relative; }
.sb-addr-dropdown { position:absolute; top:100%; left:0; right:0; z-index:50; background:#181c2e; border:1px solid rgba(99,102,241,0.3); border-radius:6px; max-height:200px; overflow-y:auto; box-shadow:0 8px 24px rgba(0,0,0,0.4); }
.sb-addr-item { padding:6px 10px; cursor:pointer; font-size:0.72rem; color:#e2e4ef; border-bottom:1px solid rgba(255,255,255,0.04); }
.sb-addr-item:hover { background:rgba(99,102,241,0.12); }
.sb-addr-cp { font-size:0.6rem; color:#6366f1; margin-left:4px; }
.sb-addr-city { float:right; font-size:0.6rem; color:#7b80a0; }
</style>

View File

@ -1,9 +1,9 @@
<script setup>
import { inject } from 'vue'
import { computed, inject } from 'vue'
import TagInput from 'src/components/TagInput.vue'
const props = defineProps({ modelValue: Object })
const emit = defineEmits(['update:modelValue', 'confirm', 'cancel'])
const emit = defineEmits(['update:modelValue', 'confirm'])
const store = inject('store')
const MAPBOX_TOKEN = inject('MAPBOX_TOKEN')
@ -13,27 +13,32 @@ const searchAddr = inject('searchAddr')
const addrResults = inject('addrResults')
const selectAddr = inject('selectAddr')
function close () { emit('update:modelValue', null); emit('cancel') }
const show = computed({
get: () => !!props.modelValue,
set: v => { if (!v) emit('update:modelValue', null) },
})
</script>
<template>
<div v-if="modelValue" class="sb-overlay" @click.self="close">
<div class="sb-modal sb-modal-wo">
<div class="sb-modal-hdr">
<span>+ Nouveau work order</span>
<button class="sb-rp-close" @click="close"></button>
</div>
<div class="sb-modal-body sb-wo-body">
<q-dialog v-model="show" persistent>
<q-card dark class="sb-modal-card" style="min-width:500px;max-width:720px">
<q-card-section class="row items-center q-pb-sm">
<div class="text-subtitle2 text-weight-bold">+ Nouveau work order</div>
<q-space />
<q-btn icon="close" flat round dense v-close-popup />
</q-card-section>
<q-separator dark />
<q-card-section v-if="modelValue" class="sb-modal-body-row">
<div class="sb-wo-form">
<div class="sb-form-row">
<label class="sb-form-lbl">Titre *</label>
<input class="sb-form-input" v-model="modelValue.subject" placeholder="Ex: Remplacement modem" autofocus />
</div>
<div class="sb-form-row">
<q-input v-model="modelValue.subject" label="Titre *" dark dense outlined autofocus class="q-mb-sm" />
<div class="q-mb-sm">
<label class="sb-form-lbl">Adresse</label>
<div class="sb-addr-wrap">
<input class="sb-form-input" v-model="modelValue.address" placeholder="123 rue Exemple, Montréal"
@input="searchAddr(modelValue.address)" @blur="setTimeout(()=>addrResults.length=0,200)" autocomplete="off" />
<q-input v-model="modelValue.address" dense outlined dark placeholder="123 rue Exemple, Montréal"
@update:model-value="searchAddr(modelValue.address)" debounce="300" />
<div v-if="addrResults.length" class="sb-addr-dropdown">
<div v-for="a in addrResults" :key="a.address_full" class="sb-addr-item"
@mousedown.prevent="selectAddr(a, modelValue)">
@ -43,51 +48,61 @@ function close () { emit('update:modelValue', null); emit('cancel') }
</div>
</div>
</div>
<div v-if="modelValue.latitude" class="sb-addr-confirmed">
<div v-if="modelValue.latitude" class="text-positive" style="font-size:0.6rem;margin-top:2px">
{{ modelValue.ville || '' }} {{ modelValue.code_postal || '' }}
</div>
</div>
<div class="sb-form-row">
<label class="sb-form-lbl">Note</label>
<textarea class="sb-form-input" v-model="modelValue.note" rows="2" placeholder="Ex: chien dangereux, sonner 2x…" style="resize:vertical"></textarea>
<q-input v-model="modelValue.note" label="Note" dark dense outlined type="textarea" rows="2"
placeholder="Ex: chien dangereux, sonner 2x…" class="q-mb-sm" />
<div class="row q-col-gutter-sm q-mb-sm">
<div class="col-6">
<q-input v-model.number="modelValue.duration_h" label="Durée (h)" dark dense outlined type="number" min="0.5" max="12" step="0.5" />
</div>
<div class="sb-form-row">
<label class="sb-form-lbl">Durée (h)</label>
<input type="number" class="sb-form-input" v-model.number="modelValue.duration_h" min="0.5" max="12" step="0.5" />
<div class="col-6">
<q-select v-model="modelValue.priority" label="Priorité" dark dense outlined emit-value map-options
:options="[{label:'Basse',value:'low'},{label:'Moyenne',value:'medium'},{label:'Haute',value:'high'}]" />
</div>
<div class="sb-form-row">
<label class="sb-form-lbl">Priorité</label>
<select class="sb-form-sel" v-model="modelValue.priority">
<option value="low">Basse</option>
<option value="medium">Moyenne</option>
<option value="high">Haute</option>
</select>
</div>
<div class="sb-form-row">
<div class="q-mb-sm">
<label class="sb-form-lbl">Tags / Skills</label>
<TagInput v-model="modelValue.tags" :all-tags="store.allTags" :get-color="getTagColor" @create="onCreateTag" />
</div>
<div class="sb-form-row">
<label class="sb-form-lbl">Technicien</label>
<select class="sb-form-sel" v-model="modelValue.techId">
<option value=""> Non assigné </option>
<option v-for="t in store.technicians" :key="t.id" :value="t.id">{{ t.fullName }}</option>
</select>
</div>
<div class="sb-form-row" v-if="modelValue.techId">
<label class="sb-form-lbl">Date planifiée</label>
<input type="date" class="sb-form-input" v-model="modelValue.date" />
</div>
<q-select v-model="modelValue.techId" label="Technicien" dark dense outlined emit-value map-options class="q-mb-sm"
:options="[{label:'— Non assigné —',value:''},...store.technicians.map(t=>({label:t.fullName,value:t.id}))]" />
<q-input v-if="modelValue.techId" v-model="modelValue.date" label="Date planifiée" dark dense outlined type="date" />
</div>
<div v-if="modelValue.latitude" class="sb-wo-minimap">
<img :src="'https://api.mapbox.com/styles/v1/mapbox/dark-v11/static/pin-s+6366f1('+modelValue.longitude+','+modelValue.latitude+')/'+modelValue.longitude+','+modelValue.latitude+',14,0/280x280@2x?access_token='+MAPBOX_TOKEN"
alt="Carte" class="sb-minimap-img" />
</div>
</div>
<div class="sb-modal-ftr">
<button class="sbf-primary-btn" :disabled="!modelValue.subject?.trim()" @click="emit('confirm')"> Créer</button>
<button class="sb-rp-btn" @click="close">Annuler</button>
</div>
</div>
</div>
</q-card-section>
<q-separator dark />
<q-card-actions align="right" dark>
<q-btn flat label="Annuler" v-close-popup />
<q-btn color="primary" label="Créer" :disable="!modelValue?.subject?.trim()" @click="emit('confirm')" />
</q-card-actions>
</q-card>
</q-dialog>
</template>
<style scoped>
.sb-modal-body-row { display:flex; gap:1rem; }
.sb-wo-form { flex:1; min-width:0; }
.sb-wo-minimap { width:240px; flex-shrink:0; }
.sb-minimap-img { width:100%; border-radius:8px; border:1px solid rgba(255,255,255,0.06); }
.sb-form-lbl { display:block; font-size:0.62rem; font-weight:700; text-transform:uppercase; letter-spacing:0.05em; color:#7b80a0; margin-bottom:4px; }
.sb-addr-wrap { position:relative; }
.sb-addr-dropdown { position:absolute; top:100%; left:0; right:0; z-index:50; background:#181c2e; border:1px solid rgba(99,102,241,0.3); border-radius:6px; max-height:200px; overflow-y:auto; box-shadow:0 8px 24px rgba(0,0,0,0.4); }
.sb-addr-item { padding:6px 10px; cursor:pointer; font-size:0.72rem; color:#e2e4ef; border-bottom:1px solid rgba(255,255,255,0.04); }
.sb-addr-item:hover { background:rgba(99,102,241,0.12); }
.sb-addr-cp { font-size:0.6rem; color:#6366f1; margin-left:4px; }
.sb-addr-city { float:right; font-size:0.6rem; color:#7b80a0; }
</style>