gigafibre-fsm/apps/ops/src/pages/SettingsPage.vue
louispaulb 41d9b5f316 feat: flow editor, Gemini QR scanner with offline queue, dispatch planning v2
Major additions accumulated over 9 days — single commit per request.

Flow editor (new):
- Generic visual editor for step trees, usable by project wizard + agent flows
- PROJECT_KINDS / AGENT_KINDS catalogs decouple UI from domain
- Drag-and-drop reorder via vuedraggable with scope isolation per peer group
- Chain-aware depends_on rewrite on reorder (sequential only — DAGs preserved)
- Variable picker with per-applies_to catalog (Customer / Quotation /
  Service Contract / Issue / Subscription), insert + copy-clipboard modes
- trigger_condition helper with domain-specific JSONLogic examples
- Global FlowEditorDialog mounted once in MainLayout, Odoo inline pattern
- Server: targo-hub flow-runtime.js, flow-api.js, flow-templates.js
- ERPNext: Flow Template/Run doctypes, scheduler, 5 seeded system templates
- depends_on chips resolve to step labels instead of opaque "s4" ids

QR/OCR scanner (field app):
- Camera capture → Gemini Vision via targo-hub with 8s timeout
- IndexedDB offline queue retries photos when signal returns
- Watcher merges late-arriving scan results into the live UI

Dispatch:
- Planning mode (draft → publish) with offer pool for unassigned jobs
- Shared presets, recurrence selector, suggested-slots dialog
- PublishScheduleModal, unassign confirmation

Ops app:
- ClientDetailPage composables extraction (useClientData, useDeviceStatus,
  useWifiDiagnostic, useModemDiagnostic)
- Project wizard: shared detail sections, wizard catalog/publish composables
- Address pricing composable + pricing-mock data
- Settings redesign hosting flow templates

Targo-hub:
- Contract acceptance (JWT residential + DocuSeal commercial tracks)
- Referral system
- Modem-bridge diagnostic normalizer
- Device extractors consolidated

Migration scripts:
- Invoice/quote print format setup, Jinja rendering
- Additional import + fix scripts (reversals, dates, customers, payments)

Docs:
- Consolidated: old scattered MDs → HANDOFF, ARCHITECTURE, DATA_AND_FLOWS,
  FLOW_EDITOR_ARCHITECTURE, BILLING_AND_PAYMENTS, CPE_MANAGEMENT,
  APP_DESIGN_GUIDELINES
- Archived legacy wizard PHP for reference
- STATUS snapshots for 2026-04-18/19

Cleanup:
- Removed ~40 generated PDFs/HTMLs (invoice_preview*, rendered_jinja*)
- .gitignore now covers invoice preview output + nested .DS_Store

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 10:44:17 -04:00

866 lines
42 KiB
Vue

