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>
866 lines
42 KiB
Vue
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>
|