Settings → Staff tab: one row per internal Authentik user with provisioning state (active, groups, System User / Employee / Dispatch Technician / identity), flagging orphan (active but no System User → not provisioned, e.g. Karim Takougang), departed (inactive/identity-departed), ok. Hub /auth/staff (GET reconciliation) + /auth/staff/provision (groups + System User + Employee + Tech + identity in one click) + /auth/staff/active (deactivate/reactivate, non-destructive) + /auth/staff/impact + guarded DELETE (refuses with 409 if tickets/jobs reference the person → offers deactivate). Writes admin-gated. identity.js gains programmatic upsert/setActive. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1145 lines
59 KiB
Vue
1145 lines
59 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="primary" />
|
|
</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="primary" indicator-color="primary"
|
|
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-tab name="identities" icon="badge" label="Identités" />
|
|
<q-tab name="staff" icon="manage_accounts" label="Staff" />
|
|
</q-tabs>
|
|
|
|
<!-- TAB: Identités unifiées (label + alias courriel + lien tech + fusion) -->
|
|
<div v-show="permTab === 'identities'">
|
|
<IdentityManager />
|
|
</div>
|
|
|
|
<!-- TAB: Console Staff (réconciliation Authentik ↔ System User/Employee/Tech ; provisionner/désactiver/supprimer) -->
|
|
<div v-show="permTab === 'staff'">
|
|
<StaffConsole />
|
|
</div>
|
|
|
|
<!-- 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="primary" />
|
|
<q-icon v-else-if="userSearch" name="close" class="cursor-pointer" @click="userSearch = ''; userResults = []; selectedUser = null" />
|
|
</template>
|
|
</q-input>
|
|
<q-btn v-if="can('manage_users')" unelevated color="primary" icon="person_add" label="Inviter"
|
|
@click="openInviteDialog()" no-caps />
|
|
</div>
|
|
|
|
<!-- Invite dialog — POST /auth/users on the hub creates the
|
|
user in BOTH Authentik (with recovery email) and ERPNext
|
|
in one round-trip. Defaults to sysadmin group + Employee
|
|
role; admin can adjust before sending. -->
|
|
<q-dialog v-model="inviteOpen" persistent>
|
|
<q-card style="min-width:420px;max-width:520px">
|
|
<q-card-section class="row items-center q-pb-none">
|
|
<div class="text-h6">Inviter un utilisateur</div>
|
|
<q-space />
|
|
<q-btn flat round dense icon="close" v-close-popup :disable="inviteSaving" />
|
|
</q-card-section>
|
|
|
|
<!-- Form (hidden after success — we replace it with the credentials panel) -->
|
|
<q-card-section v-if="!lastInviteResult" class="q-pt-sm">
|
|
<div class="text-caption text-grey-7 q-mb-md">
|
|
Un mot de passe temporaire sera généré, envoyé par courriel,
|
|
et affiché ici en cas de problème de livraison.
|
|
L'utilisateur pourra le changer après sa première connexion.
|
|
</div>
|
|
<q-input v-model="inviteForm.full_name" label="Nom complet *" outlined dense class="q-mb-sm"
|
|
autofocus :disable="inviteSaving" />
|
|
<q-input v-model="inviteForm.email" label="Email *" type="email" outlined dense class="q-mb-md"
|
|
placeholder="prenom@targointernet.com" :disable="inviteSaving" />
|
|
|
|
<div class="text-subtitle2 q-mb-xs">Groupes Authentik</div>
|
|
<div class="row q-gutter-sm q-mb-md">
|
|
<q-chip v-for="g in ['admin','sysadmin','tech','support','comptabilite','facturation','dev']"
|
|
:key="g" clickable
|
|
:color="inviteForm.groups.includes(g) ? 'primary' : 'grey-3'"
|
|
:text-color="inviteForm.groups.includes(g) ? 'white' : 'grey-7'"
|
|
:icon="inviteForm.groups.includes(g) ? 'check' : 'add'"
|
|
@click="inviteForm.groups.includes(g)
|
|
? inviteForm.groups.splice(inviteForm.groups.indexOf(g), 1)
|
|
: inviteForm.groups.push(g)"
|
|
size="sm">
|
|
{{ g }}
|
|
</q-chip>
|
|
</div>
|
|
|
|
<div class="text-subtitle2 q-mb-xs">Rôles ERPNext</div>
|
|
<q-input v-model="inviteForm.rolesText" outlined dense placeholder="Employee, Sales User, …"
|
|
hint="Séparés par des virgules"
|
|
@update:model-value="v => inviteForm.roles = v.split(',').map(r => r.trim()).filter(Boolean)" />
|
|
</q-card-section>
|
|
|
|
<!-- Success panel — shown once the user has been created.
|
|
Big takeaway: the recovery URL the admin must forward. -->
|
|
<q-card-section v-else class="q-pt-sm">
|
|
<div class="row items-center q-mb-md">
|
|
<q-icon name="check_circle" color="green-7" size="32px" class="q-mr-sm" />
|
|
<div>
|
|
<div class="text-weight-bold">{{ lastInviteResult.full_name }} ajouté</div>
|
|
<div class="text-caption text-grey-7">{{ lastInviteResult.email }}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Credentials panel: shows the temp password + delivery status. -->
|
|
<div v-if="lastInviteResult.temp_password" class="q-pa-sm q-mb-sm"
|
|
:style="lastInviteResult.email_sent
|
|
? 'background:#ecfdf5;border-left:3px solid #10b981;border-radius:6px'
|
|
: 'background:#fef3c7;border-left:3px solid #d97706;border-radius:6px'">
|
|
<div class="text-caption text-weight-bold q-mb-xs">
|
|
<span v-if="lastInviteResult.email_sent">✓ Courriel envoyé à {{ lastInviteResult.email }}</span>
|
|
<span v-else>⚠️ Courriel non envoyé — transmettez le mot de passe manuellement</span>
|
|
</div>
|
|
<div class="text-caption text-grey-8 q-mb-sm">
|
|
Mot de passe temporaire (à changer à la première connexion) :
|
|
</div>
|
|
<q-input :model-value="lastInviteResult.temp_password" readonly outlined dense class="q-mb-sm"
|
|
input-class="text-mono">
|
|
<template #append>
|
|
<q-btn flat dense round icon="content_copy" color="primary" @click="copyTempPassword">
|
|
<q-tooltip>Copier</q-tooltip>
|
|
</q-btn>
|
|
</template>
|
|
</q-input>
|
|
</div>
|
|
<div v-else class="text-caption text-negative q-mb-sm">
|
|
✗ Mot de passe non défini — vérifier les logs du hub
|
|
</div>
|
|
|
|
<div v-if="lastInviteResult.erpnext_ok" class="text-caption text-grey-7">
|
|
✓ Compte ERPNext System User créé
|
|
</div>
|
|
<div v-else class="text-caption text-negative">
|
|
✗ ERPNext: {{ lastInviteResult.erpnext_error }}
|
|
</div>
|
|
</q-card-section>
|
|
|
|
<q-card-actions align="right" class="q-pb-md q-pr-md">
|
|
<template v-if="!lastInviteResult">
|
|
<q-btn flat label="Annuler" v-close-popup :disable="inviteSaving" />
|
|
<q-btn unelevated color="primary" label="Créer l'utilisateur" icon="person_add"
|
|
:loading="inviteSaving" @click="submitInvite()" />
|
|
</template>
|
|
<template v-else>
|
|
<q-btn flat label="Inviter un autre" @click="lastInviteResult = null; openInviteDialog()" />
|
|
<q-btn unelevated color="primary" label="Terminé" v-close-popup />
|
|
</template>
|
|
</q-card-actions>
|
|
</q-card>
|
|
</q-dialog>
|
|
|
|
<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">
|
|
<UserAvatar :email="u.email" :name="u.name || u.username" :size="36" class="q-mr-sm" />
|
|
|
|
<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-1" text-color="red-9" class="q-mr-xs" />
|
|
<q-badge v-if="u.is_superuser" label="superuser" color="amber-1" 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="green-1" text-color="green-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">
|
|
<input ref="teamAvatarInput" type="file" accept="image/png,image/jpeg,image/webp,image/gif"
|
|
class="hidden" @change="onTeamAvatarFile">
|
|
<div class="row items-center q-mb-md">
|
|
<!-- Avatar éditable par un admin (upload pour ce membre via ?as=). -->
|
|
<div class="ua-edit q-mr-md" :class="{ 'ua-edit--can': can('manage_users') }"
|
|
@click="can('manage_users') && pickTeamAvatar(selectedUser)">
|
|
<UserAvatar :email="selectedUser.email" :name="selectedUser.name || selectedUser.username" :size="42" />
|
|
<div v-if="can('manage_users')" class="ua-edit__cam">
|
|
<q-icon name="photo_camera" size="12px" color="white" />
|
|
</div>
|
|
<q-tooltip v-if="can('manage_users')">Changer la photo</q-tooltip>
|
|
</div>
|
|
<div class="col">
|
|
<!-- Nom complet éditable (source Authentik, affichée partout). -->
|
|
<div v-if="!editingName" class="row items-center no-wrap">
|
|
<div class="text-h6">{{ selectedUser.name || selectedUser.username }}</div>
|
|
<q-btn v-if="can('manage_users')" flat round dense size="sm" icon="edit"
|
|
color="grey-6" class="q-ml-xs" @click="startEditName(selectedUser)">
|
|
<q-tooltip>Modifier le nom complet</q-tooltip>
|
|
</q-btn>
|
|
</div>
|
|
<div v-else class="row items-center no-wrap q-gutter-xs" style="max-width:420px">
|
|
<q-input v-model="nameDraft" dense outlined autofocus class="col"
|
|
:disable="savingName" @keyup.enter="commitEditName(selectedUser)"
|
|
@keyup.esc="editingName = false" />
|
|
<q-btn round dense unelevated color="primary" icon="check" :loading="savingName"
|
|
@click="commitEditName(selectedUser)" />
|
|
<q-btn round dense flat icon="close" :disable="savingName" @click="editingName = false" />
|
|
</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; editingName = false" />
|
|
</div>
|
|
|
|
<!-- Profil employé lié (ERPNext) — lien session ↔ Employee -->
|
|
<div class="q-mb-md emp-card">
|
|
<div class="row items-center q-mb-xs">
|
|
<div class="text-subtitle2"><q-icon name="badge" size="18px" class="q-mr-xs" />Profil employé</div>
|
|
<q-space />
|
|
<q-spinner v-if="empResolving" size="16px" color="primary" />
|
|
</div>
|
|
<template v-if="!empResolving">
|
|
<div v-if="selectedEmployee" class="row items-center no-wrap">
|
|
<div class="col">
|
|
<div class="text-body2 text-weight-medium">{{ selectedEmployee.employee_name }}</div>
|
|
<div class="text-caption text-grey-6">
|
|
{{ [selectedEmployee.designation, selectedEmployee.department].filter(Boolean).join(' · ') || '—' }}
|
|
<span v-if="selectedEmployee.cell_number"> · {{ selectedEmployee.cell_number }}</span>
|
|
</div>
|
|
</div>
|
|
<q-btn dense flat no-caps color="primary" icon="edit" label="Modifier"
|
|
@click="openEmpEdit(selectedEmployee.name)" />
|
|
</div>
|
|
<div v-else class="row items-center no-wrap">
|
|
<div class="col text-caption text-grey-6"><q-icon name="link_off" size="14px" /> Aucun profil employé lié à ce compte.</div>
|
|
<q-btn dense unelevated no-caps color="primary" icon="link" label="Lier un employé"
|
|
@click="openEmpLink(selectedUser.email)" />
|
|
</div>
|
|
</template>
|
|
</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) ? 'primary' : '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="warning"
|
|
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>
|
|
|
|
<EmployeeEditDialog v-model="empDlgOpen" :employee-name="empDlgName" :link-email="empDlgLinkEmail"
|
|
@saved="onEmployeeSaved" />
|
|
</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="warning" class="q-mr-sm" />
|
|
<span class="text-body2 text-warning">Importer les groupes depuis le systeme legacy</span>
|
|
<q-space />
|
|
<q-btn dense unelevated color="warning" text-color="white" icon="preview" label="Apercu"
|
|
:loading="legacySyncing" @click="syncLegacy(true)" class="q-mr-xs" size="sm" />
|
|
<q-btn dense unelevated color="primary" 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="green-1" text-color="green-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="amber-1" text-color="amber-9" 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-9" 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="green-1" text-color="green-8" />
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="syncResult.not_found.length" class="q-mb-md">
|
|
<div class="text-subtitle2 q-mb-xs text-warning">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="warning" 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="primary" 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="primary" /></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="primary" class="q-mr-sm" />
|
|
<span class="text-weight-bold">{{ g.name }}</span>
|
|
<q-space />
|
|
<q-badge :label="g.num_users + ' membres'" color="green-1" text-color="green-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="primary" 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 q-px-sm member-row">
|
|
<UserAvatar :email="m.email" :name="m.name || m.username" :size="28" class="q-mr-sm" />
|
|
<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="negative" class="member-row-del"
|
|
@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="primary" />
|
|
<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)">
|
|
<UserAvatar :email="u.email" :name="u.name || u.username" :size="24" class="q-mr-sm" />
|
|
<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="primary"
|
|
@update:model-value="permDirty = true" />
|
|
<span class="text-caption">{{ cap.label }}</span>
|
|
</div>
|
|
</template>
|
|
</PermGrid>
|
|
<q-btn v-if="permDirty" color="primary" 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="primary" 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="primary" /></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="primary"
|
|
@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="primary" 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 v-if="can('manage_settings')" :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="primary" 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 (console admin/infra — ERPNext desk, n8n, Authentik) : admins seulement (manage_settings) -->
|
|
<div v-if="can('manage_settings')" 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>
|
|
|
|
<!-- Modèles IA — routage par tâche + repli Gemini -->
|
|
<AiRoutingCard v-if="can('manage_settings')" class="q-mt-md" />
|
|
|
|
<!-- Délais SLA — réponse / résolution par file + priorité -->
|
|
<SlaSettings v-if="can('manage_settings')" class="q-mt-md" />
|
|
|
|
<!-- Départements omnichannel — abonnement PAR MEMBRE (une inscription = courriels + tickets + canaux). Vue primaire. -->
|
|
<DepartmentSubscriptions v-if="can('manage_settings')" class="q-mt-md" />
|
|
|
|
<!-- Files (Inbox) & équipes — vue PAR FILE (roster). Même store que les départements ci-dessus. -->
|
|
<QueueTeamsCard class="q-mt-md" />
|
|
|
|
<!-- Réponses pré-écrites (canned) — réutilisables dans l'Inbox -->
|
|
<CannedResponsesCard class="q-mt-md" />
|
|
</q-page>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, reactive, onMounted, watch, defineAsyncComponent, h, resolveComponent } from 'vue'
|
|
import { useRoute } from 'vue-router'
|
|
import { formatDateTimeShort as formatDate } from 'src/composables/useFormatters' // date+heure canonique (source unique)
|
|
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 { useAvatar } from 'src/composables/useAvatar'
|
|
import UserAvatar from 'src/components/shared/UserAvatar.vue'
|
|
import EmployeeEditDialog from 'src/components/shared/EmployeeEditDialog.vue'
|
|
import IdentityManager from 'src/components/shared/IdentityManager.vue' // gestion des identités unifiées (label + alias courriel + lien tech + fusion)
|
|
import StaffConsole from 'src/components/shared/StaffConsole.vue' // réconciliation Authentik ↔ staff (provisionner/désactiver/supprimer gardé)
|
|
import { listDocs } from 'src/api/erp'
|
|
import QueueTeamsCard from 'src/components/settings/QueueTeamsCard.vue'
|
|
import DepartmentSubscriptions from 'src/components/settings/DepartmentSubscriptions.vue'
|
|
import CannedResponsesCard from 'src/components/settings/CannedResponsesCard.vue'
|
|
import AiRoutingCard from 'src/components/settings/AiRoutingCard.vue'
|
|
import SlaSettings from 'src/components/settings/SlaSettings.vue'
|
|
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: 'primary', 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, savingName,
|
|
selectedGroup, groupMembers, groupMembersLoading,
|
|
addMemberSearch, memberSearchResults, memberSearchLoading,
|
|
debouncedSearchUsers, searchUsers, selectUser, toggleUserGroup, saveUserName,
|
|
selectGroup, loadGroupMembers, removeFromGroup,
|
|
debouncedMemberSearch, addUserToCurrentGroup,
|
|
inviteOpen, inviteSaving, inviteForm, lastInviteResult, openInviteDialog, submitInvite,
|
|
} = useUserGroups({ permGroups })
|
|
|
|
// ── Édition du nom complet (dans le panneau détail utilisateur) ──────────────
|
|
const editingName = ref(false)
|
|
const nameDraft = ref('')
|
|
function startEditName (user) {
|
|
nameDraft.value = user.name || ''
|
|
editingName.value = true
|
|
}
|
|
async function commitEditName (user) {
|
|
const ok = await saveUserName(user, nameDraft.value)
|
|
if (ok) editingName.value = false
|
|
}
|
|
|
|
// ── Profil employé lié au compte sélectionné (session ↔ Employee) ────────────
|
|
const selectedEmployee = ref(null)
|
|
const empResolving = ref(false)
|
|
const empDlgOpen = ref(false)
|
|
const empDlgName = ref('')
|
|
const empDlgLinkEmail = ref('')
|
|
|
|
async function resolveSelectedEmployee () {
|
|
selectedEmployee.value = null
|
|
const email = selectedUser.value?.email
|
|
if (!email) return
|
|
empResolving.value = true
|
|
try {
|
|
const fields = ['name', 'employee_name', 'designation', 'department', 'cell_number', 'office_extension', 'company_email', 'user_id']
|
|
let rows = await listDocs('Employee', { filters: { user_id: email }, fields, limit: 1 })
|
|
if (!rows.length) rows = await listDocs('Employee', { filters: { company_email: email }, fields, limit: 1 })
|
|
selectedEmployee.value = rows[0] || null
|
|
} catch { selectedEmployee.value = null } finally { empResolving.value = false }
|
|
}
|
|
watch(selectedUser, resolveSelectedEmployee)
|
|
|
|
function openEmpEdit (name) { empDlgLinkEmail.value = ''; empDlgName.value = name; empDlgOpen.value = true }
|
|
function openEmpLink (email) { empDlgName.value = ''; empDlgLinkEmail.value = email; empDlgOpen.value = true }
|
|
function onEmployeeSaved () { resolveSelectedEmployee() }
|
|
|
|
// ── Ouverture directe d'un utilisateur via ?user=<email> (bouton « Éditer »
|
|
// depuis la page Équipe). On force l'onglet Utilisateurs, on recherche par
|
|
// courriel puis on sélectionne la correspondance exacte.
|
|
const route = useRoute()
|
|
async function openUserByEmail (email) {
|
|
const target = String(email || '').trim().toLowerCase()
|
|
if (!target) return
|
|
permTab.value = 'users'
|
|
userSearch.value = target
|
|
await searchUsers()
|
|
const match = userResults.value.find(u => (u.email || '').toLowerCase() === target)
|
|
if (match) selectUser(match)
|
|
else Notify.create({ type: 'warning', message: `Aucun compte trouvé pour ${target}`, timeout: 3000 })
|
|
}
|
|
|
|
async function copyTempPassword () {
|
|
if (!lastInviteResult.value?.temp_password) return
|
|
try {
|
|
await navigator.clipboard.writeText(lastInviteResult.value.temp_password)
|
|
Notify.create({ type: 'positive', message: 'Mot de passe copié', timeout: 1500 })
|
|
} catch (e) {
|
|
Notify.create({ type: 'negative', message: 'Impossible de copier: ' + e.message })
|
|
}
|
|
}
|
|
|
|
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 })
|
|
}
|
|
|
|
// Avatar d'équipe : un admin peut définir la photo d'un membre (upload via ?as=).
|
|
const { uploadAvatar, fileToAvatarDataUrl } = useAvatar()
|
|
const teamAvatarInput = ref(null)
|
|
let teamAvatarTarget = null
|
|
function pickTeamAvatar (user) {
|
|
teamAvatarTarget = user
|
|
teamAvatarInput.value?.click()
|
|
}
|
|
async function onTeamAvatarFile (e) {
|
|
const file = e.target.files && e.target.files[0]
|
|
e.target.value = ''
|
|
if (!file || !teamAvatarTarget) return
|
|
try {
|
|
const dataUrl = await fileToAvatarDataUrl(file)
|
|
await uploadAvatar(dataUrl, { as: teamAvatarTarget.email })
|
|
} catch (err) {
|
|
Notify.create({ type: 'negative', message: 'Image illisible: ' + err.message })
|
|
}
|
|
}
|
|
|
|
// formatDate → useFormatters.formatDateTimeShort (date+heure canonique)
|
|
|
|
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()
|
|
if (route.query.user) openUserByEmail(String(route.query.user))
|
|
else searchUsers()
|
|
}
|
|
})
|
|
|
|
watch(isLoaded, (loaded) => {
|
|
if (loaded && can('manage_permissions') && !permGroups.value.length) {
|
|
loadPerms()
|
|
if (route.query.user) openUserByEmail(String(route.query.user))
|
|
else 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; }
|
|
.ua-edit { position: relative; display: inline-flex; }
|
|
.ua-edit--can { cursor: pointer; }
|
|
.ua-edit__cam { position: absolute; right: -2px; bottom: -2px; width: 18px; height: 18px; border-radius: 50%;
|
|
background: var(--ops-primary, #16a34a); display: flex; align-items: center; justify-content: center;
|
|
border: 2px solid #fff; }
|
|
|
|
.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; }
|
|
/* Surlignage de la ligne membre au survol → on vise sans erreur le bon bouton « Retirer » dans une longue liste */
|
|
.member-row { border-bottom: 1px solid #f1f5f9; border-radius: 6px; transition: background .1s; }
|
|
.member-row:hover { background: #eff3ff; box-shadow: inset 3px 0 0 #6366f1; }
|
|
.member-row .member-row-del { opacity: .45; transition: opacity .1s; }
|
|
.member-row:hover .member-row-del { opacity: 1; }
|
|
</style>
|