<template>
<q-page padding>
<div class="text-h5 q-mb-md">
<q-icon name="settings" class="q-mr-sm" /> Parametres
</div>
<div v-if="loading" class="flex flex-center q-pa-xl">
<q-spinner size="40px" color="indigo-6" />
</div>
<template v-else>
<!-- SECTION 1: Utilisateurs & Permissions -->
<div v-if="can('manage_permissions')" class="ops-card q-mb-md">
<q-expansion-item header-class="section-header" expand-icon-class="text-grey-7">
<template #header>
<SectionHeader icon="admin_panel_settings" label="Utilisateurs & Permissions" />
</template>
<q-separator />
<div class="q-pa-md">
<q-tabs v-model="permTab" dense no-caps active-color="indigo-6" indicator-color="indigo-6"
class="q-mb-md text-grey-7" align="left">
<q-tab name="users" icon="people" label="Utilisateurs" />
<q-tab name="groups" icon="groups" label="Groupes" />
<q-tab name="matrix" icon="grid_on" label="Matrice" />
</q-tabs>
<!-- TAB: Utilisateurs -->
<div v-show="permTab === 'users'">
<div class="row q-gutter-sm items-end q-mb-md">
<q-input v-model="userSearch" label="Rechercher un utilisateur (nom, email)..." outlined dense
style="min-width:300px;flex:1" @update:model-value="debouncedSearchUsers" debounce="350"
autocomplete="off" name="user-search-nope">
<template #append>
<q-spinner v-if="userSearchLoading" size="16px" color="indigo-6" />
<q-icon v-else-if="userSearch" name="close" class="cursor-pointer" @click="userSearch = ''; userResults = []; selectedUser = null" />
</template>
</q-input>
</div>
<div v-if="userResults.length" class="user-list">
<div v-for="u in userResults" :key="u.pk" class="user-card"
:class="{ 'user-card--selected': selectedUser?.pk === u.pk }"
@click="selectUser(u)">
<div class="row items-center no-wrap">
<q-avatar size="36px" :color="u.is_active ? 'indigo-1' : 'grey-3'"
:text-color="u.is_active ? 'indigo-8' : 'grey-6'" class="q-mr-sm">
{{ initial(u) }}
</q-avatar>
<div class="col">
<div class="text-weight-bold text-body2">{{ u.name || u.username }}</div>
<div class="text-caption text-grey-6">{{ u.email }}</div>
</div>
<q-badge v-if="!u.is_active" label="inactif" color="red-2" text-color="red-9" class="q-mr-xs" />
<q-badge v-if="u.is_superuser" label="superuser" color="amber-2" text-color="amber-9" class="q-mr-xs" />
<div class="row q-gutter-xs">
<q-badge v-for="g in u.groups" :key="g" :label="g" color="indigo-1" text-color="indigo-8"
class="text-caption" style="font-size:0.7rem" />
</div>
<q-icon name="chevron_right" color="grey-5" class="q-ml-sm" />
</div>
</div>
</div>
<div v-else-if="userSearchDone" class="text-center text-grey-5 q-py-lg">
Aucun utilisateur trouve
</div>
<!-- Selected user detail panel -->
<q-slide-transition>
<div v-if="selectedUser" class="user-detail-panel q-mt-md">
<div class="row items-center q-mb-md">
<q-avatar size="42px" color="indigo-1" text-color="indigo-8" class="q-mr-md">
{{ initial(selectedUser) }}
</q-avatar>
<div class="col">
<div class="text-h6">{{ selectedUser.name || selectedUser.username }}</div>
<div class="text-caption text-grey-6">{{ selectedUser.email }}</div>
<div v-if="selectedUser.last_login" class="text-caption text-grey-5">
Derniere connexion: {{ formatDate(selectedUser.last_login) }}
</div>
</div>
<q-btn flat round dense icon="close" @click="selectedUser = null" />
</div>
<!-- Groups assignment -->
<div class="q-mb-md">
<div class="text-subtitle2 q-mb-xs">
<q-icon name="groups" size="18px" class="q-mr-xs" />Groupes
</div>
<div class="row q-gutter-sm items-center flex-wrap">
<q-chip v-for="g in allGroupNames" :key="g" clickable
:color="selectedUser.groups.includes(g) ? 'indigo-6' : 'grey-3'"
:text-color="selectedUser.groups.includes(g) ? 'white' : 'grey-7'"
:icon="selectedUser.groups.includes(g) ? 'check_circle' : 'radio_button_unchecked'"
size="md" @click="toggleUserGroup(selectedUser, g)">
{{ g }}
</q-chip>
</div>
<div v-if="savingGroups" class="text-caption text-grey-5 q-mt-xs">
<q-spinner size="12px" class="q-mr-xs" /> Sauvegarde...
</div>
</div>
<!-- Permission overrides -->
<q-expansion-item dense label="Overrides individuels" caption="Forcer ON/OFF par capacite"
icon="tune" header-class="text-grey-7" class="q-mb-sm"
:default-opened="Object.keys(selectedUser.overrides).length > 0">
<div class="q-pa-sm">
<PermGrid :categories="permCategories" :caps-by-category="permCapsByCategory">
<template #cap="{ cap }">
<div class="perm-override-chip"
:class="{
'perm-override-on': selectedUser.overrides[cap.key] === true,
'perm-override-off': selectedUser.overrides[cap.key] === false
}">
<q-checkbox v-model="selectedUser.overrides[cap.key]" dense size="sm" color="orange-8"
indeterminate-value="undefined" toggle-indeterminate
@update:model-value="v => setUserOverride(selectedUser, cap.key, v)" />
<span class="text-caption">{{ cap.label }}</span>
</div>
</template>
</PermGrid>
<div class="text-caption text-grey-5 q-mt-xs">
Indetermine = herite du groupe. Coche = force ON. Decoche = force OFF.
</div>
</div>
</q-expansion-item>
<!-- Effective permissions summary -->
<q-expansion-item dense label="Permissions effectives" caption="Resultat final (groupes + overrides)"
icon="verified_user" header-class="text-grey-7">
<div class="q-pa-sm">
<div class="effective-perms">
<template v-for="cat in permCategories" :key="cat">
<div class="override-cat-label">{{ cat }}</div>
<div class="row q-gutter-xs flex-wrap q-mb-xs">
<q-badge v-for="cap in permCapsByCategory[cat]" :key="cap.key"
:color="effectivePerm(selectedUser, cap.key) ? 'green-1' : 'grey-2'"
:text-color="effectivePerm(selectedUser, cap.key) ? 'green-9' : 'grey-5'"
class="q-pa-xs" style="font-size:0.72rem">
<q-icon :name="effectivePerm(selectedUser, cap.key) ? 'check' : 'close'" size="12px" class="q-mr-xs" />
{{ cap.label }}
</q-badge>
</div>
</template>
</div>
</div>
</q-expansion-item>
</div>
</q-slide-transition>
</div>
<!-- TAB: Groupes -->
<div v-show="permTab === 'groups'">
<!-- Legacy sync banner -->
<div class="row items-center q-mb-md q-pa-sm" style="background:#fef3c7;border-radius:8px;border:1px solid #fde68a">
<q-icon name="sync" size="20px" color="amber-9" class="q-mr-sm" />
<span class="text-body2 text-amber-10">Importer les groupes depuis le systeme legacy</span>
<q-space />
<q-btn dense unelevated color="amber-8" text-color="white" icon="preview" label="Apercu"
:loading="legacySyncing" @click="syncLegacy(true)" class="q-mr-xs" size="sm" />
<q-btn dense unelevated color="indigo-6" icon="sync" label="Synchroniser"
:loading="legacySyncing" @click="syncLegacy(false)" size="sm" />
</div>
<!-- Sync results dialog -->
<q-dialog v-model="showSyncDialog" position="right" full-height>
<q-card style="width:550px;max-width:90vw" class="column no-wrap">
<q-card-section class="row items-center q-pb-sm" style="border-bottom:1px solid #e2e8f0">
<q-icon :name="syncResult?.dry_run ? 'preview' : 'check_circle'" size="22px"
:color="syncResult?.dry_run ? 'amber-8' : 'green-7'" class="q-mr-sm" />
<div class="text-subtitle1 text-weight-bold">
{{ syncResult?.dry_run ? 'Apercu sync legacy' : 'Sync terminee' }}
</div>
<q-space />
<q-btn flat round dense icon="close" @click="showSyncDialog = false" />
</q-card-section>
<q-card-section v-if="syncResult" class="col" style="overflow-y:auto">
<div class="row q-gutter-sm q-mb-md">
<q-badge color="indigo-1" text-color="indigo-8" class="q-pa-sm">
<q-icon name="add_circle" size="14px" class="q-mr-xs" />
{{ syncResult.summary.to_sync }} a synchroniser
</q-badge>
<q-badge color="green-1" text-color="green-8" class="q-pa-sm">
<q-icon name="check" size="14px" class="q-mr-xs" />
{{ syncResult.summary.already_ok }} deja OK
</q-badge>
<q-badge color="orange-1" text-color="orange-8" class="q-pa-sm">
<q-icon name="help" size="14px" class="q-mr-xs" />
{{ syncResult.summary.not_found }} non trouves
</q-badge>
<q-badge v-if="syncResult.summary.errors" color="red-1" text-color="red-8" class="q-pa-sm">
<q-icon name="error" size="14px" class="q-mr-xs" />
{{ syncResult.summary.errors }} erreurs
</q-badge>
</div>
<div v-if="syncResult.matched.length" class="q-mb-md">
<div class="text-subtitle2 q-mb-xs">
{{ syncResult.dry_run ? 'Utilisateurs a ajouter' : 'Utilisateurs synchronises' }}
</div>
<div v-for="m in syncResult.matched" :key="m.email" class="row items-center q-py-xs"
style="border-bottom:1px solid #f1f5f9;font-size:0.85rem">
<q-icon :name="m.status === 'synced' ? 'check_circle' : 'schedule'" size="16px"
:color="m.status === 'synced' ? 'green-6' : 'amber-6'" class="q-mr-sm" />
<span class="text-weight-medium">{{ m.username }}</span>
<span class="text-grey-6 q-ml-sm">{{ m.email }}</span>
<q-space />
<q-badge :label="m.group" color="indigo-1" text-color="indigo-8" />
</div>
</div>
<div v-if="syncResult.not_found.length" class="q-mb-md">
<div class="text-subtitle2 q-mb-xs text-orange-8">Non trouves dans Authentik</div>
<div v-for="m in syncResult.not_found" :key="m.email" class="row items-center q-py-xs"
style="border-bottom:1px solid #f1f5f9;font-size:0.85rem">
<q-icon name="person_off" size="16px" color="orange-5" class="q-mr-sm" />
<span>{{ m.name }}</span>
<span class="text-grey-6 q-ml-sm">{{ m.email }}</span>
<q-space />
<q-badge :label="m.legacy_group" color="grey-3" text-color="grey-7" />
</div>
</div>
<q-expansion-item v-if="syncResult.already_ok.length" dense
:label="`${syncResult.already_ok.length} deja dans le bon groupe`"
icon="check_circle" header-class="text-green-7 text-caption">
<div class="q-pa-xs">
<div v-for="m in syncResult.already_ok" :key="m.email" class="text-caption text-grey-6 q-py-xs">
{{ m.email }} → {{ m.group }}
</div>
</div>
</q-expansion-item>
<div v-if="syncResult.dry_run && syncResult.summary.to_sync > 0" class="q-mt-md text-center">
<q-btn color="indigo-6" icon="sync" label="Appliquer la synchronisation"
unelevated :loading="legacySyncing" @click="showSyncDialog = false; syncLegacy(false)" />
</div>
</q-card-section>
</q-card>
</q-dialog>
<div v-if="permLoading" class="flex flex-center q-pa-md"><q-spinner size="28px" color="indigo-6" /></div>
<div v-else class="group-cards">
<div v-for="g in permGroups" :key="g.name" class="group-card"
:class="{ 'group-card--selected': selectedGroup === g.name }"
@click="selectGroup(g.name)">
<div class="row items-center no-wrap q-mb-sm">
<q-icon name="groups" size="20px" color="indigo-6" class="q-mr-sm" />
<span class="text-weight-bold">{{ g.name }}</span>
<q-space />
<q-badge :label="g.num_users + ' membres'" color="indigo-1" text-color="indigo-8" />
</div>
<div class="text-caption text-grey-6">
{{ countGroupPerms(g) }} permissions actives
</div>
</div>
</div>
<!-- Group detail -->
<q-slide-transition>
<div v-if="selectedGroup && groupMembers" class="q-mt-md group-detail-panel">
<div class="row items-center q-mb-md">
<q-icon name="groups" size="24px" color="indigo-6" class="q-mr-sm" />
<span class="text-h6">{{ selectedGroup }}</span>
<q-space />
<q-btn flat round dense icon="close" @click="selectedGroup = null; groupMembers = null" />
</div>
<div class="text-subtitle2 q-mb-xs">Membres</div>
<div v-if="groupMembersLoading" class="text-caption text-grey-5">
<q-spinner size="14px" class="q-mr-xs" /> Chargement...
</div>
<div v-else>
<div v-for="m in groupMembers" :key="m.pk" class="row items-center q-py-xs"
style="border-bottom:1px solid #f1f5f9">
<q-avatar size="28px" color="indigo-1" text-color="indigo-8" class="q-mr-sm" style="font-size:0.7rem">
{{ initial(m) }}
</q-avatar>
<span class="text-body2">{{ m.name || m.username }}</span>
<span class="text-caption text-grey-6 q-ml-sm">{{ m.email }}</span>
<q-space />
<q-btn flat round dense icon="person_remove" size="sm" color="red-4"
@click="removeFromGroup(m, selectedGroup)" title="Retirer du groupe">
<q-tooltip>Retirer du groupe</q-tooltip>
</q-btn>
</div>
<div v-if="groupMembers.length === 0" class="text-caption text-grey-5 q-py-sm">
Aucun membre dans ce groupe
</div>
</div>
<!-- Add member -->
<div class="q-mt-sm" style="position:relative">
<q-input v-model="addMemberSearch" label="Ajouter un membre..." outlined dense
autocomplete="off" name="add-member-nope"
@update:model-value="debouncedMemberSearch">
<template #append>
<q-spinner v-if="memberSearchLoading" size="16px" color="indigo-6" />
<q-icon v-else-if="addMemberSearch" name="close" class="cursor-pointer"
@click="addMemberSearch = ''; memberSearchResults = []" />
</template>
</q-input>
<div v-if="memberSearchResults.length" class="member-search-dropdown">
<div v-for="u in memberSearchResults" :key="u.pk" class="member-search-item"
@click="addUserToCurrentGroup(u)">
<q-avatar size="24px" color="indigo-1" text-color="indigo-8" class="q-mr-sm" style="font-size:0.65rem">
{{ initial(u) }}
</q-avatar>
<span class="text-body2">{{ u.name || u.username }}</span>
<span class="text-caption text-grey-6 q-ml-sm">{{ u.email }}</span>
<q-badge v-if="u.groups.includes(selectedGroup)" label="deja membre" color="grey-3" text-color="grey-6" class="q-ml-auto" />
</div>
</div>
</div>
<!-- Group permissions inline -->
<q-expansion-item dense label="Permissions du groupe" icon="security"
header-class="text-grey-7 q-mt-md" default-opened>
<div class="q-pa-sm">
<PermGrid :categories="permCategories" :caps-by-category="permCapsByCategory">
<template #cap="{ cap }">
<div class="perm-override-chip"
:class="{ 'perm-override-on': permMatrix[selectedGroup]?.[cap.key] }">
<q-checkbox v-model="permMatrix[selectedGroup][cap.key]" dense size="sm" color="indigo-6"
@update:model-value="permDirty = true" />
<span class="text-caption">{{ cap.label }}</span>
</div>
</template>
</PermGrid>
<q-btn v-if="permDirty" color="indigo-6" icon="save" label="Sauvegarder permissions"
dense unelevated :loading="permSaving" @click="saveGroupPerms(selectedGroup)" class="q-mt-sm" />
</div>
</q-expansion-item>
</div>
</q-slide-transition>
</div>
<!-- TAB: Matrice (vue globale) -->
<div v-show="permTab === 'matrix'">
<div class="row items-center q-mb-sm">
<span class="text-subtitle2">Matrice globale Groupes x Capacites</span>
<q-space />
<q-btn v-if="permDirty" color="indigo-6" icon="save" label="Sauvegarder" dense unelevated
:loading="permSaving" @click="saveAllPerms" />
</div>
<div v-if="permLoading" class="flex flex-center q-pa-md"><q-spinner size="28px" color="indigo-6" /></div>
<div v-else-if="permGroups.length" class="perm-matrix">
<table class="perm-table">
<thead>
<tr>
<th class="perm-cap-col">Capacite</th>
<th v-for="g in permGroups" :key="g.name" class="perm-group-col">
{{ g.name }}
<div class="text-caption text-grey-6">{{ g.num_users }}</div>
</th>
</tr>
</thead>
<tbody>
<template v-for="cat in permCategories" :key="cat">
<tr class="perm-cat-row">
<td :colspan="permGroups.length + 1" class="text-weight-bold text-grey-7">{{ cat }}</td>
</tr>
<tr v-for="cap in permCapsByCategory[cat]" :key="cap.key">
<td class="perm-cap-label">{{ cap.label }}</td>
<td v-for="g in permGroups" :key="g.name" class="text-center">
<q-checkbox v-model="permMatrix[g.name][cap.key]" dense size="sm" color="indigo-6"
@update:model-value="permDirty = true" />
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</div>
</q-expansion-item>
</div>
<!-- SECTION 2: SMS / Twilio -->
<div class="ops-card q-mb-md">
<q-expansion-item header-class="section-header" expand-icon-class="text-grey-7">
<template #header>
<SectionHeader icon="sms" label="SMS & Templates" />
</template>
<q-separator />
<div class="q-pa-md">
<div class="row q-col-gutter-md">
<div class="col-12 col-md-6">
<q-input v-model="settings.twilio_account_sid" label="Account SID" outlined dense
placeholder="ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
@blur="save('twilio_account_sid')" />
</div>
<div class="col-12 col-md-6">
<q-input v-model="settings.twilio_auth_token" label="Auth Token" outlined dense
:type="showToken ? 'text' : 'password'" placeholder="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
@blur="save('twilio_auth_token')">
<template #append>
<q-icon :name="showToken ? 'visibility_off' : 'visibility'" class="cursor-pointer"
@click="showToken = !showToken" />
</template>
</q-input>
</div>
<div class="col-12 col-md-6">
<q-input v-model="settings.twilio_from_number" label="Numero d'envoi (From)" outlined dense
placeholder="+15145551234"
@blur="save('twilio_from_number')" />
</div>
<div class="col-12 col-md-6">
<div class="row items-center q-gutter-sm">
<q-btn color="indigo-6" icon="send" label="Tester SMS" :loading="testingSms" dense unelevated
@click="testSms" :disable="!settings.twilio_account_sid || !settings.twilio_from_number" />
<q-badge v-if="smsTestResult" :color="smsTestResult.ok ? 'green' : 'red'" class="q-pa-xs">
{{ smsTestResult.message }}
</q-badge>
</div>
</div>
</div>
<div class="text-caption text-grey-6 q-mt-sm">
Les credentials Twilio permettent l'envoi de SMS depuis la fiche client.
<a href="https://console.twilio.com" target="_blank">Console Twilio</a>
</div>
<q-separator class="q-my-md" />
<div class="text-subtitle2 q-mb-sm">Templates SMS</div>
<div class="row q-col-gutter-md">
<div v-for="t in smsTemplates" :key="t.key" class="col-12">
<q-input v-model="settings[t.key]" :label="t.label" outlined dense autogrow
@blur="save(t.key)" />
</div>
</div>
<div class="text-caption text-grey-6 q-mt-sm">
Variables: <code>{client_name}</code>, <code>{tech_name}</code>, <code>{eta}</code>, <code>{job_id}</code>, <code>{address}</code>, <code>{duration}</code>
</div>
</div>
</q-expansion-item>
</div>
<!-- SECTION 3: Integrations -->
<div class="ops-card q-mb-md">
<q-expansion-item header-class="section-header" expand-icon-class="text-grey-7">
<template #header>
<SectionHeader icon="extension" label="Integrations" />
</template>
<q-separator />
<div class="q-pa-md">
<div class="text-subtitle2 q-mb-xs">
<q-icon name="email" size="18px" class="q-mr-xs" /> Email — SMTP
</div>
<div class="text-caption text-grey-6 q-mb-md">
La configuration SMTP (Mailjet) se fait dans ERPNext.
<a :href="erpDeskUrl + '/app/email-account'" target="_blank">Configurer</a>
</div>
<q-separator class="q-my-md" />
<div class="text-subtitle2 q-mb-xs">
<q-icon name="webhook" size="18px" class="q-mr-xs" /> n8n — Webhooks
</div>
<div class="row q-col-gutter-md q-mb-md">
<div class="col-12 col-md-6">
<q-input v-model="settings.n8n_url" label="n8n URL" outlined dense @blur="save('n8n_url')" />
</div>
<div class="col-12 col-md-6">
<q-input v-model="settings.n8n_webhook_base" label="Webhook base URL" outlined dense @blur="save('n8n_webhook_base')" />
</div>
</div>
<q-separator class="q-my-md" />
<div class="text-subtitle2 q-mb-xs">
<q-icon name="credit_card" size="18px" class="q-mr-xs" /> Stripe — Paiements
</div>
<div class="row q-col-gutter-md q-mb-md">
<div class="col-12 col-md-4">
<q-select v-model="settings.stripe_mode" label="Mode" outlined dense emit-value map-options
:options="[{ label: 'Test', value: 'test' }, { label: 'Live', value: 'live' }]"
@update:model-value="save('stripe_mode')" />
</div>
<div class="col-12 col-md-4">
<q-input v-model="settings.stripe_publishable_key" label="Publishable Key" outlined dense
@blur="save('stripe_publishable_key')" />
</div>
<div class="col-12 col-md-4">
<q-input v-model="settings.stripe_secret_key" label="Secret Key" outlined dense
type="password" @blur="save('stripe_secret_key')" />
</div>
</div>
<q-separator class="q-my-md" />
<div class="text-subtitle2 q-mb-xs">
<q-icon name="map" size="18px" class="q-mr-xs" /> Mapbox
</div>
<div class="row q-col-gutter-md">
<div class="col-12">
<q-input v-model="settings.mapbox_token" label="Mapbox Token" outlined dense @blur="save('mapbox_token')" />
</div>
</div>
</div>
</q-expansion-item>
</div>
<!-- SECTION 4: 3CX Phone -->
<div class="ops-card q-mb-md">
<q-expansion-item header-class="section-header" expand-icon-class="text-grey-7">
<template #header>
<SectionHeader icon="phone" label="3CX — Telephone WebRTC" />
</template>
<q-separator />
<div class="q-pa-md">
<div class="row q-col-gutter-md">
<div class="col-12 col-md-6">
<q-input v-model="phoneConfig.wssUrl" label="WSS URL (SBC)" outlined dense
placeholder="wss://targopbx.3cx.ca/wss" hint="WebSocket SBC endpoint" @blur="savePhone" />
</div>
<div class="col-12 col-md-6">
<q-input v-model="phoneConfig.sipDomain" label="SIP Domain" outlined dense
placeholder="targopbx.3cx.ca" @blur="savePhone" />
</div>
<div class="col-12">
<div class="text-caption text-grey-7 q-mb-xs">
Connectez-vous avec vos identifiants 3CX pour recuperer automatiquement vos credentials SIP.
</div>
<div class="row q-gutter-sm items-end">
<q-input v-model="pbxUsername" label="Email 3CX" outlined dense style="width:200px" />
<q-input v-model="pbxPassword" label="Mot de passe" outlined dense type="password" style="width:200px"
@keydown.enter="login3cx" />
<q-btn color="indigo-6" label="Connecter" dense unelevated :loading="loggingIn3cx" @click="login3cx" />
</div>
</div>
<div v-if="phoneConfig.extension" class="col-12">
<div class="row q-gutter-md">
<q-input v-model="phoneConfig.extension" label="Extension" outlined dense readonly style="width:100px" />
<q-input v-model="phoneConfig.authId" label="Auth ID" outlined dense readonly style="width:160px" />
<q-input :model-value="phoneConfig.authPassword ? '--------' : ''" label="Auth Password" outlined dense readonly style="width:160px" />
<q-input v-model="phoneConfig.displayName" label="Nom" outlined dense readonly style="width:160px" />
</div>
<q-badge color="green" class="q-mt-xs">SIP credentials OK — Extension {{ phoneConfig.extension }}</q-badge>
</div>
</div>
<div class="text-caption text-grey-6 q-mt-sm">
Le SBC doit etre active dans 3CX Admin. <a href="https://targopbx.3cx.ca" target="_blank">3CX Admin</a>
</div>
</div>
</q-expansion-item>
</div>
<!-- SECTION 5: Reseau (OLT) -->
<div v-if="can('view_clients')" class="ops-card q-mb-md">
<q-expansion-item header-class="section-header" expand-icon-class="text-grey-7"
@before-show="lazyFlags.network = true">
<template #header>
<SectionHeader icon="lan" label="Reseau — OLT / Fibre" />
</template>
<q-separator />
<div class="q-pa-none">
<NetworkPage v-if="lazyFlags.network" />
</div>
</q-expansion-item>
</div>
<!-- SECTION 6: Telephonie (Fonoster/SIP) -->
<div v-if="can('manage_telephony')" class="ops-card q-mb-md">
<q-expansion-item header-class="section-header" expand-icon-class="text-grey-7"
@before-show="lazyFlags.telephony = true">
<template #header>
<SectionHeader icon="phone_in_talk" label="Telephonie — SIP / Trunks" />
</template>
<q-separator />
<div class="q-pa-none">
<TelephonyPage v-if="lazyFlags.telephony" />
</div>
</q-expansion-item>
</div>
<!-- SECTION 7: Agent AI Flows -->
<div v-if="can('manage_settings')" class="ops-card q-mb-md">
<q-expansion-item header-class="section-header" expand-icon-class="text-grey-7"
@before-show="lazyFlags.agent = true">
<template #header>
<SectionHeader icon="smart_toy" label="Agent AI — Flows" />
</template>
<q-separator />
<div class="q-pa-none">
<AgentFlowsPage v-if="lazyFlags.agent" />
</div>
</q-expansion-item>
</div>
<!-- SECTION 7b: Flow Templates (onboarding / service orchestration) -->
<div v-if="can('manage_settings')" class="ops-card q-mb-md">
<q-expansion-item header-class="section-header" expand-icon-class="text-grey-7"
@before-show="lazyFlags.flows = true">
<template #header>
<SectionHeader icon="account_tree" label="Flows — Orchestration projets" />
</template>
<q-separator />
<div class="q-pa-md">
<FlowTemplatesSection v-if="lazyFlags.flows" />
</div>
</q-expansion-item>
</div>
<!-- SECTION 8: Liens rapides -->
<div class="ops-card">
<q-expansion-item default-opened header-class="section-header" expand-icon-class="text-grey-7">
<template #header>
<SectionHeader icon="launch" label="Liens rapides" />
</template>
<q-separator />
<div class="q-pa-md">
<div class="row q-gutter-sm">
<q-btn flat dense icon="open_in_new" label="ERPNext Desk" :href="erpDeskUrl" target="_blank" />
<q-btn flat dense icon="open_in_new" label="Dispatch Settings" :href="erpDeskUrl + '/app/dispatch-settings'" target="_blank" />
<q-btn flat dense icon="open_in_new" label="n8n" href="https://n8n.gigafibre.ca" target="_blank" />
<q-btn flat dense icon="open_in_new" label="Authentik" href="https://auth.targo.ca" target="_blank" />
<q-btn flat dense icon="open_in_new" label="Twilio Console" href="https://console.twilio.com" target="_blank" />
</div>
</div>
</q-expansion-item>
</div>
</template>
</q-page>
</template>
<script setup>
import { ref, reactive, onMounted, watch, defineAsyncComponent, h, resolveComponent } from 'vue'
import { Notify } from 'quasar'
import { authFetch } from 'src/api/auth'
import { BASE_URL, ERP_DESK_URL as erpDeskUrl } from 'src/config/erpnext'
import { sendTestSms } from 'src/api/sms'
import { getPhoneConfig, savePhoneConfig, fetch3cxCredentials } from 'src/composables/usePhone'
import { usePermissions } from 'src/composables/usePermissions'
import { usePermissionMatrix } from 'src/composables/usePermissionMatrix'
import { useUserGroups } from 'src/composables/useUserGroups'
import { useLegacySync } from 'src/composables/useLegacySync'
const NetworkPage = defineAsyncComponent(() => import('src/pages/NetworkPage.vue'))
const TelephonyPage = defineAsyncComponent(() => import('src/pages/TelephonyPage.vue'))
const AgentFlowsPage = defineAsyncComponent(() => import('src/pages/AgentFlowsPage.vue'))
const FlowTemplatesSection = defineAsyncComponent(() => import('src/components/flow-editor/FlowTemplatesSection.vue'))
// Inline functional component for section headers
const QIcon = resolveComponent('QIcon')
const SectionHeader = (props) => h('div', { class: 'row items-center no-wrap', style: 'width:100%' }, [
h(QIcon, { name: props.icon, size: props.icon === 'admin_panel_settings' ? '22px' : '20px', color: 'indigo-6', class: 'q-mr-sm' }),
h('span', { class: 'text-subtitle1 text-weight-bold' }, props.label),
])
SectionHeader.props = ['icon', 'label']
// Inline functional component for permission category grid
const PermGrid = (props, { slots }) => {
const nodes = []
for (const cat of props.categories) {
nodes.push(h('div', { class: 'override-cat-label', key: cat + '-label' }, cat))
const caps = props.capsByCategory[cat] || []
nodes.push(h('div', { class: 'row q-gutter-xs flex-wrap q-mb-xs', key: cat + '-caps' },
caps.map(cap => slots.cap({ cap }))
))
}
return h('div', { class: 'override-grid' }, nodes)
}
PermGrid.props = ['categories', 'capsByCategory']
const { can, isLoaded } = usePermissions()
const {
permTab, permLoading, permSaving, permDirty,
permGroups, permMatrix,
allGroupNames, permCategories, permCapsByCategory,
loadPerms, saveAllPerms, saveGroupPerms, countGroupPerms,
effectivePerm, setUserOverride,
} = usePermissionMatrix()
const {
userSearch, userResults, userSearchLoading, userSearchDone,
selectedUser, savingGroups,
selectedGroup, groupMembers, groupMembersLoading,
addMemberSearch, memberSearchResults, memberSearchLoading,
debouncedSearchUsers, searchUsers, selectUser, toggleUserGroup,
selectGroup, loadGroupMembers, removeFromGroup,
debouncedMemberSearch, addUserToCurrentGroup,
} = useUserGroups({ permGroups })
const { legacySyncing, showSyncDialog, syncResult, syncLegacy } = useLegacySync({
loadPerms, loadGroupMembers, selectedGroup,
})
// Lazy-load flags for embedded pages
const lazyFlags = reactive({ network: false, telephony: false, agent: false, flows: false })
// Page state
const loading = ref(true)
const settings = ref({})
const showToken = ref(false)
const testingSms = ref(false)
const smsTestResult = ref(null)
// 3CX Phone config
const phoneConfig = ref(getPhoneConfig())
const pbxUsername = ref('')
const pbxPassword = ref('')
const loggingIn3cx = ref(false)
// SMS template definitions
const smsTemplates = [
{ key: 'sms_enroute', label: 'Technicien en route' },
{ key: 'sms_completed', label: 'Service complete' },
{ key: 'sms_assigned', label: 'Job assigne (technicien)' },
]
function notify (msg, type = 'positive', timeout = 1500) {
Notify.create({ type, message: msg, timeout })
}
function initial (u) {
return (u.name || u.username || '?')[0].toUpperCase()
}
function formatDate (d) {
if (!d) return ''
return new Date(d).toLocaleDateString('fr-CA', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
}
function savePhone () {
savePhoneConfig(phoneConfig.value)
notify('Config telephone sauvegardee')
}
async function login3cx () {
if (!pbxUsername.value || !pbxPassword.value) return
loggingIn3cx.value = true
try {
const creds = await fetch3cxCredentials(pbxUsername.value, pbxPassword.value)
Object.assign(phoneConfig.value, {
extension: creds.extension,
authId: creds.authId,
authPassword: creds.authPassword,
displayName: creds.displayName,
})
savePhoneConfig(phoneConfig.value)
pbxPassword.value = ''
notify(`Connecte — Extension ${creds.extension} (${creds.displayName})`, 'positive', 3000)
} catch (e) {
notify('3CX login echoue: ' + e.message, 'negative', 4000)
} finally {
loggingIn3cx.value = false
}
}
// Snapshot for change detection
const snapshots = {}
onMounted(async () => {
try {
const res = await authFetch(BASE_URL + '/api/resource/Dispatch Settings/Dispatch Settings')
if (!res.ok) throw new Error('Failed to load settings')
const json = await res.json()
settings.value = json.data
for (const key of Object.keys(json.data)) {
snapshots[key] = json.data[key] ?? ''
}
} catch (e) {
notify('Erreur chargement parametres: ' + e.message, 'negative')
} finally {
loading.value = false
}
if (isLoaded.value && can('manage_permissions')) {
loadPerms()
searchUsers()
}
})
watch(isLoaded, (loaded) => {
if (loaded && can('manage_permissions') && !permGroups.value.length) {
loadPerms()
searchUsers()
}
}, { immediate: false })
async function save (field) {
const val = settings.value[field] ?? ''
const prev = snapshots[field] ?? ''
if (val === prev) return
snapshots[field] = val
try {
const res = await authFetch(BASE_URL + '/api/resource/Dispatch Settings/Dispatch Settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ [field]: val }),
})
if (!res.ok) throw new Error('Save failed: ' + res.status)
notify('Sauvegarde')
} catch (e) {
notify('Erreur: ' + e.message, 'negative')
settings.value[field] = prev
snapshots[field] = prev
}
}
async function testSms () {
testingSms.value = true
smsTestResult.value = null
try {
const result = await sendTestSms('+15149490739', 'Test SMS depuis Targo Ops', '')
smsTestResult.value = { ok: true, message: result.simulated ? 'Simule (pas envoye)' : 'SMS envoye' }
} catch (e) {
smsTestResult.value = { ok: false, message: e.message }
} finally {
testingSms.value = false
}
}
</script>
<style scoped>
.section-header { padding: 12px 16px; }
.perm-table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
.perm-table th, .perm-table td { padding: 4px 8px; border-bottom: 1px solid #e2e8f0; }
.perm-cap-col { text-align: left; min-width: 200px; }
.perm-group-col { text-align: center; min-width: 90px; white-space: nowrap; }
.perm-cat-row td { padding-top: 12px; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.5px; }
.perm-cap-label { color: #475569; }
.perm-matrix { overflow-x: auto; }
.perm-override-chip { display: inline-flex; align-items: center; gap: 2px; padding: 2px 6px; border-radius: 4px; margin: 2px; background: #f1f5f9; }
.perm-override-on { background: #fef3c7; }
.perm-override-off { background: #fee2e2; }
.override-cat-label { font-size: 0.72rem; font-weight: 700; color: #6b7280; text-transform: uppercase; letter-spacing: 0.5px; margin-top: 8px; margin-bottom: 2px; }
.user-list { max-height: 360px; overflow-y: auto; border: 1px solid #e2e8f0; border-radius: 8px; }
.user-card { padding: 10px 14px; border-bottom: 1px solid #f1f5f9; cursor: pointer; transition: background 0.15s; }
.user-card:last-child { border-bottom: none; }
.user-card:hover { background: #f8fafc; }
.user-card--selected { background: #eef2ff; border-left: 3px solid #6366f1; }
.user-detail-panel { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 10px; padding: 16px; }
.group-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; }
.group-card { padding: 14px; border: 1px solid #e2e8f0; border-radius: 10px; cursor: pointer; transition: all 0.15s; }
.group-card:hover { border-color: #6366f1; background: #fafafe; }
.group-card--selected { border-color: #6366f1; background: #eef2ff; box-shadow: 0 0 0 1px #6366f1; }
.group-detail-panel { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 10px; padding: 16px; }
.effective-perms .q-badge { margin: 1px; }
:deep(.q-page) { min-height: auto !important; padding: 12px !important; }
.member-search-dropdown {
position: absolute; left: 0; right: 0; top: 100%; z-index: 10;
background: white; border: 1px solid #e2e8f0; border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.1); max-height: 240px; overflow-y: auto;
}
.member-search-item {
display: flex; align-items: center; padding: 8px 12px; cursor: pointer;
border-bottom: 1px solid #f1f5f9; font-size: 0.85rem;
}
.member-search-item:last-child { border-bottom: none; }
.member-search-item:hover { background: #f1f5f9; }
</style>