Reestruturação da tela de usuários

This commit is contained in:
Gabriel 2026-08-20 15:50:13 -03:00
parent 4f5c5a17f1
commit 131cf6ea85
20 changed files with 1522 additions and 350 deletions

View File

@ -91,7 +91,7 @@ Um único app, `portal_api/`:
| `permissions.py` | `PodeGerenciarPermissoes` — gate único de `gerencia_permissoes()` para as telas administrativas; `PermissaoApp(module_key, app_key)` — classe genérica reutilizável que checa `Usuario.permissao_app()`, instanciada por view (ex.: Links & Ferramentas e Ramais, ver seção própria abaixo). | | `permissions.py` | `PodeGerenciarPermissoes` — gate único de `gerencia_permissoes()` para as telas administrativas; `PermissaoApp(module_key, app_key)` — classe genérica reutilizável que checa `Usuario.permissao_app()`, instanciada por view (ex.: Links & Ferramentas e Ramais, ver seção própria abaixo). |
| `views.py` | `login_view`/`logout_view`/`csrf_view` (auth por sessão), `me_view` (usuário + `permissoes_efetivas` já unidas no servidor + `lideranca`/`liderados`), `trocar_senha_view`, `usuarios_resumo_view`, `meus_liderados_view` (ver seção "Liderança"), `departamentos_resumo_view` (ver "Ramais"), `catalogo_view`, e os `ModelViewSet` de perfis/departamentos/usuários/compromissos/favoritos/widgets/notificações dispensadas/links e ferramentas/favoritos de links e ferramentas/seções e linhas de Acessos Gerais/ramais/ausências de ramal/importações de plano de saúde e suas linhas. | | `views.py` | `login_view`/`logout_view`/`csrf_view` (auth por sessão), `me_view` (usuário + `permissoes_efetivas` já unidas no servidor + `lideranca`/`liderados`), `trocar_senha_view`, `usuarios_resumo_view`, `meus_liderados_view` (ver seção "Liderança"), `departamentos_resumo_view` (ver "Ramais"), `catalogo_view`, e os `ModelViewSet` de perfis/departamentos/usuários/compromissos/favoritos/widgets/notificações dispensadas/links e ferramentas/favoritos de links e ferramentas/seções e linhas de Acessos Gerais/ramais/ausências de ramal/importações de plano de saúde e suas linhas. |
| `admin.py` | Django admin básico para todos os models (uso interno, não é a UI do portal). | | `admin.py` | Django admin básico para todos os models (uso interno, não é a UI do portal). |
| `management/commands/seed_portal.py` | Recria os 8 perfis padrão + `gabriel`/`bruno` + as 13 linhas de `FuncaoTelefonia` + o seed de `CategoriaEvento`. | | `management/commands/seed_portal.py` | Recria os 8 perfis padrão + `gabriel`/`bruno` + as 13 linhas de `FuncaoTelefonia` + o seed de `CategoriaEvento`; também realinha a sequence do Postgres por trás de `PerfilAcesso.codigo` (ver nota abaixo). |
| `management/commands/seed_indicador_desempenho.py` | Popula o primeiro histórico do Indicador de Desempenho (7 `IndicadorCriterio` + 5 `IndicadorPercentualTipo`, idempotente) com os valores da planilha antiga — ver seção "Indicador de Desempenho" abaixo. | | `management/commands/seed_indicador_desempenho.py` | Popula o primeiro histórico do Indicador de Desempenho (7 `IndicadorCriterio` + 5 `IndicadorPercentualTipo`, idempotente) com os valores da planilha antiga — ver seção "Indicador de Desempenho" abaixo. |
| `planos_saude/` | Pacote Python puro (sem ORM) com o pipeline de extração/casamento de "Importação de Plano de Saúde", portado de `projects/project/` — ver seção própria abaixo. | | `planos_saude/` | Pacote Python puro (sem ORM) com o pipeline de extração/casamento de "Importação de Plano de Saúde", portado de `projects/project/` — ver seção própria abaixo. |
| `custo_contratacao/` | Pacote Python puro (sem ORM) da ferramenta "Simulação de Custo de Contratação" (Geradoc) — `tabelas.py` (seed/default das faixas de INSS/IRRF, hoje editáveis via `ParametroFiscalCustoContratacao`), `calculo.py` (`ParametrosFiscais` dataclass + `calcula_custo_empregado`), `pdf.py` (`gera_pdf_simulacao`, via `reportlab`). Ver seção própria abaixo. | | `custo_contratacao/` | Pacote Python puro (sem ORM) da ferramenta "Simulação de Custo de Contratação" (Geradoc) — `tabelas.py` (seed/default das faixas de INSS/IRRF, hoje editáveis via `ParametroFiscalCustoContratacao`), `calculo.py` (`ParametrosFiscais` dataclass + `calcula_custo_empregado`), `pdf.py` (`gera_pdf_simulacao`, via `reportlab`). Ver seção própria abaixo. |
@ -186,7 +186,7 @@ O item "Calendário De Paula" no menu **não é uma página local** — é um `<
## Ordem de `<script>` e por que ela não quebra nada ## Ordem de `<script>` e por que ela não quebra nada
Todas as páginas-shell (todas exceto `index.html`) carregam o mesmo prefixo de scripts, na mesma ordem: `api.js → profiles.js → auth.js → access.js → account.js → favorites.js → events.js → ...`, seguido de scripts específicos da página. Nenhum script usa `defer` exceto `theme.js` (que fica no `<head>`). Todas as páginas-shell (todas exceto `index.html`) carregam o mesmo prefixo de scripts, na mesma ordem: `api.js → dual-select.js → profiles.js → auth.js → access.js → account.js → favorites.js → events.js → ...`, seguido de scripts específicos da página. Nenhum script usa `defer` exceto `theme.js` (que fica no `<head>`).
Isso importa porque, por exemplo, `access.js` chama `pidRequireAuth()` de `auth.js`, que só aparece **antes** dele na tag `<script>` — mas mesmo quando a ordem fosse invertida funcionaria, porque toda chamada cross-arquivo acontece dentro de um callback de `document.addEventListener("DOMContentLoaded", ...)`, nunca no nível superior do script. Como todo `<script>` sem `defer` executa (e portanto declara suas funções) antes do evento `DOMContentLoaded` disparar, a ordem relativa entre arquivos não importa — só importa que todos estejam presentes na página antes desse evento. Ao adicionar um novo arquivo JS compartilhado, não é preciso se preocupar em "colocá-lo antes de quem o usa", desde que toda chamada fique dentro de um handler de `DOMContentLoaded` (ou de uma função só invocada por um). Isso importa porque, por exemplo, `access.js` chama `pidRequireAuth()` de `auth.js`, que só aparece **antes** dele na tag `<script>` — mas mesmo quando a ordem fosse invertida funcionaria, porque toda chamada cross-arquivo acontece dentro de um callback de `document.addEventListener("DOMContentLoaded", ...)`, nunca no nível superior do script. Como todo `<script>` sem `defer` executa (e portanto declara suas funções) antes do evento `DOMContentLoaded` disparar, a ordem relativa entre arquivos não importa — só importa que todos estejam presentes na página antes desse evento. Ao adicionar um novo arquivo JS compartilhado, não é preciso se preocupar em "colocá-lo antes de quem o usa", desde que toda chamada fique dentro de um handler de `DOMContentLoaded` (ou de uma função só invocada por um).
@ -250,6 +250,8 @@ Isso reaproveita 100% a árvore de permissões que já existe (`renderTree()`/`r
**Cuidado com `seed_portal.py`**: o módulo continua tendo seu próprio `enabled` (visibilidade no menu) e, se estiver em `BASE_KEYS`/`SECTORAL_KEYS` etc., `catalogo.permissions_from_keys()` habilita **todos** os apps de um módulo enabled de uma vez — incluindo todo `*-editar`. Por isso `seed_portal.py` força `permissoes["links-ferramentas"]["apps"]["links-ferramentas-editar"] = False`, `permissoes["links-ferramentas"]["apps"]["acessos-gerais-editar"] = False` e `permissoes["ramais"]["apps"]["ramais-editar"] = False` (+ as outras duas subtelas administráveis de Ramais) explicitamente pra todo perfil que não seja "Integração e Inovação" (código 8, `chaves=None` → todos os apps `True` de propósito), depois de gerar o dict — sem esse override, qualquer perfil com o módulo habilitado nasceria com poder de editar tudo. Ao adicionar uma aplicação nova nesse padrão (dentro de um módulo existente ou não), replicar esse mesmo cuidado no seed. **Cuidado com `seed_portal.py`**: o módulo continua tendo seu próprio `enabled` (visibilidade no menu) e, se estiver em `BASE_KEYS`/`SECTORAL_KEYS` etc., `catalogo.permissions_from_keys()` habilita **todos** os apps de um módulo enabled de uma vez — incluindo todo `*-editar`. Por isso `seed_portal.py` força `permissoes["links-ferramentas"]["apps"]["links-ferramentas-editar"] = False`, `permissoes["links-ferramentas"]["apps"]["acessos-gerais-editar"] = False` e `permissoes["ramais"]["apps"]["ramais-editar"] = False` (+ as outras duas subtelas administráveis de Ramais) explicitamente pra todo perfil que não seja "Integração e Inovação" (código 8, `chaves=None` → todos os apps `True` de propósito), depois de gerar o dict — sem esse override, qualquer perfil com o módulo habilitado nasceria com poder de editar tudo. Ao adicionar uma aplicação nova nesse padrão (dentro de um módulo existente ou não), replicar esse mesmo cuidado no seed.
**Bug real (rodada 69) — `IntegrityError: duplicate key value violates unique constraint "portal_api_perfilacesso_pkey"` ao criar um perfil pela tela**: `seed_portal.py` semeia `PerfilAcesso` com `codigo` **explícito** (`update_or_create(codigo=dado["codigo"], ...)`, já que os 8 códigos 1–8 são referenciados por número fixo em vários lugares do código — ex.: `codigo == 8` = "Integração e Inovação"). No Postgres, um `INSERT` com PK explícita **nunca avança a sequence** por trás do `AutoField` — então a sequence ficava parada em 1 (seu valor inicial), e o primeiro perfil criado pela tela (`POST /api/perfis/`, sem PK explícita) recebia `codigo=1` do `nextval()`, colidindo com um código já usado pelo seed. `_reset_sequence(model)` (função módulo-level em `seed_portal.py`, roda `SELECT setval(pg_get_serial_sequence(...), MAX(pk))` via SQL puro do Postgres) corrige isso, chamada logo depois do loop de `PERFIS_SEED` — toda vez que `seed_portal` roda, a sequence é realinhada de novo. Se esse erro voltar a aparecer no futuro (ex.: um `loaddata`/`RunPython` de migração também inserindo `PerfilAcesso` com PK explícita sem passar por `seed_portal.py` depois), o comando pra corrigir manualmente é `python manage.py seed_portal` (idempotente, seguro rodar de novo) — não precisa de acesso direto ao banco.
### Popup "Nova Aplicação" (gerenciar acesso por aplicação, entre perfis) ### Popup "Nova Aplicação" (gerenciar acesso por aplicação, entre perfis)
Botão `#pa-app-search-btn` ao lado de "Novo Perfil" (`perfis-acesso.html`) abre `#pa-app-search-modal` — o caminho inverso da árvore de permissões: em vez de abrir um perfil e marcar módulo por módulo, o usuário busca uma aplicação/ferramenta pelo nome e vê/gerencia **todos os perfis** que têm acesso a ela numa tabela só. Botão `#pa-app-search-btn` ao lado de "Novo Perfil" (`perfis-acesso.html`) abre `#pa-app-search-modal` — o caminho inverso da árvore de permissões: em vez de abrir um perfil e marcar módulo por módulo, o usuário busca uma aplicação/ferramenta pelo nome e vê/gerencia **todos os perfis** que têm acesso a ela numa tabela só.
@ -260,6 +262,16 @@ Botão `#pa-app-search-btn` ao lado de "Novo Perfil" (`perfis-acesso.html`) abre
- **Conceder acesso habilita o módulo automaticamente** (decisão explícita do usuário): se o checkbox marcado pertence a um módulo com `enabled=false` naquele perfil, o toggle também vira `perm.enabled = true` no mesmo PATCH — sem isso, o perfil ganharia a chave em `apps` mas o item continuaria escondido no menu (`access.js` esconde o `nav-group`/`nav-subitem` inteiro por `enabled`, não só por app). **Revogar não desabilita o módulo de volta** (outras aplicações dele podem seguir em uso por aquele perfil). - **Conceder acesso habilita o módulo automaticamente** (decisão explícita do usuário): se o checkbox marcado pertence a um módulo com `enabled=false` naquele perfil, o toggle também vira `perm.enabled = true` no mesmo PATCH — sem isso, o perfil ganharia a chave em `apps` mas o item continuaria escondido no menu (`access.js` esconde o `nav-group`/`nav-subitem` inteiro por `enabled`, não só por app). **Revogar não desabilita o módulo de volta** (outras aplicações dele podem seguir em uso por aquele perfil).
- Perfis inativos (`ativo=False`) aparecem na tabela com o selo `.status-pill--inativo` (mesmo componente da coluna "Status" de `usuarios.html`), sem serem excluídos da lista — nada nesse popup impede gerenciar o acesso deles. - Perfis inativos (`ativo=False`) aparecem na tabela com o selo `.status-pill--inativo` (mesmo componente da coluna "Status" de `usuarios.html`), sem serem excluídos da lista — nada nesse popup impede gerenciar o acesso deles.
### Aba "Usuários do Escritório" (dentro da edição de um perfil) — duas tabelas com seleção múltipla
Substituiu o antigo `<select>` + botão "Vincular" + lista simples com X pra remover — pedido explícito do usuário pra reestruturar visualmente no estilo de um componente de transferência dupla (referência: uma tela de outro sistema com duas grades lado a lado, cada uma com checkbox de seleção, busca por coluna, ordenação e um botão de ação em lote).
- **Duas tabelas** (`.pa-users-dual`, grid 2 colunas que colapsa pra 1 abaixo de 900px): à esquerda, `#pa-users-available-*` — todo usuário **ativo** ainda não vinculado a este perfil; à direita, `#pa-users-linked-*` — todo usuário **ativo** já vinculado. Usuário inativo nunca aparece em nenhum dos dois painéis (`listaParaPainelUsuarios()` filtra `usuariosCacheAtual` por `is_active` antes de separar entre vinculado/disponível) — decisão explícita do usuário; na prática, inativar já limpa os `perfis` de alguém no backend (`_revogar_acesso_se_inativo()`, ver "Inativar/reativar usuário" abaixo), então esse filtro no frontend é sobretudo defensivo pra dados legados. Cada tabela tem: checkbox de seleção por linha + "selecionar todos" no cabeçalho (`#pa-users-available-select-all`/`#pa-users-linked-select-all`, aplica só sobre as linhas **filtradas** visíveis, mesmo critério de `.checklist-select-all`), coluna "Nome Usuário" ordenável (clique alterna asc/desc, ícone `↕` que fica `--accent` quando ativo — mesmo padrão `.ua-sort-icon` já usado em `usuarios.html`) e coluna "E-mail" (não ordenável), com uma segunda linha de cabeçalho (`.pa-users-table__filters`) só com os campos de busca por nome/e-mail — filtro client-side sobre o array já carregado, sem debounce.
- **Botão de atualizar** (ícone circular, `#pa-users-available-refresh-btn`/`#pa-users-linked-refresh-btn`) refaz `GET /api/usuarios/` (`refreshUsuarios()`, `profiles.js`) e re-renderiza os dois painéis a partir do mesmo cache — as duas tabelas sempre refletem o mesmo snapshot de usuários, nunca buscam independentemente uma da outra.
- **Vincular/Desvincular em lote**: o botão de cada painel (`#pa-users-link-btn`/`#pa-users-unlink-btn`, desabilitado enquanto a seleção daquele painel estiver vazia) dispara `bulkAlterarVinculo(kind, vincular)` — um `PATCH /api/usuarios/{id}/` (`pidSetUsuarioPerfis`) por usuário selecionado, em paralelo (`Promise.all`), cada um recalculando a própria lista de `perfis` (adiciona ou remove só o `codigo` do perfil sendo editado, preservando os demais perfis do usuário). Ao terminar, a seleção é limpa e os dois painéis são recarregados do zero (`refreshUsuarios()`) — um usuário que acabou de ser vinculado desaparece da tabela da esquerda e aparece na da direita, e vice-versa.
- Abrir a aba de um perfil diferente (`renderUsers()`, chamada por `openEdit()`) sempre reseta os dois painéis: seleção limpa, ordenação de volta pra ascendente, campos de busca vazios — evita carregar o estado de filtro/seleção deixado num perfil anterior.
- Sem endpoint novo — 100% reaproveitamento de `GET /api/usuarios/` (`UsuarioListSerializer`, já expõe `email`) e `PATCH /api/usuarios/{id}/` (`pidSetUsuarioPerfis`, já existia).
## Inativar/reativar usuário (usuarios.html) ## Inativar/reativar usuário (usuarios.html)
Usa o campo `is_active` que já vem de `AbstractUser` — não foi criado nenhum campo/migração novo, só exposto em `UsuarioSerializer`/`UsuarioListSerializer` e ligado na UI. `is_active=False` já é suficiente pro Django bloquear o acesso sozinho, sem nenhum código extra de autenticação: Usa o campo `is_active` que já vem de `AbstractUser` — não foi criado nenhum campo/migração novo, só exposto em `UsuarioSerializer`/`UsuarioListSerializer` e ligado na UI. `is_active=False` já é suficiente pro Django bloquear o acesso sozinho, sem nenhum código extra de autenticação:
@ -267,22 +279,30 @@ Usa o campo `is_active` que já vem de `AbstractUser` — não foi criado nenhum
- **Login novo**: `authenticate()` (usado em `login_view`) roda via `django.contrib.auth.backends.ModelBackend`, que internamente chama `user_can_authenticate()` e recusa (`None`) qualquer usuário com `is_active=False`, mesmo com a senha certa. Como isso faz `authenticate()` retornar `None` tanto pra senha errada quanto pra usuário inativo, `login_view` faz uma checagem manual **só no caminho de falha** (`Usuario.objects.filter(username=username, is_active=False).first()` + `check_password()`) pra devolver uma mensagem diferente ("Este usuário está inativo...", 403) só quando a senha bate mas a conta está inativa — sem essa checagem extra, qualquer tentativa com credenciais erradas ou inexistentes continua caindo no genérico "Login ou senha inválidos." (401), pra não revelar se um username existe. - **Login novo**: `authenticate()` (usado em `login_view`) roda via `django.contrib.auth.backends.ModelBackend`, que internamente chama `user_can_authenticate()` e recusa (`None`) qualquer usuário com `is_active=False`, mesmo com a senha certa. Como isso faz `authenticate()` retornar `None` tanto pra senha errada quanto pra usuário inativo, `login_view` faz uma checagem manual **só no caminho de falha** (`Usuario.objects.filter(username=username, is_active=False).first()` + `check_password()`) pra devolver uma mensagem diferente ("Este usuário está inativo...", 403) só quando a senha bate mas a conta está inativa — sem essa checagem extra, qualquer tentativa com credenciais erradas ou inexistentes continua caindo no genérico "Login ou senha inválidos." (401), pra não revelar se um username existe.
- **Sessão já aberta**: também não precisa de nenhum middleware/permissão customizado — `ModelBackend.get_user(user_id)` (chamado pelo Django a cada request pra popular `request.user` a partir da sessão) também recusa usuários inativos, então na próxima requisição depois de desativado o usuário vira `AnonymousUser` automaticamente e `IsAuthenticated`/`PodeGerenciarPermissoes` já barram sozinhos. Ou seja: desativar alguém já derruba o acesso na mesma hora, não só impede o próximo login. - **Sessão já aberta**: também não precisa de nenhum middleware/permissão customizado — `ModelBackend.get_user(user_id)` (chamado pelo Django a cada request pra popular `request.user` a partir da sessão) também recusa usuários inativos, então na próxima requisição depois de desativado o usuário vira `AnonymousUser` automaticamente e `IsAuthenticated`/`PodeGerenciarPermissoes` já barram sozinhos. Ou seja: desativar alguém já derruba o acesso na mesma hora, não só impede o próximo login.
**Inativar desvincula perfis de acesso e liderança automaticamente** (`_revogar_acesso_se_inativo()`, `serializers.py`, decisão explícita do usuário): sempre que `UsuarioSerializer.create()`/`update()` termina com `usuario.is_active=False`, `perfis` é limpo (`usuario.perfis.clear()`) e o usuário sai do `liderados` de qualquer gerente que o tivesse (`usuario.lideres.clear()` — `lideres` é a relação **reversa** de `Usuario.liderados`; limpar aqui remove `usuario` do lado de quem o lidera, sem afetar quem `usuario` eventualmente lidera, caso ele mesmo seja gerente). A chamada é sempre a **última** coisa em `create()`/`update()`, depois dos `.set()` de `perfis`/`departamentos`/`liderados` — colocar antes seria inútil, já que o formulário de edição de `usuarios.html` sempre reenvia o checklist de perfis inteiro junto com qualquer mudança no checkbox "Usuário ativo", e um `.set()` posterior desfaria uma limpeza feita cedo demais. Cobre os dois pontos de entrada reais (botão de alternar na lista + checkbox no formulário de edição), ambos passando por `PATCH /api/usuarios/{id}/`; não há um hook equivalente no `admin.py` (uso interno, fora de escopo). **Não é uma trava**: nada impede reativar alguém depois (ele volta sem nenhum perfil/liderança, precisa reconfigurar) nem impede — por ora — que um gerente adicione manualmente um usuário já inativo aos próprios `liderados` pela tela dele (o hook só dispara ao salvar o usuário inativo em si, não ao salvar o gerente).
Na UI (`users-admin.js`/`usuarios.html`): coluna "Status" na lista (`.status-pill`/`.status-pill--ativo`/`.status-pill--inativo`, mesmo componente que já existia pro `PerfilAcesso.ativo`) e um botão de alternar (ícone de "power") na linha, ao lado de editar/excluir — `PATCH /api/usuarios/{id}/` com `{is_active: !atual}`, com confirmação via `window.confirm`. O checkbox "Usuário ativo" no formulário de edição faz a mesma coisa (útil quando já se está editando outros campos). Os dois lugares bloqueiam **auto-desativação** (mesmo padrão de guarda já usado pra "não pode excluir a si mesmo": checagem só no frontend, `id === me.id`) — no formulário isso aparece como o checkbox desabilitado (`disabled`) quando `account.id === me.id`, em vez de um alerta. Na UI (`users-admin.js`/`usuarios.html`): coluna "Status" na lista (`.status-pill`/`.status-pill--ativo`/`.status-pill--inativo`, mesmo componente que já existia pro `PerfilAcesso.ativo`) e um botão de alternar (ícone de "power") na linha, ao lado de editar/excluir — `PATCH /api/usuarios/{id}/` com `{is_active: !atual}`, com confirmação via `window.confirm`. O checkbox "Usuário ativo" no formulário de edição faz a mesma coisa (útil quando já se está editando outros campos). Os dois lugares bloqueiam **auto-desativação** (mesmo padrão de guarda já usado pra "não pode excluir a si mesmo": checagem só no frontend, `id === me.id`) — no formulário isso aparece como o checkbox desabilitado (`disabled`) quando `account.id === me.id`, em vez de um alerta.
**Filtro de status na lista** (`#ua-status-filtros`, chips "Ativos"/"Inativos"/"Todos" ao lado do título "Usuários" — mesma linguagem visual de `.ind-departamento-chip`): client-side, sobre o array `users` já carregado (`statusFiltro` em `users-admin.js`, aplicado em `renderList()` antes do filtro de busca por texto). Nasce em `"ativos"` por padrão (decisão explícita do usuário — a lista não deve abrir mostrando quem já foi desativado) e reseta pra `"ativos"` só no load da página, não a cada `renderList()`.
**Colunas de código cadastral + ordenação** (`#ua-table`): a lista também mostra `codigo_folha`/`codigo_questor`/`codigo_tareffa`/`codigo_contabit`/`ramal` como colunas próprias (antes só apareciam dentro do formulário de edição) — pedido explícito do usuário pra conseguir achar quem está sem algum desses códigos cadastrado antes de outras aplicações passarem a depender deles, mesmo eles sendo campos opcionais (`blank=True`) no model. Célula vazia renderiza `<span class="ua-campo-vazio">—</span>` (itálico, cor apagada) em vez de string vazia, pra ficar visualmente óbvio ao ordenar a coluna. Todo `<th data-sort="...">` (login, nome, os 4 códigos, ramal, status) é clicável e alterna asc/desc (`sortKey`/`sortDir` em `users-admin.js`, ícone `↕` que fica `--accent` quando ativo) — mesmo padrão de `#ips-list-table` (`importacao-plano-saude.js`) e do modal de Ramais (`ramais-lookup.js`), inclusive a mesma função de comparação (`comparaValoresUsuario`, número vs. número quando os dois convertem, senão `localeCompare` pt-BR) — cada arquivo mantém sua própria cópia da função, não foi extraída pra um utilitário compartilhado em `api.js`. "Perfil de Acesso" (junção de nomes) não é ordenável, mesmo critério das outras telas que não ordenam colunas agregadas.
**Botão "Vincular" (visual, sem funcionalidade ainda) nos campos Código da Folha/Questor/Tareffa do formulário de edição**: decisão explícita do usuário — esses 3 códigos vão futuramente ser buscados/vinculados a partir de uma ferramenta externa (ex.: `codigo_tareffa` via a view já existente em `portal_api/database/` que lê o Tareffa, ver [[project_database_package]] na memória) em vez de digitados à mão, mas essa vinculação de verdade **não foi implementada nesta rodada** — só a estrutura visual. Cada um dos 3 campos (`#ua-codigo-folha`/`#ua-codigo-questor`/`#ua-codigo-tareffa`) ganhou um input + botão "Vincular" (ícone de elo + texto) encostados numa única caixa (`.ua-field-link` — borda/raio únicos, botão separado por `border-left`, mesmo estilo de referência que o usuário mostrou de um campo de busca com botão "Buscar" atado à direita); passou por duas versões mais simples antes (botão solto ao lado do input, depois só o ícone sem texto no canto) até o usuário pedir essa terceira, "no estilo do botão de buscar". Sempre `disabled` com `title="Vinculação com sistema externo ainda não implementada"` — não tem nenhum handler de clique em `users-admin.js`. `codigo_contabit` e `ramal` (também campos cadastrais na mesma seção "Dados Cadastrais") **não** ganharam o botão — não fazem parte do conjunto de códigos com vinculação externa planejada, continuam sendo só texto livre. Ao implementar a busca de verdade num momento futuro, reaproveitar esse mesmo botão (tirar o `disabled`, adicionar o handler), não recriar o campo do zero.
## Liderança (gerente/coordenador) e o modal "Gerenciar Usuário" ## Liderança (gerente/coordenador) e o modal "Gerenciar Usuário"
`Usuario.lideranca` (booleano) marca um usuário como gerente/coordenador de outros; `Usuario.liderados` é um M2M **auto-referenciado** (`"self"`, `symmetrical=False`, `related_name="lideres"`) — ou seja, "A lidera B" não implica "B lidera A". Exemplo: marcar `lideranca=True` em "debora" e incluir "gabriel" em `liderados` representa "debora é gerente de gabriel". `Usuario.lideranca` (booleano) marca um usuário como gerente/coordenador de outros; `Usuario.liderados` é um M2M **auto-referenciado** (`"self"`, `symmetrical=False`, `related_name="lideres"`) — ou seja, "A lidera B" não implica "B lidera A". Exemplo: marcar `lideranca=True` em "debora" e incluir "gabriel" em `liderados` representa "debora é gerente de gabriel".
Dois lugares gravam essa mesma relação: Dois lugares gravam essa mesma relação:
- **Tela de Usuários** (`usuarios.html`/`users-admin.js`, só quem tem `gerencia_permissoes`): seção "Liderança" no formulário de edição — checkbox "É gerente ou coordenador de outros usuários" (`#ua-lideranca`) libera (`hidden`) uma `.checklist-box` (`#ua-liderados-checklist`) com todos os outros usuários cadastrados (a própria conta sendo editada é excluída da lista — mesmo padrão de guarda "frontend-only" já usado pra "não pode excluir a si mesmo"/"não pode se auto-desativar", não há checagem equivalente no backend). Salvar envia `lideranca`+`liderados` (array de ids) no mesmo payload de `PATCH`/`POST /api/usuarios/`. - **Tela de Usuários** (`usuarios.html`/`users-admin.js`, só quem tem `gerencia_permissoes`): seção "Liderança" no formulário de edição — checkbox "É gerente ou coordenador de outros usuários" (`#ua-lideranca`) libera (`hidden`) o widget de vinculação dual descrito abaixo (a própria conta sendo editada é excluída da lista de candidatos — mesmo padrão de guarda "frontend-only" já usado pra "não pode excluir a si mesmo"/"não pode se auto-desativar", não há checagem equivalente no backend). Salvar envia `lideranca`+`liderados` (array de ids) no mesmo payload de `PATCH`/`POST /api/usuarios/`.
- **Modal "Gerenciar Usuário"** (`account.js`, disponível em todo shell via o item "Gerenciar Usuário" no dropdown da conta — substituiu o antigo botão direto "Alterar senha"): abre `#manage-account-modal`, que sempre tem um botão "Alterar senha" (que fecha esse modal e abre o `#password-modal` já existente, mesmo fluxo de antes) e, só se `me.lideranca` for `true`, uma segunda seção com a mesma `.checklist-box` de liderados — permitindo que o próprio gerente/coordenador se autogerencie sem precisar de acesso à tela administrativa de Usuários. Essa lista vem de `GET /api/usuarios-resumo/` (não de `/api/usuarios/`, que exige `gerencia_permissoes`) e salvar dispara `PATCH /api/me/liderados/`, que grava na mesma `Usuario.liderados` — `meus_liderados_view` recusa (403) se `request.user.lideranca` for `False`, já que só faz sentido pra quem tem o checkbox marcado. - **Modal "Gerenciar Usuário"** (`account.js`, disponível em todo shell via o item "Gerenciar Usuário" no dropdown da conta — substituiu o antigo botão direto "Alterar senha"): abre `#manage-account-modal`, que sempre tem um botão "Alterar senha" (que fecha esse modal e abre o `#password-modal` já existente, mesmo fluxo de antes) e, só se `me.lideranca` for `true`, o mesmo widget de vinculação dual — permitindo que o próprio gerente/coordenador se autogerencie sem precisar de acesso à tela administrativa de Usuários. Essa lista vem de `GET /api/usuarios-resumo/` (não de `/api/usuarios/`, que exige `gerencia_permissoes`) e salvar dispara `PATCH /api/me/liderados/`, que grava na mesma `Usuario.liderados` — `meus_liderados_view` recusa (403) se `request.user.lideranca` for `False`, já que só faz sentido pra quem tem o checkbox marcado.
**Busca por nome/departamento e layout em duas linhas**: `UsuarioResumoSerializer` (usado por `/api/usuarios-resumo/` e por `liderados` aninhado em `UsuarioListSerializer`/`/api/me/`) expõe também `departamentos` (nested `DepartamentoSerializer`), não só `id`/`nome`. Os dois pontos que renderizam o checklist de liderados (`users-admin.js`/`account.js`) usam isso pra: (a) cada item (`.checklist-item`) mostrar o nome e, numa segunda linha abaixo (`.checklist-item__info` empilha os dois em coluna), os departamentos do candidato unidos por vírgula (`—` se nenhum) — layout em duas linhas com quebra normal (não uma só linha com `text-overflow: ellipsis`) porque truncar cortava departamentos no meio quando o usuário tinha mais de um vinculado; (b) um campo de busca (`.checklist-search`, `#ua-liderados-search`/`#manage-account-liderados-search`) que filtra a lista pelo nome do usuário **ou** pelo texto dos departamentos (`String.includes`, case-insensitive) — qualquer um dos dois casa. A seleção de checkboxes é mantida em memória num `Set` (`lideradosSelecionados`, um por tela) em vez de lida direto do DOM, porque filtrar re-renderiza `innerHTML` e apagaria o estado de quem estivesse marcado mas momentaneamente fora do filtro; um listener de `change` delegado no container (`.checklist-box`) atualiza esse `Set` a cada clique, e o array final enviado à API vem de `Array.from(set)`, não de uma querystring nos checkboxes visíveis. **Widget "Usuários sob liderança" — duas tabelas, não vinculados à esquerda e vinculados à direita** (`.dual-select`, `static/js/dual-select.js` + estilos em `components.css`): substituiu o antigo `.checklist-box` de uma lista só (checkbox + busca + "marcar todos") — reestruturado a pedido do usuário no mesmo estilo do widget "Usuários do Escritório" de Perfis de Acesso (ver seção própria abaixo), só que genérico o bastante pra rodar tanto em `usuarios.html` quanto dentro do modal "Gerenciar Usuário" (presente em todo shell). `pidCriarSeletorDuplo(config)` (`dual-select.js`, incluído no prefixo de scripts de todo shell, logo depois de `api.js`) é a fábrica compartilhada — recebe as referências de DOM de cada painel (`available`/`linked`: checkbox "selecionar todos", cabeçalho ordenável, dois campos de busca, corpo da tabela, rodapé de contagem e o botão de ação) mais `secundariaValor(candidato)` (aqui, `departamentosTexto()`, unindo os nomes dos departamentos por vírgula) e devolve `{ setDados(candidatos, vinculadosIniciais), getVinculadosIds() }`. Cada tela (`users-admin.js`/`account.js`) só chama `setDados()` ao abrir o formulário/modal e `getVinculadosIds()` no momento de salvar — a vinculação em si é só em memória dentro do widget (nenhuma chamada de API própria), o "Vincular"/"Desvincular" só move ids entre os dois painéis local mente, igual ao checklist antigo (que também só populava um `Set` em memória até o "Salvar" de fora).
- Cada painel tem: checkbox de seleção múltipla + "selecionar todos" (sobre as linhas **filtradas** visíveis, mesmo critério do antigo `.checklist-select-all`), coluna "Nome" ordenável (clique alterna asc/desc) e coluna "Departamento", com uma segunda linha de cabeçalho só com os dois campos de busca (por nome e por departamento, independentes) — filtro client-side sobre o array já carregado. O botão de ação do painel ("Vincular" a esquerda/"Desvincular" a direita) fica desabilitado enquanto a seleção daquele painel estiver vazia, e mover usuários limpa a seleção e re-renderiza os dois painéis (quem saiu de um painel aparece no outro).
**"Marcar todos os resultados da busca"** (`.checklist-select-all`, `#ua-liderados-select-all`/`#manage-account-liderados-select-all`): opera só sobre o subconjunto **filtrado** pela busca no momento (não sobre todos os candidatos) — marcar adiciona o id de cada usuário filtrado ao `Set`, desmarcar remove; a cada re-render, o próprio estado desse checkbox é recalculado a partir do filtro atual (`checked` se todos os filtrados estão no `Set`, `indeterminate` se só parte, `disabled` se a busca não retornou ninguém), então ele reflete o resultado visível, não um total geral fixo. - `#manage-account-modal-card` (id novo no `.modal-card` do modal "Gerenciar Usuário") ganha a classe `.modal-card--wide` via JS (`account.js`) só quando `me.lideranca` é `true` — o modal volta ao tamanho padrão (420px) quando só tem o botão "Alterar senha", em vez de ficar largo à toa pra quem não lidera ninguém.
- `.dual-select`/`.dual-select__*` moram em `components.css` (não em `perfis-acesso.css`), pela mesma razão de `.checklist-box` — o modal "Gerenciar Usuário" existe em todo shell, e a maioria deles não carrega `perfis-acesso.css`.
`.checklist-box`/`.checklist-item` (+ `.checklist-item__nome`/`.checklist-item__departamento`/`.checklist-empty`/`.checklist-search`, `components.css`) é o componente genérico de "lista com checkbox, coluna secundária e busca" reaproveitado pelos três checklists de `usuarios.html` (Perfis de Acesso, Departamento, Liderados — os dois primeiros sem coluna/busca, só o layout base) **e** pelo checklist de liderados do modal "Gerenciar Usuário" — por isso mora em `components.css` (carregado em todo shell), não em `perfis-acesso.css` (só carregado em `usuarios.html`/`perfis-acesso.html`/`ramais.html`), onde vivia antes de existir um segundo consumidor fora dessas três páginas. - **Usuário inativo nunca aparece nos candidatos** — mesma decisão de "Usuários do Escritório" acima. Em `usuarios.html`, `fillLideradosChecklist()` (`users-admin.js`) filtra `users` por `is_active` antes de montar a lista de candidatos; no modal "Gerenciar Usuário", isso já vem de graça porque `GET /api/usuarios-resumo/` (`usuarios_resumo_view`) só devolve usuários ativos.
## Favoritos: como o ID de uma aplicação é derivado ## Favoritos: como o ID de uma aplicação é derivado
@ -594,7 +614,7 @@ Terceiro checkbox de "Tipo de importação" (ao lado de Mensalidade/Coparticipa
| `tokens.css` | Variáveis (`:root`, tema claro em `:root[data-theme="light"]`). | | `tokens.css` | Variáveis (`:root`, tema claro em `:root[data-theme="light"]`). |
| `base.css` | Reset global, incluindo `[hidden] { display: none !important; }` — necessário porque vários componentes (`.no-access`, `.app-card`, `.notif-badge`) definem seu próprio `display`, o que sem o `!important` sobrescreveria o comportamento nativo de `hidden`. Também os `@keyframes` globais de animação (`pidFadeIn`/`pidFadeSlideUp`/`pidScaleIn`, ver "Animações" abaixo), já que é o único CSS carregado por **todas** as páginas sem exceção (inclusive `index.html`). | | `base.css` | Reset global, incluindo `[hidden] { display: none !important; }` — necessário porque vários componentes (`.no-access`, `.app-card`, `.notif-badge`) definem seu próprio `display`, o que sem o `!important` sobrescreveria o comportamento nativo de `hidden`. Também os `@keyframes` globais de animação (`pidFadeIn`/`pidFadeSlideUp`/`pidScaleIn`, ver "Animações" abaixo), já que é o único CSS carregado por **todas** as páginas sem exceção (inclusive `index.html`). |
| `layout.css` | Casca do shell: `.app-shell`, `.sidebar*`, `.nav-*`, `.fav-toggle`, `.topbar*`. A sidebar usa tokens **congelados**, independentes de tema (fundo sempre escuro em claro/escuro) — não trocar por variáveis que espelham `:root[data-theme="light"]`. Exceção deliberada: `--sidebar-text-primary`/`--sidebar-text-secondary`/`--sidebar-text-muted` (texto/ícone do menu) *são* sobrescritas em `:root[data-theme="light"]` (`tokens.css`) pra branco puro — pedido explícito do usuário pra melhorar a legibilidade; só o fundo/borda da sidebar continuam frozen. Também `.page-content` (largura do conteúdo de cada página, `max-width:1200px` centralizado por padrão) + o modificador `.page-content--wide` (`max-width:1600px`) — `portal.html` ("Principal") é a única página que usa só `.page-content` puro (grade de favoritos fica mais confortável de leitura mais estreita); as outras 7 páginas (`perfis-acesso.html`, `usuarios.html`, `links-ferramentas.html`, `acessos-gerais.html`, `ramais.html`, `calendario-individual.html`, `importacao-plano-saude.html`) usam `class="page-content page-content--wide"` no `<main>`, decisão explícita do usuário pra aproveitar melhor o espaço entre a sidebar e a borda da tela em telas de tabela/formulário. Uma página nova que seja mais "aplicação" (tabela, formulário, CRUD) do que "dashboard" deve nascer já com `page-content--wide`. | | `layout.css` | Casca do shell: `.app-shell`, `.sidebar*`, `.nav-*`, `.fav-toggle`, `.topbar*`. A sidebar usa tokens **congelados**, independentes de tema (fundo sempre escuro em claro/escuro) — não trocar por variáveis que espelham `:root[data-theme="light"]`. Exceção deliberada: `--sidebar-text-primary`/`--sidebar-text-secondary`/`--sidebar-text-muted` (texto/ícone do menu) *são* sobrescritas em `:root[data-theme="light"]` (`tokens.css`) pra branco puro — pedido explícito do usuário pra melhorar a legibilidade; só o fundo/borda da sidebar continuam frozen. Também `.page-content` (largura do conteúdo de cada página, `max-width:1200px` centralizado por padrão) + o modificador `.page-content--wide` (`max-width:1600px`) — `portal.html` ("Principal") é a única página que usa só `.page-content` puro (grade de favoritos fica mais confortável de leitura mais estreita); as outras 7 páginas (`perfis-acesso.html`, `usuarios.html`, `links-ferramentas.html`, `acessos-gerais.html`, `ramais.html`, `calendario-individual.html`, `importacao-plano-saude.html`) usam `class="page-content page-content--wide"` no `<main>`, decisão explícita do usuário pra aproveitar melhor o espaço entre a sidebar e a borda da tela em telas de tabela/formulário. Uma página nova que seja mais "aplicação" (tabela, formulário, CRUD) do que "dashboard" deve nascer já com `page-content--wide`. |
| `components.css` | UI genérica reutilizável: `.btn-solid/.btn-outline/.btn-ghost/.btn-danger-outline`, **`.modal-overlay`/`.modal-card`** (moldura genérica de modal) **e também** `.modal-field`/`.modal-field-row`/`.modal-checkbox`/`.modal-error`/`.modal-actions` (campos internos do modal — moldura e campos vivem juntos aqui, apesar do nome sugerir só a moldura, incluindo `select`/`textarea` dentro de `.modal-field`, com seta customizada via `background-image` porque o nativo do browser destoa do tema escuro), `.app-card*`, `.no-access`, `.checklist-box`/`.checklist-item`/`.checklist-item__info`/`.checklist-item__nome`/`.checklist-item__departamento`/`.checklist-empty`/`.checklist-search`/`.checklist-select-all` (lista com checkbox, segunda linha de detalhe e busca — usada em `usuarios.html` e no modal "Gerenciar Usuário" de todo shell, ver seção "Liderança"). | | `components.css` | UI genérica reutilizável: `.btn-solid/.btn-outline/.btn-ghost/.btn-danger-outline`, **`.modal-overlay`/`.modal-card`** (moldura genérica de modal, + o modificador `.modal-card--wide` pra quando precisa de mais espaço horizontal) **e também** `.modal-field`/`.modal-field-row`/`.modal-checkbox`/`.modal-error`/`.modal-actions` (campos internos do modal — moldura e campos vivem juntos aqui, apesar do nome sugerir só a moldura, incluindo `select`/`textarea` dentro de `.modal-field`, com seta customizada via `background-image` porque o nativo do browser destoa do tema escuro), `.app-card*`, `.no-access`, `.checklist-box`/`.checklist-item`/`.checklist-item__info`/`.checklist-item__nome`/`.checklist-item__departamento`/`.checklist-empty`/`.checklist-search`/`.checklist-select-all` (lista com checkbox, segunda linha de detalhe e busca — usada nos checklists de Perfis de Acesso/Departamento em `usuarios.html`) e `.dual-select`/`.dual-select__*` (vinculação em duas tabelas — não vinculados/vinculados, ver seção "Liderança" — usada em `usuarios.html` e no modal "Gerenciar Usuário" de todo shell). |
| `perfis-acesso.css` | `.pa-*` (tela de Perfis de Acesso), incluindo as seções (`.ua-section*`) e campos específicos (`.ua-inline-add`/`.ua-departamento-item`/`.ua-active-toggle`/`.ua-liderados-field`) do formulário de edição de `usuarios.html`. | | `perfis-acesso.css` | `.pa-*` (tela de Perfis de Acesso), incluindo as seções (`.ua-section*`) e campos específicos (`.ua-inline-add`/`.ua-departamento-item`/`.ua-active-toggle`/`.ua-liderados-field`) do formulário de edição de `usuarios.html`. |
| `calendario.css` | Só `.calendar-*` (grade mensal, células de dia, nav do mês) — os campos do modal de compromisso usam as classes genéricas `.modal-field`/`.modal-checkbox`/`.modal-error`/`.modal-actions` de `components.css`. | | `calendario.css` | Só `.calendar-*` (grade mensal, células de dia, nav do mês) — os campos do modal de compromisso usam as classes genéricas `.modal-field`/`.modal-checkbox`/`.modal-error`/`.modal-actions` de `components.css`. |
| `widgets.css` | `.widgets-*`, `.widget-card*`, `.widget-picker-*` — só usado em `portal.html`; também tem `#portal-title` (fonte "Bree Serif" do título "Portal De Paula" no topbar), que não é widget mas mora aqui por ser o único CSS próprio da página. | | `widgets.css` | `.widgets-*`, `.widget-card*`, `.widget-picker-*` — só usado em `portal.html`; também tem `#portal-title` (fonte "Bree Serif" do título "Portal De Paula" no topbar), que não é widget mas mora aqui por ser o único CSS próprio da página. |

View File

@ -924,6 +924,60 @@ Validado rodando o parser (`BradescoSaude.extrai`) e o pipeline completo (`proce
Registrado em `pipeline.OPERADORAS` como `"bradesco_saude": {"label": "1386 - Bradesco Saúde", ...}`. Registrado em `pipeline.OPERADORAS` como `"bradesco_saude": {"label": "1386 - Bradesco Saúde", ...}`.
### 65. Filtro Ativos/Inativos/Todos na lista de Usuários
Usuário pediu um botão pra filtrar a lista de `usuarios.html` entre Ativos/Inativos, abrindo por padrão só com os Ativos, mas com opção de tirar o filtro pra ver os inativos também.
Implementado como 3 chips ("Ativos"/"Inativos"/"Todos", `#ua-status-filtros`, ao lado do título "Usuários") — mesma linguagem visual de `.ind-departamento-chip` (Indicador de Desempenho). Filtro 100% client-side sobre o array `users` já carregado (`statusFiltro`, padrão `"ativos"`), aplicado em `renderList()` antes da busca por texto. Sem endpoint novo.
### 66. Colunas de código cadastral (Folha/Questor/Tareffa/Contabit/Ramal) + ordenação na lista de Usuários
Usuário pediu pra ver os campos cadastrais opcionais (`codigo_folha`/`codigo_questor`/`codigo_tareffa`/`codigo_contabit`/`ramal`, hoje só visíveis dentro do formulário de edição) como colunas na lista, e poder ordenar por elas — mesmo sendo campos opcionais, o objetivo é achar quem está sem algum deles cadastrado antes de outras aplicações passarem a depender dessas informações.
`UsuarioListSerializer` já expunha os 5 campos (nenhuma mudança de backend necessária). Adicionadas 5 colunas em `#ua-table` (`usuarios.html`) entre Nome e Perfil de Acesso; célula vazia mostra `—` em itálico apagado (`.ua-campo-vazio`) em vez de ficar em branco, pra ficar visualmente óbvio ao ordenar. Cabeçalhos com `data-sort` (login, nome, os 4 códigos, ramal, status) ficam clicáveis e alternam asc/desc — mesmo padrão (`comparaValoresUsuario`, ícone `↕` que vira `--accent` no ativo) já usado em `#ips-list-table` (Importação de Plano de Saúde) e no modal de Ramais.
### 67. Aba "Usuários do Escritório" (Perfis de Acesso) reestruturada em duas tabelas com seleção múltipla
Usuário anexou um print de outro sistema (duas grades lado a lado, cada uma com checkbox, busca por coluna, ordenação e um botão de ação em lote — "VINCULAR"/"DESVINCULAR") e pediu pra reestruturar a aba "Usuários do Escritório" (dentro da edição de um perfil, `perfis-acesso.html`) nesse mesmo estilo: ver todos os usuários ativos de um lado e vincular, ver os já vinculados do outro lado e desvincular.
Substituído o antigo `<select>` + botão "Vincular" + lista simples com X (`.pa-users-add`/`.pa-user-list`, removidos) por `.pa-users-dual` — duas tabelas (`#pa-users-available-*` à esquerda, só usuários ativos ainda não vinculados; `#pa-users-linked-*` à direita, todos os já vinculados, inclusive inativos, com selo `.status-pill--inativo`), cada uma com checkbox por linha + "selecionar todos" (sobre as linhas filtradas visíveis), busca por nome/e-mail, ordenação por nome (clique no cabeçalho, ícone `↕`) e um botão de atualizar. "Vincular"/"Desvincular" operam em lote sobre a seleção (`bulkAlterarVinculo()`, `Promise.all` de `PATCH /api/usuarios/{id}/` por usuário selecionado) — sem endpoint novo, só reaproveitando `GET/PATCH /api/usuarios/` que já existiam. Abrir a aba de um perfil diferente sempre reseta seleção/ordenação/busca dos dois painéis.
### 68. Botão "Vincular" (só visual) nos campos Código da Folha/Questor/Tareffa
Usuário anexou um print mostrando 3 campos específicos (Código da Folha, Código do Questor, Código do Tareffa — não Contabit nem Ramal) e explicou que, no futuro, esses códigos serão buscados/vinculados automaticamente a partir de ferramentas externas (ex.: `codigo_tareffa` via a view que já existe no banco pra ler o Tareffa) em vez de digitados manualmente. Pediu explicitamente pra **não** construir essa vinculação agora — só deixar a estrutura visual pronta, com um botão "Vincular".
Passou por 3 versões visuais no mesmo dia até o formato final: (1) botão "Vincular" solto ao lado do input (`.btn-outline`, com texto); (2) só o ícone de elo no canto do próprio input (`position:absolute`, sem texto — pedido do usuário: "deixe o botão mais simples, só o símbolo no canto"); (3) formato final — input + botão "Vincular" (ícone + texto) encostados numa única caixa (`.ua-field-link`, borda/raio únicos, `border-left` separando os dois), depois do usuário mostrar um print de referência (campo de busca com botão "Buscar" atado à direita) e pedir esse mesmo estilo. Sempre `disabled` com `title` explicando que a vinculação ainda não foi implementada. Sem handler de JS nenhum — puramente estrutural, pra reaproveitar quando a integração de verdade for construída.
### 69. Bug: `IntegrityError` ao criar um novo perfil de acesso (`codigo` duplicado)
Usuário reportou erro `500` ao criar um perfil pela tela: `django.db.utils.IntegrityError: duplicate key value violates unique constraint "portal_api_perfilacesso_pkey" — DETAIL: Key (codigo)=(6) already exists`.
Causa: `seed_portal.py` semeia os 8 perfis com `codigo` **explícito** (`update_or_create(codigo=dado["codigo"], ...)`, necessário porque vários lugares do código referenciam código fixo, ex. `codigo == 8`). No Postgres, `INSERT` com PK explícita não avança a sequence do `AutoField` — a sequence de `PerfilAcesso.codigo` ficou parada em 1 desde a criação do banco, então o primeiro `POST /api/perfis/` (sem PK explícita) pediu `nextval()` e recebeu um código baixo já usado pelo seed (6 = "Financeiro").
Corrigido com `_reset_sequence()` (nova função em `seed_portal.py`, `SELECT setval(pg_get_serial_sequence(...), MAX(codigo))` via SQL puro) chamada logo após semear `PERFIS_SEED` — toda execução do comando realinha a sequence de novo, então o bug não volta a acontecer mesmo que o seed seja reexecutado no futuro. Aplicado no banco em produção rodando `python manage.py seed_portal` (confirmado via `SELECT last_value FROM portal_api_perfilacesso_codigo_seq` = 8, igual ao maior código existente).
### 70. "Usuários sob liderança" reestruturado em duas tabelas (não vinculados/vinculados)
Usuário anexou dois prints — um da própria seção "Liderança" (checklist único, com busca e "marcar todos") e outro de um sistema externo com duas grades lado a lado (checkbox, "Código"/"Nome", ordenação, paginação, VINCULAR/DESVINCULAR) — e pediu pra reestruturar "Usuários sob liderança" nesse mesmo estilo de duas tabelas: não vinculados de um lado, vinculados do outro (corrigido depois pra confirmar: não vinculados à **esquerda**, vinculados à **direita** — mesma convenção já usada no widget "Usuários do Escritório" de Perfis de Acesso, rodada 67).
Essa seção aparece em dois lugares (formulário de edição de usuário em `usuarios.html` **e** o modal "Gerenciar Usuário", presente em todo shell, pra autogestão do próprio gerente/coordenador) — perguntado ao usuário se a mudança deveria valer só pra tela de Usuários ou pros dois lugares; resposta: os dois.
Como o widget "Usuários do Escritório" (rodada 67) já tinha esse exato padrão visual mas vivia só em `perfis-acesso.css`/inline em `profiles.js` (só usado nessa página), foi extraída uma versão genérica reaproveitável: `static/js/dual-select.js` (`pidCriarSeletorDuplo()`, fábrica que recebe as referências de DOM de cada painel + uma função pro valor da coluna secundária, devolve `{setDados, getVinculadosIds}` — vinculação só em memória, sem chamada de API própria) + `.dual-select`/`.dual-select__*` em `components.css` (não em `perfis-acesso.css`, já que o modal "Gerenciar Usuário" existe em todo shell e a maioria não carrega esse CSS). `dual-select.js` foi adicionado ao prefixo de scripts compartilhado das 10 páginas-shell, logo depois de `api.js`.
Trocados: `usuarios.html` (seção "Liderança") e as 10 cópias do modal "Gerenciar Usuário" (uma por shell) — `.checklist-box`+busca+"marcar todos" virou o par de tabelas com coluna "Nome" (ordenável) + "Departamento" (busca própria). `#manage-account-modal-card` ganhou id novo pra `account.js` poder aplicar `.modal-card--wide` só quando `me.lideranca` é `true` (o modal padrão de 420px não cabe duas tabelas lado a lado). `users-admin.js`/`account.js` tiveram a lógica de checklist único substituída por chamadas a `pidCriarSeletorDuplo` — o restante do fluxo (salvar `liderados` junto do form em `usuarios.html`, `PATCH /api/me/liderados/` imediato no modal) não mudou.
**Ajuste de proporção no mesmo dia**: usuário reportou dois problemas visuais depois de testar — (1) "Perfis de Acesso" e "Departamento" (`usuarios.html`) não ficaram do mesmo tamanho; (2) o par de tabelas de "Usuários sob liderança" ficou comprimido num canto, sem usar a largura da seção. Causa de (1): `.pa-field-row.ua-permissions-row` usava `grid-template-columns: 1.6fr 1fr` (resquício de quando só existia um checklist de cada lado, sem se importar com simetria) — trocado para `1fr 1fr`. Causa de (2): `.ua-liderados-field` tinha `max-width: 480px`, herdado de quando o campo continha só um `.checklist-box` estreito — removido, já que agora o campo precisa da largura inteira da seção pras duas tabelas do `.dual-select`.
### 72. Inativar usuário desvincula perfis de acesso e liderança automaticamente
Regra de negócio pedida pelo usuário: ao marcar um usuário como Inativo, ele deve ser desvinculado automaticamente de todos os perfis de acesso que tinha, e removido da lista de liderados de qualquer gerente que o tivesse — pra não deixar um vínculo "pendurado" em alguém que já não pode mais acessar o Portal.
Implementado em `_revogar_acesso_se_inativo()` (`serializers.py`), chamada ao final de `UsuarioSerializer.create()`/`update()` (depois dos `.set()` de `perfis`/`departamentos`/`liderados`, nunca antes — o formulário de edição sempre reenvia o checklist de perfis inteiro junto com qualquer alteração no checkbox "Usuário ativo", então limpar antes dos `.set()` seria desfeito na mesma requisição). Cobre os dois pontos reais de entrada (botão de alternar na lista e checkbox no formulário), ambos via `PATCH /api/usuarios/{id}/`.
Uma primeira versão tentou resolver isso em `Usuario.save()` (model), mas foi abandonada: tanto o `ModelSerializer.update()` do DRF quanto o admin do Django chamam `.set()`/`save_m2m()` nos M2M **depois** de `instance.save()`, então uma limpeza dentro de `save()` seria sempre desfeita pelo `.set(perfis)` que vem a seguir no mesmo request. Por isso a limpeza mora no serializer, como o último passo, não no model.
**Complemento no mesmo dia**: usuário reportou visualmente que usuários inativos ainda apareciam nos dois widgets de vinculação (dual-select de "Usuários sob liderança" e de "Usuários do Escritório" em Perfis de Acesso) — pediu que usuário inativo nunca seja exibido em nenhum dos dois painéis (nem disponível, nem já vinculado). Corrigido: `listaParaPainelUsuarios()` (`profiles.js`) agora filtra `usuariosCacheAtual` por `is_active` antes de separar entre disponível/vinculado (removido também o selo "Inativo" que antes marcava um usuário inativo ainda vinculado, já que esse caso não deve mais aparecer); `fillLideradosChecklist()` (`users-admin.js`) ganhou o mesmo filtro. No modal "Gerenciar Usuário" (`account.js`) já não era um problema — `GET /api/usuarios-resumo/` só devolve usuários ativos.
## Roadmap / próximos passos ## Roadmap / próximos passos
Nenhuma pendência explícita em aberto no momento, exceto a limitação conhecida Nenhuma pendência explícita em aberto no momento, exceto a limitação conhecida

View File

@ -1,7 +1,8 @@
from typing import Any from typing import Any
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.db import transaction from django.db import connection, transaction
from django.db.models import Model
from portal_api import catalogo from portal_api import catalogo
from portal_api.models import CategoriaEvento, FuncaoTelefonia, PerfilAcesso, Usuario from portal_api.models import CategoriaEvento, FuncaoTelefonia, PerfilAcesso, Usuario
@ -115,6 +116,27 @@ PERFIS_SEED = [
] ]
def _reset_sequence(model: type[Model]) -> None:
"""Realinha a sequence do Postgres por trás do AutoField de `model` com o
maior valor de PK já usado na tabela.
Necessário porque `PerfilAcesso` é populado abaixo com `codigo` explícito
(`update_or_create(codigo=dado["codigo"], ...)`) — um INSERT com PK
explícita no Postgres nunca avança a sequence associada ao `serial`, então
o próximo INSERT sem PK explícita (criar um perfil novo pela tela) volta a
gerar códigos baixos (1, 2, ...) que colidem com os perfis já seedados,
estourando `IntegrityError: duplicate key value violates unique
constraint`. Reexecutar o seed sempre corrige isso de novo.
"""
table = model._meta.db_table
pk_column = model._meta.pk.column
with connection.cursor() as cursor:
cursor.execute(
f"SELECT setval(pg_get_serial_sequence('{table}', '{pk_column}'), "
f"COALESCE((SELECT MAX({pk_column}) FROM {table}), 1))"
)
class Command(BaseCommand): class Command(BaseCommand):
help = "Recria os 8 perfis de acesso padrão e os usuários de demonstração (gabriel/bruno)." help = "Recria os 8 perfis de acesso padrão e os usuários de demonstração (gabriel/bruno)."
@ -156,6 +178,8 @@ class Command(BaseCommand):
acao = "criado" if criado else "atualizado" acao = "criado" if criado else "atualizado"
self.stdout.write(f"Perfil {acao}: {perfil.nome} (código {perfil.codigo})") self.stdout.write(f"Perfil {acao}: {perfil.nome} (código {perfil.codigo})")
_reset_sequence(PerfilAcesso)
integracao = PerfilAcesso.objects.get(codigo=8) integracao = PerfilAcesso.objects.get(codigo=8)
# Só inicializa nome/senha/perfis na primeira criação — reexecuções não devem # Só inicializa nome/senha/perfis na primeira criação — reexecuções não devem

View File

@ -99,6 +99,24 @@ class UsuarioResumoSerializer(serializers.ModelSerializer):
fields = ["id", "nome", "departamentos"] fields = ["id", "nome", "departamentos"]
def _revogar_acesso_se_inativo(usuario: Usuario) -> None:
"""Decisão explícita do usuário: uma conta inativa (`is_active=False`) não
deve continuar vinculada a nenhum perfil de acesso, nem aparecer sob a
liderança de nenhum gerente — sem isso, o vínculo ficaria "pendurado" em
alguém que já não pode mais logar. Chamado sempre **depois** dos
`.set()` de `perfis`/`liderados` em `create()`/`update()`, pra não ser
imediatamente desfeito se o mesmo payload também enviar `perfis` (ex.: o
formulário de edição de `usuarios.html` sempre reenvia o checklist
inteiro, mesmo quando só o checkbox "Usuário ativo" mudou). `lideres` é a
relação reversa de `Usuario.liderados` — limpar aqui remove `usuario` do
`liderados` de todo gerente que o tivesse, sem afetar quem `usuario`
eventualmente lidera."""
if usuario.is_active:
return
usuario.perfis.clear()
usuario.lideres.clear()
class UsuarioSerializer(serializers.ModelSerializer): class UsuarioSerializer(serializers.ModelSerializer):
"""Usado para criar/editar (aceita `senha` e `perfis` graváveis).""" """Usado para criar/editar (aceita `senha` e `perfis` graváveis)."""
@ -143,6 +161,7 @@ class UsuarioSerializer(serializers.ModelSerializer):
usuario.perfis.set(perfis) usuario.perfis.set(perfis)
usuario.departamentos.set(departamentos) usuario.departamentos.set(departamentos)
usuario.liderados.set(liderados) usuario.liderados.set(liderados)
_revogar_acesso_se_inativo(usuario)
return usuario return usuario
def update(self, instance: Usuario, validated_data: dict[str, Any]) -> Usuario: def update(self, instance: Usuario, validated_data: dict[str, Any]) -> Usuario:
@ -161,6 +180,7 @@ class UsuarioSerializer(serializers.ModelSerializer):
instance.departamentos.set(departamentos) instance.departamentos.set(departamentos)
if liderados is not None: if liderados is not None:
instance.liderados.set(liderados) instance.liderados.set(liderados)
_revogar_acesso_se_inativo(instance)
return instance return instance

View File

@ -127,6 +127,15 @@
line-height: 1.5; line-height: 1.5;
} }
/* Modal que precisa de mais espaço horizontal que o padrão de 420px — hoje
usado só por #manage-account-modal quando o campo de liderados (duas
tabelas lado a lado, ver .dual-select abaixo) está visível; aplicado/
removido em JS conforme `me.lideranca`, pra não deixar o modal largo à
toa quando só tem o botão "Alterar senha". */
.modal-card--wide {
max-width: 760px;
}
/* Modal que embute uma ferramenta externa num <iframe> (ex.: "Calendário De /* Modal que embute uma ferramenta externa num <iframe> (ex.: "Calendário De
Paula" no sidebar, todo shell) — ocupa quase a tela inteira e o iframe Paula" no sidebar, todo shell) — ocupa quase a tela inteira e o iframe
preenche todo o espaço vertical restante (flex:1 dentro do .modal-card, preenche todo o espaço vertical restante (flex:1 dentro do .modal-card,
@ -365,6 +374,146 @@
margin-bottom: var(--space-2); margin-bottom: var(--space-2);
} }
/* Widget genérico de vinculação em duas tabelas (não vinculados à esquerda,
vinculados à direita — ver static/js/dual-select.js), com checkbox de
seleção múltipla, busca por coluna, ordenação por nome e um botão de ação
em lote por painel ("Vincular"/"Desvincular"). Mora aqui, não em
perfis-acesso.css, porque roda tanto em usuarios.html quanto dentro do
modal "Gerenciar Usuário" (presente em todo shell) — mesma razão de
.checklist-box viver aqui. Auto-contido (não reaproveita .pa-table),
porque a maioria dos shells que usam esse modal não carrega
perfis-acesso.css. */
.dual-select {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-4);
align-items: start;
}
@media (max-width: 720px) {
.dual-select {
grid-template-columns: 1fr;
}
}
.dual-select__panel-header {
display: flex;
align-items: center;
justify-content: flex-end;
margin-bottom: var(--space-2);
}
.dual-select__panel-header .btn-solid:disabled,
.dual-select__panel-header .btn-danger-outline:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.dual-select__table-wrap {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
overflow: hidden;
max-height: 280px;
overflow-y: auto;
}
.dual-select__table {
width: 100%;
border-collapse: collapse;
}
.dual-select__table thead {
position: sticky;
top: 0;
z-index: 1;
}
.dual-select__table th {
text-align: left;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-muted);
padding: var(--space-2) var(--space-3);
background: var(--bg-surface-raised);
border-bottom: 1px solid var(--border-subtle);
}
.dual-select__table td {
padding: var(--space-2) var(--space-3);
border-bottom: 1px solid var(--border-subtle);
font-size: 0.85rem;
color: var(--text-primary);
}
.dual-select__table tr:last-child td {
border-bottom: none;
}
.dual-select__table tr:hover td {
background: var(--card-bg-hover);
}
.dual-select__table th input[type="checkbox"],
.dual-select__table td input[type="checkbox"] {
width: 15px;
height: 15px;
accent-color: var(--accent);
vertical-align: middle;
}
.dual-select__sort {
cursor: pointer;
user-select: none;
white-space: nowrap;
}
.dual-select__sort-icon {
color: var(--text-muted);
font-size: 0.72rem;
}
.dual-select__sort.is-sort-asc .dual-select__sort-icon,
.dual-select__sort.is-sort-desc .dual-select__sort-icon {
color: var(--accent);
}
.dual-select__filters th {
background: var(--bg-surface);
padding: var(--space-2) var(--space-3);
}
.dual-select__filters input {
width: 100%;
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-sm);
border: 1px solid var(--border-subtle);
background: var(--bg-canvas);
color: var(--text-primary);
font-size: 0.78rem;
font-weight: 400;
text-transform: none;
letter-spacing: normal;
outline: none;
}
.dual-select__filters input:focus {
border-color: var(--accent);
}
.dual-select__empty-row td {
text-align: center;
color: var(--text-muted);
padding: var(--space-5);
}
.dual-select__footer {
padding: var(--space-2) var(--space-1) 0;
font-size: 0.76rem;
color: var(--text-muted);
}
.btn-ghost:hover { .btn-ghost:hover {
background: var(--accent); background: var(--accent);
color: var(--accent-contrast); color: var(--accent-contrast);

View File

@ -48,6 +48,33 @@
color: var(--accent-contrast); color: var(--accent-contrast);
} }
/* Ordenação por coluna na tabela de usuários (#ua-table) — mesma linguagem
visual do ícone ↕ estático já usada em #ips-list-table
(importacao-plano-saude.css) e no modal de Ramais (ramais-lookup.css). */
#ua-table th[data-sort] {
cursor: pointer;
user-select: none;
white-space: nowrap;
}
.ua-sort-icon {
color: var(--text-muted);
font-size: 0.75rem;
}
#ua-table th[data-sort].is-sort-asc .ua-sort-icon,
#ua-table th[data-sort].is-sort-desc .ua-sort-icon {
color: var(--accent);
}
/* Marca visualmente um código cadastral (folha/Questor/Tareffa/Contabit)
ou ramal não preenchido — ordenar a coluna agrupa essas linhas, para
achar quem ainda está sem cadastro. */
.ua-campo-vazio {
color: var(--text-muted);
font-style: italic;
}
.pa-search { .pa-search {
position: relative; position: relative;
width: 260px; width: 260px;
@ -283,7 +310,7 @@
} }
.pa-field-row.ua-permissions-row { .pa-field-row.ua-permissions-row {
grid-template-columns: 1.6fr 1fr; grid-template-columns: 1fr 1fr;
} }
@media (max-width: 900px) { @media (max-width: 900px) {
@ -384,9 +411,60 @@
flex: 1; flex: 1;
} }
/* Campo cadastral com vinculação externa futura (Código da Folha/Questor/
Tareffa) — input + botão "Vincular" formando uma única caixa (mesmo
estilo de referência mostrado pelo usuário: campo de busca com o botão
"Buscar" encostado à direita, separado por uma linha divisória);
desabilitado de propósito, já que a busca/vínculo de verdade com a
ferramenta externa (ex.: view do Tareffa no banco) ainda não foi
implementada. */
.ua-field-link {
display: flex;
align-items: stretch;
border-radius: var(--radius-sm);
border: 1px solid var(--border-subtle);
overflow: hidden;
background: var(--bg-canvas);
}
.ua-field-link:focus-within {
border-color: var(--accent);
}
.ua-field-link input {
flex: 1;
min-width: 0;
border: none !important;
border-radius: 0 !important;
background: transparent !important;
}
.ua-vincular-btn {
display: flex;
align-items: center;
gap: var(--space-2);
flex-shrink: 0;
padding: 0 var(--space-3);
border-left: 1px solid var(--border-subtle);
background: var(--bg-surface-raised);
color: var(--text-secondary);
font-size: 0.82rem;
font-weight: 600;
white-space: nowrap;
}
.ua-vincular-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.ua-vincular-btn:not(:disabled):hover {
color: var(--accent);
background: var(--bg-surface);
}
.ua-liderados-field { .ua-liderados-field {
margin-top: var(--space-3); margin-top: var(--space-3);
max-width: 480px;
} }
.ua-departamento-item { .ua-departamento-item {
@ -637,62 +715,97 @@
accent-color: var(--accent); accent-color: var(--accent);
} }
/* Usuários do escritório */ /* Usuários do escritório — duas tabelas lado a lado (disponíveis pra
.pa-users-add { vincular / já vinculados a este perfil), cada uma com checkbox de
display: flex; seleção múltipla, busca por coluna e ordenação por nome; "Vincular"/
gap: var(--space-2); "Desvincular" operam em lote sobre a seleção da tabela correspondente. */
margin-bottom: var(--space-4); .pa-users-dual {
max-width: 420px; display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-5);
align-items: start;
} }
.pa-users-add select { @media (max-width: 900px) {
flex: 1; .pa-users-dual {
padding: var(--space-2) var(--space-3); grid-template-columns: 1fr;
}
}
.pa-users-panel__header {
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--space-2);
margin-bottom: var(--space-3);
}
.pa-users-table__checkbox {
width: 40px;
}
.pa-users-table th input[type="checkbox"] {
width: 15px;
height: 15px;
accent-color: var(--accent);
vertical-align: middle;
}
.pa-users-table td input[type="checkbox"] {
width: 15px;
height: 15px;
accent-color: var(--accent);
}
.pa-users-table__sort {
cursor: pointer;
user-select: none;
white-space: nowrap;
}
.pa-users-table__sort.is-sort-asc .ua-sort-icon,
.pa-users-table__sort.is-sort-desc .ua-sort-icon {
color: var(--accent);
}
.pa-users-table__filters th {
background: var(--bg-surface);
padding: var(--space-2) var(--space-4);
}
.pa-users-table__filters input {
width: 100%;
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
border: 1px solid var(--border-subtle); border: 1px solid var(--border-subtle);
background: var(--bg-canvas); background: var(--bg-canvas);
color: var(--text-primary); color: var(--text-primary);
font-size: 0.88rem; font-size: 0.8rem;
font-weight: 400;
text-transform: none;
letter-spacing: normal;
outline: none; outline: none;
} }
.pa-users-add select:focus { .pa-users-table__filters input:focus {
border-color: var(--accent); border-color: var(--accent);
} }
.pa-user-login { .pa-users-panel .pa-table-wrap {
color: var(--text-muted); max-height: 360px;
font-weight: 400; overflow-y: auto;
} }
.pa-users-empty { .pa-users-panel .pa-table thead {
color: var(--text-muted); position: sticky;
font-size: 0.85rem; top: 0;
z-index: 1;
} }
.pa-user-list { .pa-users-panel .btn-solid:disabled,
display: flex; .pa-users-panel .btn-danger-outline:disabled {
flex-direction: column; opacity: 0.5;
gap: var(--space-2); cursor: not-allowed;
max-width: 420px;
}
.pa-user-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-sm);
background: var(--bg-surface-raised);
font-size: 0.88rem;
}
.pa-user-remove {
color: var(--text-muted);
}
.pa-user-remove:hover {
color: var(--danger);
} }
.pa-edit-actions { .pa-edit-actions {

View File

@ -59,73 +59,38 @@ document.addEventListener("DOMContentLoaded", async () => {
}); });
const manageAccountModal = document.getElementById("manage-account-modal"); const manageAccountModal = document.getElementById("manage-account-modal");
const manageAccountModalCard = document.getElementById("manage-account-modal-card");
const manageAccountChangePasswordBtn = document.getElementById("manage-account-change-password-btn"); const manageAccountChangePasswordBtn = document.getElementById("manage-account-change-password-btn");
const manageAccountCloseBtn = document.getElementById("manage-account-close-btn"); const manageAccountCloseBtn = document.getElementById("manage-account-close-btn");
const manageAccountLideradosField = document.getElementById("manage-account-liderados-field"); const manageAccountLideradosField = document.getElementById("manage-account-liderados-field");
const manageAccountLideradosSearch = document.getElementById("manage-account-liderados-search");
const manageAccountLideradosSelectAll = document.getElementById("manage-account-liderados-select-all");
const manageAccountLideradosChecklist = document.getElementById("manage-account-liderados-checklist");
const manageAccountLideradosError = document.getElementById("manage-account-liderados-error"); const manageAccountLideradosError = document.getElementById("manage-account-liderados-error");
const manageAccountSaveBtn = document.getElementById("manage-account-save-btn"); const manageAccountSaveBtn = document.getElementById("manage-account-save-btn");
if (manageAccountBtn && manageAccountModal) { if (manageAccountBtn && manageAccountModal) {
let lideradosCandidatos = [];
let lideradosSelecionados = new Set();
function departamentosTexto(usuario) { function departamentosTexto(usuario) {
return (usuario.departamentos || []).map((d) => d.nome).join(", "); return (usuario.departamentos || []).map((d) => d.nome).join(", ");
} }
function lideradosFiltrados() { const seletorLiderados = pidCriarSeletorDuplo({
const termo = manageAccountLideradosSearch.value.trim().toLowerCase(); available: {
return lideradosCandidatos.filter( selectAll: document.getElementById("manage-account-liderados-available-select-all"),
(u) => !termo || u.nome.toLowerCase().includes(termo) || departamentosTexto(u).toLowerCase().includes(termo) sortHeader: document.getElementById("manage-account-liderados-available-sort-nome"),
); searchPrincipal: document.getElementById("manage-account-liderados-available-search-nome"),
} searchSecundaria: document.getElementById("manage-account-liderados-available-search-departamento"),
body: document.getElementById("manage-account-liderados-available-body"),
function renderManageAccountLiderados() { footer: document.getElementById("manage-account-liderados-available-footer"),
const filtrados = lideradosFiltrados(); acaoBtn: document.getElementById("manage-account-liderados-available-link-btn"),
manageAccountLideradosChecklist.innerHTML = filtrados.length },
? filtrados linked: {
.map( selectAll: document.getElementById("manage-account-liderados-linked-select-all"),
(u) => ` sortHeader: document.getElementById("manage-account-liderados-linked-sort-nome"),
<label class="checklist-item"> searchPrincipal: document.getElementById("manage-account-liderados-linked-search-nome"),
<input type="checkbox" value="${u.id}" ${lideradosSelecionados.has(u.id) ? "checked" : ""} /> searchSecundaria: document.getElementById("manage-account-liderados-linked-search-departamento"),
<span class="checklist-item__info"> body: document.getElementById("manage-account-liderados-linked-body"),
<span class="checklist-item__nome">${u.nome}</span> footer: document.getElementById("manage-account-liderados-linked-footer"),
<span class="checklist-item__departamento">${departamentosTexto(u) || "—"}</span> acaoBtn: document.getElementById("manage-account-liderados-linked-unlink-btn"),
</span> },
</label>` secundariaValor: departamentosTexto,
)
.join("")
: `<p class="checklist-empty">Nenhum usuário encontrado.</p>`;
const todosMarcados = filtrados.length > 0 && filtrados.every((u) => lideradosSelecionados.has(u.id));
const algumMarcado = filtrados.some((u) => lideradosSelecionados.has(u.id));
manageAccountLideradosSelectAll.disabled = filtrados.length === 0;
manageAccountLideradosSelectAll.checked = todosMarcados;
manageAccountLideradosSelectAll.indeterminate = !todosMarcados && algumMarcado;
}
manageAccountLideradosChecklist.addEventListener("change", (event) => {
const checkbox = event.target.closest('input[type="checkbox"]');
if (!checkbox) return;
const id = Number(checkbox.value);
if (checkbox.checked) lideradosSelecionados.add(id);
else lideradosSelecionados.delete(id);
renderManageAccountLiderados();
});
manageAccountLideradosSearch.addEventListener("input", renderManageAccountLiderados);
manageAccountLideradosSelectAll.addEventListener("change", () => {
const filtrados = lideradosFiltrados();
if (manageAccountLideradosSelectAll.checked) {
filtrados.forEach((u) => lideradosSelecionados.add(u.id));
} else {
filtrados.forEach((u) => lideradosSelecionados.delete(u.id));
}
renderManageAccountLiderados();
}); });
manageAccountBtn.addEventListener("click", async () => { manageAccountBtn.addEventListener("click", async () => {
@ -134,16 +99,16 @@ document.addEventListener("DOMContentLoaded", async () => {
if (me.lideranca) { if (me.lideranca) {
manageAccountLideradosField.hidden = false; manageAccountLideradosField.hidden = false;
manageAccountSaveBtn.hidden = false; manageAccountSaveBtn.hidden = false;
manageAccountLideradosSearch.value = ""; manageAccountModalCard.classList.add("modal-card--wide");
lideradosCandidatos = (await pidApiRequest("/usuarios-resumo/")) const candidatos = (await pidApiRequest("/usuarios-resumo/"))
.filter((u) => u.id !== me.id) .filter((u) => u.id !== me.id)
.slice() .slice()
.sort((a, b) => a.nome.localeCompare(b.nome)); .sort((a, b) => a.nome.localeCompare(b.nome));
lideradosSelecionados = new Set((me.liderados || []).map((u) => u.id)); seletorLiderados.setDados(candidatos, (me.liderados || []).map((u) => u.id));
renderManageAccountLiderados();
} else { } else {
manageAccountLideradosField.hidden = true; manageAccountLideradosField.hidden = true;
manageAccountSaveBtn.hidden = true; manageAccountSaveBtn.hidden = true;
manageAccountModalCard.classList.remove("modal-card--wide");
} }
manageAccountModal.hidden = false; manageAccountModal.hidden = false;
}); });
@ -153,7 +118,7 @@ document.addEventListener("DOMContentLoaded", async () => {
}); });
manageAccountSaveBtn.addEventListener("click", async () => { manageAccountSaveBtn.addEventListener("click", async () => {
const liderados = Array.from(lideradosSelecionados); const liderados = seletorLiderados.getVinculadosIds();
try { try {
const atualizados = await pidApiRequest("/me/liderados/", { method: "PATCH", body: { liderados } }); const atualizados = await pidApiRequest("/me/liderados/", { method: "PATCH", body: { liderados } });
me.liderados = atualizados; me.liderados = atualizados;

152
static/js/dual-select.js Normal file
View File

@ -0,0 +1,152 @@
// Widget genérico de vinculação em duas tabelas — não vinculados (esquerda)
// e vinculados (direita), com checkbox de seleção múltipla, busca por coluna,
// ordenação por nome e um botão de ação em lote por painel ("Vincular"/
// "Desvincular"). Mesmo espírito visual/de interação do painel "Usuários do
// Escritório" de perfis-acesso.html (profiles.js), mas genérico o bastante
// pra rodar em qualquer shell — hoje reaproveitado por "Usuários sob
// liderança" em usuarios.html (users-admin.js) e no modal "Gerenciar
// Usuário" (account.js, presente em todo shell).
//
// A vinculação aqui é só em memória — quem chama decide quando persistir
// (chamando getVinculadosIds() no momento de salvar), não dispara nenhuma
// requisição por conta própria.
//
// config = {
// available: { selectAll, sortHeader, searchPrincipal, searchSecundaria, body, footer, acaoBtn },
// linked: { ...mesmo formato... },
// secundariaValor: (candidato) => string,
// }
// candidato = { id, nome, ...outros campos usados por secundariaValor }
function pidCriarSeletorDuplo(config) {
const paineis = { available: config.available, linked: config.linked };
const secundariaValor = config.secundariaValor || (() => "");
const estado = {
available: { sortDir: 1, selected: new Set() },
linked: { sortDir: 1, selected: new Set() },
};
let candidatos = [];
let vinculadosIds = new Set();
function secundariaTexto(candidato) {
return secundariaValor(candidato) || "—";
}
function listaPara(kind) {
return kind === "linked"
? candidatos.filter((c) => vinculadosIds.has(c.id))
: candidatos.filter((c) => !vinculadosIds.has(c.id));
}
function filtradosOrdenados(kind) {
const el = paineis[kind];
const state = estado[kind];
const termoNome = el.searchPrincipal.value.trim().toLowerCase();
const termoSecundaria = el.searchSecundaria.value.trim().toLowerCase();
return listaPara(kind)
.filter((c) => !termoNome || c.nome.toLowerCase().includes(termoNome))
.filter((c) => !termoSecundaria || secundariaTexto(c).toLowerCase().includes(termoSecundaria))
.slice()
.sort((a, b) => a.nome.localeCompare(b.nome, "pt-BR", { sensitivity: "base" }) * state.sortDir);
}
function renderPainel(kind) {
const el = paineis[kind];
const state = estado[kind];
// Poda a seleção de ids que saíram da lista (ex.: acabou de ser
// vinculado/desvinculado e migrou pro outro painel).
const idsAtuais = new Set(listaPara(kind).map((c) => c.id));
Array.from(state.selected).forEach((id) => {
if (!idsAtuais.has(id)) state.selected.delete(id);
});
const filtrados = filtradosOrdenados(kind);
el.body.innerHTML = filtrados.length
? filtrados
.map(
(c) => `
<tr>
<td><input type="checkbox" data-dual-select="${c.id}" ${state.selected.has(c.id) ? "checked" : ""} /></td>
<td>${c.nome}</td>
<td>${secundariaTexto(c)}</td>
</tr>`
)
.join("")
: `<tr class="dual-select__empty-row"><td colspan="3">Nenhum usuário encontrado.</td></tr>`;
const selecionadosVisiveis = filtrados.filter((c) => state.selected.has(c.id)).length;
el.selectAll.disabled = filtrados.length === 0;
el.selectAll.checked = filtrados.length > 0 && selecionadosVisiveis === filtrados.length;
el.selectAll.indeterminate = selecionadosVisiveis > 0 && selecionadosVisiveis < filtrados.length;
el.sortHeader.classList.toggle("is-sort-asc", state.sortDir === 1);
el.sortHeader.classList.toggle("is-sort-desc", state.sortDir === -1);
el.footer.textContent = `${filtrados.length} ${filtrados.length === 1 ? "usuário" : "usuários"}`;
el.acaoBtn.disabled = state.selected.size === 0;
}
function renderAmbos() {
renderPainel("available");
renderPainel("linked");
}
function mover(kind) {
const state = estado[kind];
const ids = Array.from(state.selected);
if (!ids.length) return;
ids.forEach((id) => {
if (kind === "available") vinculadosIds.add(id);
else vinculadosIds.delete(id);
});
state.selected.clear();
renderAmbos();
}
["available", "linked"].forEach((kind) => {
const el = paineis[kind];
el.searchPrincipal.addEventListener("input", () => renderPainel(kind));
el.searchSecundaria.addEventListener("input", () => renderPainel(kind));
el.sortHeader.addEventListener("click", () => {
estado[kind].sortDir *= -1;
renderPainel(kind);
});
el.selectAll.addEventListener("change", () => {
const filtrados = filtradosOrdenados(kind);
filtrados.forEach((c) => {
if (el.selectAll.checked) estado[kind].selected.add(c.id);
else estado[kind].selected.delete(c.id);
});
renderPainel(kind);
});
el.body.addEventListener("change", (event) => {
const checkbox = event.target.closest("[data-dual-select]");
if (!checkbox) return;
const id = Number(checkbox.getAttribute("data-dual-select"));
if (checkbox.checked) estado[kind].selected.add(id);
else estado[kind].selected.delete(id);
renderPainel(kind);
});
el.acaoBtn.addEventListener("click", () => mover(kind));
});
function setDados(novosCandidatos, vinculadosIniciais) {
candidatos = novosCandidatos;
vinculadosIds = new Set(vinculadosIniciais);
["available", "linked"].forEach((kind) => {
estado[kind].selected.clear();
estado[kind].sortDir = 1;
paineis[kind].searchPrincipal.value = "";
paineis[kind].searchSecundaria.value = "";
});
renderAmbos();
}
function getVinculadosIds() {
return Array.from(vinculadosIds);
}
return { setDados, getVinculadosIds };
}

View File

@ -150,9 +150,33 @@ document.addEventListener("DOMContentLoaded", async () => {
const moduleListEl = document.getElementById("pa-module-list"); const moduleListEl = document.getElementById("pa-module-list");
const treePanel = document.getElementById("pa-tree-panel"); const treePanel = document.getElementById("pa-tree-panel");
const treeSearchInput = document.getElementById("pa-tree-search-input"); const treeSearchInput = document.getElementById("pa-tree-search-input");
const usersAddSelect = document.getElementById("pa-user-add-select"); const usersPanelsEl = {
const usersAddBtn = document.getElementById("pa-user-add-btn"); available: {
const usersListEl = document.getElementById("pa-user-list"); refreshBtn: document.getElementById("pa-users-available-refresh-btn"),
linkBtn: document.getElementById("pa-users-link-btn"),
selectAll: document.getElementById("pa-users-available-select-all"),
sortHeader: document.getElementById("pa-users-available-sort-nome"),
searchNome: document.getElementById("pa-users-available-search-nome"),
searchEmail: document.getElementById("pa-users-available-search-email"),
body: document.getElementById("pa-users-available-body"),
footer: document.getElementById("pa-users-available-footer"),
},
linked: {
refreshBtn: document.getElementById("pa-users-linked-refresh-btn"),
linkBtn: document.getElementById("pa-users-unlink-btn"),
selectAll: document.getElementById("pa-users-linked-select-all"),
sortHeader: document.getElementById("pa-users-linked-sort-nome"),
searchNome: document.getElementById("pa-users-linked-search-nome"),
searchEmail: document.getElementById("pa-users-linked-search-email"),
body: document.getElementById("pa-users-linked-body"),
footer: document.getElementById("pa-users-linked-footer"),
},
};
const usersDualState = {
available: { sortDir: 1, selected: new Set() },
linked: { sortDir: 1, selected: new Set() },
};
let usuariosCacheAtual = [];
const appSearchBtn = document.getElementById("pa-app-search-btn"); const appSearchBtn = document.getElementById("pa-app-search-btn");
const appSearchModal = document.getElementById("pa-app-search-modal"); const appSearchModal = document.getElementById("pa-app-search-modal");
const appSearchCloseBtn = document.getElementById("pa-app-search-close-btn"); const appSearchCloseBtn = document.getElementById("pa-app-search-close-btn");
@ -284,36 +308,84 @@ document.addEventListener("DOMContentLoaded", async () => {
`; `;
} }
async function renderUsers() { function usersDualFiltrados(kind, lista) {
const usuarios = await pidFetchUsuarios(); const el = usersPanelsEl[kind];
const isLinked = (u) => u.perfis.some((p) => p.codigo === draftProfile.codigo); const state = usersDualState[kind];
const linked = usuarios.filter(isLinked); const termNome = el.searchNome.value.trim().toLowerCase();
const unlinked = usuarios.filter((u) => !isLinked(u)); const termEmail = el.searchEmail.value.trim().toLowerCase();
return lista
.filter((u) => !termNome || u.nome.toLowerCase().includes(termNome))
.filter((u) => !termEmail || (u.email || "").toLowerCase().includes(termEmail))
.slice()
.sort((a, b) => a.nome.localeCompare(b.nome, "pt-BR", { sensitivity: "base" }) * state.sortDir);
}
usersListEl.innerHTML = linked.length function renderUsersPanel(kind, listaCompleta) {
? linked const el = usersPanelsEl[kind];
const state = usersDualState[kind];
// Poda a seleção de ids que saíram da lista (ex.: acabou de ser
// vinculado/desvinculado e migrou pro outro painel).
const idsAtuais = new Set(listaCompleta.map((u) => u.id));
Array.from(state.selected).forEach((id) => {
if (!idsAtuais.has(id)) state.selected.delete(id);
});
const filtrados = usersDualFiltrados(kind, listaCompleta);
el.body.innerHTML = filtrados.length
? filtrados
.map( .map(
(u) => ` (u) => `
<div class="pa-user-row"> <tr>
<span>${u.nome} <span class="pa-user-login">(${u.username})</span></span> <td><input type="checkbox" data-pa-users-select="${u.id}" ${state.selected.has(u.id) ? "checked" : ""} /></td>
<button type="button" class="pa-user-remove" data-pa-user-unlink="${u.id}" aria-label="Remover vínculo"> <td>${u.nome}</td>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12" stroke-linecap="round"/></svg> <td>${u.email || "—"}</td>
</button> </tr>`
</div>`
) )
.join("") .join("")
: `<p class="pa-users-empty">Nenhum usuário vinculado a este perfil.</p>`; : `<tr class="pa-empty-row"><td colspan="3">Nenhum usuário encontrado.</td></tr>`;
usersAddSelect.innerHTML = unlinked.length const selecionadosVisiveis = filtrados.filter((u) => state.selected.has(u.id)).length;
? `<option value="">Selecione um usuário...</option>` + el.selectAll.disabled = filtrados.length === 0;
unlinked el.selectAll.checked = filtrados.length > 0 && selecionadosVisiveis === filtrados.length;
.map((u) => { el.selectAll.indeterminate = selecionadosVisiveis > 0 && selecionadosVisiveis < filtrados.length;
const otherNames = u.perfis.map((p) => p.nome);
const suffix = otherNames.length ? ` — também em: ${otherNames.join(", ")}` : ""; el.sortHeader.classList.toggle("is-sort-asc", state.sortDir === 1);
return `<option value="${u.id}">${u.nome} (${u.username})${suffix}</option>`; el.sortHeader.classList.toggle("is-sort-desc", state.sortDir === -1);
})
.join("") el.footer.textContent = `${filtrados.length} ${filtrados.length === 1 ? "usuário" : "usuários"}`;
: `<option value="">Nenhum usuário disponível</option>`; el.linkBtn.disabled = state.selected.size === 0;
}
function listaParaPainelUsuarios(kind) {
// Usuário inativo não aparece em nenhum dos dois painéis — decisão
// explícita do usuário; inativar já limpa os perfis dele no backend
// (ver _revogar_acesso_se_inativo em serializers.py), então isso é
// sobretudo defensivo pra dados legados/transição.
const ativos = usuariosCacheAtual.filter((u) => u.is_active);
const isLinked = (u) => u.perfis.some((p) => p.codigo === draftProfile.codigo);
return kind === "linked" ? ativos.filter(isLinked) : ativos.filter((u) => !isLinked(u));
}
function renderUsersFromCache() {
renderUsersPanel("available", listaParaPainelUsuarios("available"));
renderUsersPanel("linked", listaParaPainelUsuarios("linked"));
}
async function refreshUsuarios() {
usuariosCacheAtual = await pidFetchUsuarios();
renderUsersFromCache();
}
async function renderUsers() {
["available", "linked"].forEach((kind) => {
usersDualState[kind].selected.clear();
usersDualState[kind].sortDir = 1;
usersPanelsEl[kind].searchNome.value = "";
usersPanelsEl[kind].searchEmail.value = "";
});
await refreshUsuarios();
} }
function renderTabs() { function renderTabs() {
@ -592,30 +664,56 @@ document.addEventListener("DOMContentLoaded", async () => {
} }
}); });
async function linkUser() { // Vincula (available → linked) ou desvincula (linked → available) todos
const id = Number(usersAddSelect.value); // os usuários selecionados no painel de uma vez, em paralelo.
if (!id) return; async function bulkAlterarVinculo(kind, vincular) {
const usuarios = await pidFetchUsuarios(); const state = usersDualState[kind];
const account = usuarios.find((u) => u.id === id); const ids = Array.from(state.selected);
if (!account) return; if (!ids.length) return;
const codes = account.perfis.map((p) => p.codigo); const el = usersPanelsEl[kind];
if (!codes.includes(draftProfile.codigo)) codes.push(draftProfile.codigo); el.linkBtn.disabled = true;
await pidSetUsuarioPerfis(id, codes); try {
await renderUsers(); await Promise.all(
ids.map((id) => {
const account = usuariosCacheAtual.find((u) => u.id === id);
if (!account) return Promise.resolve();
const codes = account.perfis.map((p) => p.codigo).filter((c) => c !== draftProfile.codigo);
if (vincular) codes.push(draftProfile.codigo);
return pidSetUsuarioPerfis(id, codes);
})
);
} finally {
state.selected.clear();
await refreshUsuarios();
}
} }
usersAddBtn.addEventListener("click", linkUser); ["available", "linked"].forEach((kind) => {
const el = usersPanelsEl[kind];
usersListEl.addEventListener("click", async (event) => { el.refreshBtn.addEventListener("click", refreshUsuarios);
const removeBtn = event.target.closest("[data-pa-user-unlink]"); el.searchNome.addEventListener("input", renderUsersFromCache);
if (!removeBtn) return; el.searchEmail.addEventListener("input", renderUsersFromCache);
const id = Number(removeBtn.getAttribute("data-pa-user-unlink")); el.sortHeader.addEventListener("click", () => {
const usuarios = await pidFetchUsuarios(); usersDualState[kind].sortDir *= -1;
const account = usuarios.find((u) => u.id === id); renderUsersFromCache();
if (!account) return; });
const codes = account.perfis.map((p) => p.codigo).filter((c) => c !== draftProfile.codigo); el.selectAll.addEventListener("change", () => {
await pidSetUsuarioPerfis(id, codes); const filtrados = usersDualFiltrados(kind, listaParaPainelUsuarios(kind));
await renderUsers(); filtrados.forEach((u) => {
if (el.selectAll.checked) usersDualState[kind].selected.add(u.id);
else usersDualState[kind].selected.delete(u.id);
});
renderUsersFromCache();
});
el.body.addEventListener("change", (event) => {
const checkbox = event.target.closest("[data-pa-users-select]");
if (!checkbox) return;
const id = Number(checkbox.getAttribute("data-pa-users-select"));
if (checkbox.checked) usersDualState[kind].selected.add(id);
else usersDualState[kind].selected.delete(id);
renderUsersFromCache();
});
el.linkBtn.addEventListener("click", () => bulkAlterarVinculo(kind, kind === "available"));
}); });
saveBtn.addEventListener("click", async () => { saveBtn.addEventListener("click", async () => {

View File

@ -30,6 +30,9 @@ document.addEventListener("DOMContentLoaded", async () => {
const searchInput = document.getElementById("ua-search-input"); const searchInput = document.getElementById("ua-search-input");
const statusFiltros = document.getElementById("ua-status-filtros"); const statusFiltros = document.getElementById("ua-status-filtros");
let statusFiltro = "ativos"; let statusFiltro = "ativos";
const sortHeaders = Array.from(document.querySelectorAll("#ua-table th[data-sort]"));
let sortKey = "nome";
let sortDir = 1;
const newBtn = document.getElementById("ua-new-btn"); const newBtn = document.getElementById("ua-new-btn");
const backBtn = document.getElementById("ua-back-btn"); const backBtn = document.getElementById("ua-back-btn");
const cancelBtn = document.getElementById("ua-cancel-btn"); const cancelBtn = document.getElementById("ua-cancel-btn");
@ -54,9 +57,32 @@ document.addEventListener("DOMContentLoaded", async () => {
const ativoInput = document.getElementById("ua-ativo"); const ativoInput = document.getElementById("ua-ativo");
const liderancaInput = document.getElementById("ua-lideranca"); const liderancaInput = document.getElementById("ua-lideranca");
const lideradosField = document.getElementById("ua-liderados-field"); const lideradosField = document.getElementById("ua-liderados-field");
const lideradosSearchInput = document.getElementById("ua-liderados-search");
const lideradosSelectAllInput = document.getElementById("ua-liderados-select-all"); function departamentosTexto(usuario) {
const lideradosChecklist = document.getElementById("ua-liderados-checklist"); return (usuario.departamentos || []).map((d) => d.nome).join(", ");
}
const seletorLiderados = pidCriarSeletorDuplo({
available: {
selectAll: document.getElementById("ua-liderados-available-select-all"),
sortHeader: document.getElementById("ua-liderados-available-sort-nome"),
searchPrincipal: document.getElementById("ua-liderados-available-search-nome"),
searchSecundaria: document.getElementById("ua-liderados-available-search-departamento"),
body: document.getElementById("ua-liderados-available-body"),
footer: document.getElementById("ua-liderados-available-footer"),
acaoBtn: document.getElementById("ua-liderados-available-link-btn"),
},
linked: {
selectAll: document.getElementById("ua-liderados-linked-select-all"),
sortHeader: document.getElementById("ua-liderados-linked-sort-nome"),
searchPrincipal: document.getElementById("ua-liderados-linked-search-nome"),
searchSecundaria: document.getElementById("ua-liderados-linked-search-departamento"),
body: document.getElementById("ua-liderados-linked-body"),
footer: document.getElementById("ua-liderados-linked-footer"),
acaoBtn: document.getElementById("ua-liderados-linked-unlink-btn"),
},
secundariaValor: departamentosTexto,
});
liderancaInput.addEventListener("change", () => { liderancaInput.addEventListener("change", () => {
lideradosField.hidden = !liderancaInput.checked; lideradosField.hidden = !liderancaInput.checked;
@ -68,13 +94,49 @@ document.addEventListener("DOMContentLoaded", async () => {
return perfis.length ? perfis.map((p) => p.nome).join(", ") : "Sem perfil vinculado"; return perfis.length ? perfis.map((p) => p.nome).join(", ") : "Sem perfil vinculado";
} }
// Compara duas strings tratando conteúdo numérico como número (ex.:
// "92" < "150") e caindo pra comparação alfabética quando não são os dois
// numéricos — mesmo critério de comparaValoresLista() em
// importacao-plano-saude.js.
function comparaValoresUsuario(a, b) {
const an = Number(a);
const bn = Number(b);
const ambosNumeros = a !== "" && b !== "" && !Number.isNaN(an) && !Number.isNaN(bn);
if (ambosNumeros) return an - bn;
return String(a).localeCompare(String(b), "pt-BR", { sensitivity: "base" });
}
const PID_UA_SORT_VALOR = {
username: (u) => u.username || "",
nome: (u) => u.nome || "",
codigo_folha: (u) => u.codigo_folha || "",
codigo_questor: (u) => u.codigo_questor || "",
codigo_tareffa: (u) => u.codigo_tareffa || "",
codigo_contabit: (u) => u.codigo_contabit || "",
ramal: (u) => u.ramal || "",
status: (u) => (u.is_active ? 1 : 0),
};
// Célula vazia fica destacada (itálico, cor apagada) — ordenar a coluna
// agrupa quem ainda não tem aquele código/ramal cadastrado.
function celulaCodigo(valor) {
return valor ? valor : '<span class="ua-campo-vazio">—</span>';
}
function renderList() { function renderList() {
const term = searchInput.value.trim().toLowerCase(); const term = searchInput.value.trim().toLowerCase();
const extrator = PID_UA_SORT_VALOR[sortKey];
const rows = users const rows = users
.filter((u) => statusFiltro === "todos" || (statusFiltro === "ativos") === u.is_active) .filter((u) => statusFiltro === "todos" || (statusFiltro === "ativos") === u.is_active)
.filter((u) => !term || u.nome.toLowerCase().includes(term) || u.username.toLowerCase().includes(term)) .filter((u) => !term || u.nome.toLowerCase().includes(term) || u.username.toLowerCase().includes(term))
.slice() .slice()
.sort((a, b) => a.nome.localeCompare(b.nome)); .sort((a, b) => comparaValoresUsuario(extrator(a), extrator(b)) * sortDir);
sortHeaders.forEach((th) => {
const key = th.getAttribute("data-sort");
th.classList.toggle("is-sort-asc", key === sortKey && sortDir === 1);
th.classList.toggle("is-sort-desc", key === sortKey && sortDir === -1);
});
tableBody.innerHTML = rows.length tableBody.innerHTML = rows.length
? rows ? rows
@ -83,6 +145,11 @@ document.addEventListener("DOMContentLoaded", async () => {
<tr> <tr>
<td>${u.username}</td> <td>${u.username}</td>
<td><a href="#" class="pa-name-link" data-ua-edit="${u.id}">${u.nome}</a></td> <td><a href="#" class="pa-name-link" data-ua-edit="${u.id}">${u.nome}</a></td>
<td>${celulaCodigo(u.codigo_folha)}</td>
<td>${celulaCodigo(u.codigo_questor)}</td>
<td>${celulaCodigo(u.codigo_tareffa)}</td>
<td>${celulaCodigo(u.codigo_contabit)}</td>
<td>${celulaCodigo(u.ramal)}</td>
<td>${profileNomes(u.perfis)}</td> <td>${profileNomes(u.perfis)}</td>
<td><span class="status-pill ${u.is_active ? "status-pill--ativo" : "status-pill--inativo"}">${u.is_active ? "Ativo" : "Inativo"}</span></td> <td><span class="status-pill ${u.is_active ? "status-pill--ativo" : "status-pill--inativo"}">${u.is_active ? "Ativo" : "Inativo"}</span></td>
<td> <td>
@ -101,7 +168,7 @@ document.addEventListener("DOMContentLoaded", async () => {
</tr>` </tr>`
) )
.join("") .join("")
: `<tr class="pa-empty-row"><td colspan="5">Nenhum usuário encontrado.</td></tr>`; : `<tr class="pa-empty-row"><td colspan="10">Nenhum usuário encontrado.</td></tr>`;
footerCount.textContent = `${rows.length} ${rows.length === 1 ? "item" : "itens"}`; footerCount.textContent = `${rows.length} ${rows.length === 1 ? "item" : "itens"}`;
} }
@ -119,79 +186,21 @@ document.addEventListener("DOMContentLoaded", async () => {
.join(""); .join("");
} }
let lideradosCandidatos = [];
let lideradosSelecionados = new Set();
function departamentosTexto(usuario) {
return (usuario.departamentos || []).map((d) => d.nome).join(", ");
}
function lideradosFiltrados() {
const termo = lideradosSearchInput.value.trim().toLowerCase();
return lideradosCandidatos.filter(
(u) => !termo || u.nome.toLowerCase().includes(termo) || departamentosTexto(u).toLowerCase().includes(termo)
);
}
function renderLideradosChecklist() {
const filtrados = lideradosFiltrados();
lideradosChecklist.innerHTML = filtrados.length
? filtrados
.map(
(u) => `
<label class="checklist-item">
<input type="checkbox" value="${u.id}" ${lideradosSelecionados.has(u.id) ? "checked" : ""} />
<span class="checklist-item__info">
<span class="checklist-item__nome">${u.nome}</span>
<span class="checklist-item__departamento">${departamentosTexto(u) || "—"}</span>
</span>
</label>`
)
.join("")
: `<p class="checklist-empty">Nenhum usuário encontrado.</p>`;
const todosMarcados = filtrados.length > 0 && filtrados.every((u) => lideradosSelecionados.has(u.id));
const algumMarcado = filtrados.some((u) => lideradosSelecionados.has(u.id));
lideradosSelectAllInput.disabled = filtrados.length === 0;
lideradosSelectAllInput.checked = todosMarcados;
lideradosSelectAllInput.indeterminate = !todosMarcados && algumMarcado;
}
function fillLideradosChecklist(selectedIds, excludeId) { function fillLideradosChecklist(selectedIds, excludeId) {
lideradosCandidatos = users // Usuário inativo não entra como candidato (nem aparece do lado
.filter((u) => u.id !== excludeId) // vinculado) — mesma decisão do widget "Usuários do Escritório" de
// Perfis de Acesso (ver profiles.js).
const candidatos = users
.filter((u) => u.id !== excludeId && u.is_active)
.slice() .slice()
.sort((a, b) => a.nome.localeCompare(b.nome)); .sort((a, b) => a.nome.localeCompare(b.nome));
lideradosSelecionados = new Set(selectedIds); seletorLiderados.setDados(candidatos, selectedIds);
lideradosSearchInput.value = "";
renderLideradosChecklist();
} }
function selectedLideradosIds() { function selectedLideradosIds() {
return Array.from(lideradosSelecionados); return seletorLiderados.getVinculadosIds();
} }
lideradosChecklist.addEventListener("change", (event) => {
const checkbox = event.target.closest('input[type="checkbox"]');
if (!checkbox) return;
const id = Number(checkbox.value);
if (checkbox.checked) lideradosSelecionados.add(id);
else lideradosSelecionados.delete(id);
renderLideradosChecklist();
});
lideradosSearchInput.addEventListener("input", renderLideradosChecklist);
lideradosSelectAllInput.addEventListener("change", () => {
const filtrados = lideradosFiltrados();
if (lideradosSelectAllInput.checked) {
filtrados.forEach((u) => lideradosSelecionados.add(u.id));
} else {
filtrados.forEach((u) => lideradosSelecionados.delete(u.id));
}
renderLideradosChecklist();
});
let departamentosCache = []; let departamentosCache = [];
async function fillDepartamentoChecklist(selectedIds) { async function fillDepartamentoChecklist(selectedIds) {
@ -284,6 +293,18 @@ document.addEventListener("DOMContentLoaded", async () => {
} }
searchInput.addEventListener("input", renderList); searchInput.addEventListener("input", renderList);
sortHeaders.forEach((th) => {
th.addEventListener("click", () => {
const key = th.getAttribute("data-sort");
if (sortKey === key) {
sortDir *= -1;
} else {
sortKey = key;
sortDir = 1;
}
renderList();
});
});
statusFiltros.addEventListener("click", (event) => { statusFiltros.addEventListener("click", (event) => {
const chip = event.target.closest("[data-ua-status]"); const chip = event.target.closest("[data-ua-status]");
if (!chip) return; if (!chip) return;

View File

@ -377,19 +377,62 @@
</div> </div>
<div class="modal-overlay" id="manage-account-modal" hidden> <div class="modal-overlay" id="manage-account-modal" hidden>
<div class="modal-card"> <div class="modal-card" id="manage-account-modal-card">
<h2 class="modal-card__title">Gerenciar Usuário</h2> <h2 class="modal-card__title">Gerenciar Usuário</h2>
<div class="modal-field"> <div class="modal-field">
<button type="button" class="btn-outline" id="manage-account-change-password-btn">Alterar senha</button> <button type="button" class="btn-outline" id="manage-account-change-password-btn">Alterar senha</button>
</div> </div>
<div class="modal-field" id="manage-account-liderados-field" hidden> <div class="modal-field" id="manage-account-liderados-field" hidden>
<label>Usuários sob sua liderança</label> <label>Usuários sob sua liderança</label>
<input type="text" id="manage-account-liderados-search" class="checklist-search" placeholder="Buscar por nome ou departamento..." autocomplete="off" /> <div class="dual-select">
<label class="modal-checkbox checklist-select-all"> <div class="dual-select__panel">
<input type="checkbox" id="manage-account-liderados-select-all" /> <div class="dual-select__panel-header">
Marcar todos os resultados da busca <button type="button" class="btn-solid" id="manage-account-liderados-available-link-btn" disabled>Vincular</button>
</label> </div>
<div class="checklist-box" id="manage-account-liderados-checklist"></div> <div class="dual-select__table-wrap">
<table class="dual-select__table">
<thead>
<tr>
<th><input type="checkbox" id="manage-account-liderados-available-select-all" aria-label="Selecionar todos" /></th>
<th class="dual-select__sort" id="manage-account-liderados-available-sort-nome">Nome <span class="dual-select__sort-icon">↕</span></th>
<th>Departamento</th>
</tr>
<tr class="dual-select__filters">
<th></th>
<th><input type="text" id="manage-account-liderados-available-search-nome" placeholder="Buscar por nome..." autocomplete="off" /></th>
<th><input type="text" id="manage-account-liderados-available-search-departamento" placeholder="Buscar por departamento..." autocomplete="off" /></th>
</tr>
</thead>
<tbody id="manage-account-liderados-available-body"></tbody>
</table>
</div>
<div class="dual-select__footer" id="manage-account-liderados-available-footer"></div>
</div>
<div class="dual-select__panel">
<div class="dual-select__panel-header">
<button type="button" class="btn-danger-outline" id="manage-account-liderados-linked-unlink-btn" disabled>Desvincular</button>
</div>
<div class="dual-select__table-wrap">
<table class="dual-select__table">
<thead>
<tr>
<th><input type="checkbox" id="manage-account-liderados-linked-select-all" aria-label="Selecionar todos" /></th>
<th class="dual-select__sort" id="manage-account-liderados-linked-sort-nome">Nome <span class="dual-select__sort-icon">↕</span></th>
<th>Departamento</th>
</tr>
<tr class="dual-select__filters">
<th></th>
<th><input type="text" id="manage-account-liderados-linked-search-nome" placeholder="Buscar por nome..." autocomplete="off" /></th>
<th><input type="text" id="manage-account-liderados-linked-search-departamento" placeholder="Buscar por departamento..." autocomplete="off" /></th>
</tr>
</thead>
<tbody id="manage-account-liderados-linked-body"></tbody>
</table>
</div>
<div class="dual-select__footer" id="manage-account-liderados-linked-footer"></div>
</div>
</div>
<p class="modal-error" id="manage-account-liderados-error"></p> <p class="modal-error" id="manage-account-liderados-error"></p>
</div> </div>
<div class="modal-actions"> <div class="modal-actions">
@ -454,6 +497,7 @@
</div> </div>
<script src="{% static 'js/api.js' %}"></script> <script src="{% static 'js/api.js' %}"></script>
<script src="{% static 'js/dual-select.js' %}"></script>
<script src="{% static 'js/profiles.js' %}"></script> <script src="{% static 'js/profiles.js' %}"></script>
<script src="{% static 'js/auth.js' %}"></script> <script src="{% static 'js/auth.js' %}"></script>
<script src="{% static 'js/access.js' %}"></script> <script src="{% static 'js/access.js' %}"></script>

View File

@ -513,19 +513,62 @@
</div> </div>
<div class="modal-overlay" id="manage-account-modal" hidden> <div class="modal-overlay" id="manage-account-modal" hidden>
<div class="modal-card"> <div class="modal-card" id="manage-account-modal-card">
<h2 class="modal-card__title">Gerenciar Usuário</h2> <h2 class="modal-card__title">Gerenciar Usuário</h2>
<div class="modal-field"> <div class="modal-field">
<button type="button" class="btn-outline" id="manage-account-change-password-btn">Alterar senha</button> <button type="button" class="btn-outline" id="manage-account-change-password-btn">Alterar senha</button>
</div> </div>
<div class="modal-field" id="manage-account-liderados-field" hidden> <div class="modal-field" id="manage-account-liderados-field" hidden>
<label>Usuários sob sua liderança</label> <label>Usuários sob sua liderança</label>
<input type="text" id="manage-account-liderados-search" class="checklist-search" placeholder="Buscar por nome ou departamento..." autocomplete="off" /> <div class="dual-select">
<label class="modal-checkbox checklist-select-all"> <div class="dual-select__panel">
<input type="checkbox" id="manage-account-liderados-select-all" /> <div class="dual-select__panel-header">
Marcar todos os resultados da busca <button type="button" class="btn-solid" id="manage-account-liderados-available-link-btn" disabled>Vincular</button>
</label> </div>
<div class="checklist-box" id="manage-account-liderados-checklist"></div> <div class="dual-select__table-wrap">
<table class="dual-select__table">
<thead>
<tr>
<th><input type="checkbox" id="manage-account-liderados-available-select-all" aria-label="Selecionar todos" /></th>
<th class="dual-select__sort" id="manage-account-liderados-available-sort-nome">Nome <span class="dual-select__sort-icon">↕</span></th>
<th>Departamento</th>
</tr>
<tr class="dual-select__filters">
<th></th>
<th><input type="text" id="manage-account-liderados-available-search-nome" placeholder="Buscar por nome..." autocomplete="off" /></th>
<th><input type="text" id="manage-account-liderados-available-search-departamento" placeholder="Buscar por departamento..." autocomplete="off" /></th>
</tr>
</thead>
<tbody id="manage-account-liderados-available-body"></tbody>
</table>
</div>
<div class="dual-select__footer" id="manage-account-liderados-available-footer"></div>
</div>
<div class="dual-select__panel">
<div class="dual-select__panel-header">
<button type="button" class="btn-danger-outline" id="manage-account-liderados-linked-unlink-btn" disabled>Desvincular</button>
</div>
<div class="dual-select__table-wrap">
<table class="dual-select__table">
<thead>
<tr>
<th><input type="checkbox" id="manage-account-liderados-linked-select-all" aria-label="Selecionar todos" /></th>
<th class="dual-select__sort" id="manage-account-liderados-linked-sort-nome">Nome <span class="dual-select__sort-icon">↕</span></th>
<th>Departamento</th>
</tr>
<tr class="dual-select__filters">
<th></th>
<th><input type="text" id="manage-account-liderados-linked-search-nome" placeholder="Buscar por nome..." autocomplete="off" /></th>
<th><input type="text" id="manage-account-liderados-linked-search-departamento" placeholder="Buscar por departamento..." autocomplete="off" /></th>
</tr>
</thead>
<tbody id="manage-account-liderados-linked-body"></tbody>
</table>
</div>
<div class="dual-select__footer" id="manage-account-liderados-linked-footer"></div>
</div>
</div>
<p class="modal-error" id="manage-account-liderados-error"></p> <p class="modal-error" id="manage-account-liderados-error"></p>
</div> </div>
<div class="modal-actions"> <div class="modal-actions">
@ -590,6 +633,7 @@
</div> </div>
<script src="{% static 'js/api.js' %}"></script> <script src="{% static 'js/api.js' %}"></script>
<script src="{% static 'js/dual-select.js' %}"></script>
<script src="{% static 'js/profiles.js' %}"></script> <script src="{% static 'js/profiles.js' %}"></script>
<script src="{% static 'js/auth.js' %}"></script> <script src="{% static 'js/auth.js' %}"></script>
<script src="{% static 'js/access.js' %}"></script> <script src="{% static 'js/access.js' %}"></script>

View File

@ -499,19 +499,62 @@
</div> </div>
<div class="modal-overlay" id="manage-account-modal" hidden> <div class="modal-overlay" id="manage-account-modal" hidden>
<div class="modal-card"> <div class="modal-card" id="manage-account-modal-card">
<h2 class="modal-card__title">Gerenciar Usuário</h2> <h2 class="modal-card__title">Gerenciar Usuário</h2>
<div class="modal-field"> <div class="modal-field">
<button type="button" class="btn-outline" id="manage-account-change-password-btn">Alterar senha</button> <button type="button" class="btn-outline" id="manage-account-change-password-btn">Alterar senha</button>
</div> </div>
<div class="modal-field" id="manage-account-liderados-field" hidden> <div class="modal-field" id="manage-account-liderados-field" hidden>
<label>Usuários sob sua liderança</label> <label>Usuários sob sua liderança</label>
<input type="text" id="manage-account-liderados-search" class="checklist-search" placeholder="Buscar por nome ou departamento..." autocomplete="off" /> <div class="dual-select">
<label class="modal-checkbox checklist-select-all"> <div class="dual-select__panel">
<input type="checkbox" id="manage-account-liderados-select-all" /> <div class="dual-select__panel-header">
Marcar todos os resultados da busca <button type="button" class="btn-solid" id="manage-account-liderados-available-link-btn" disabled>Vincular</button>
</label> </div>
<div class="checklist-box" id="manage-account-liderados-checklist"></div> <div class="dual-select__table-wrap">
<table class="dual-select__table">
<thead>
<tr>
<th><input type="checkbox" id="manage-account-liderados-available-select-all" aria-label="Selecionar todos" /></th>
<th class="dual-select__sort" id="manage-account-liderados-available-sort-nome">Nome <span class="dual-select__sort-icon">↕</span></th>
<th>Departamento</th>
</tr>
<tr class="dual-select__filters">
<th></th>
<th><input type="text" id="manage-account-liderados-available-search-nome" placeholder="Buscar por nome..." autocomplete="off" /></th>
<th><input type="text" id="manage-account-liderados-available-search-departamento" placeholder="Buscar por departamento..." autocomplete="off" /></th>
</tr>
</thead>
<tbody id="manage-account-liderados-available-body"></tbody>
</table>
</div>
<div class="dual-select__footer" id="manage-account-liderados-available-footer"></div>
</div>
<div class="dual-select__panel">
<div class="dual-select__panel-header">
<button type="button" class="btn-danger-outline" id="manage-account-liderados-linked-unlink-btn" disabled>Desvincular</button>
</div>
<div class="dual-select__table-wrap">
<table class="dual-select__table">
<thead>
<tr>
<th><input type="checkbox" id="manage-account-liderados-linked-select-all" aria-label="Selecionar todos" /></th>
<th class="dual-select__sort" id="manage-account-liderados-linked-sort-nome">Nome <span class="dual-select__sort-icon">↕</span></th>
<th>Departamento</th>
</tr>
<tr class="dual-select__filters">
<th></th>
<th><input type="text" id="manage-account-liderados-linked-search-nome" placeholder="Buscar por nome..." autocomplete="off" /></th>
<th><input type="text" id="manage-account-liderados-linked-search-departamento" placeholder="Buscar por departamento..." autocomplete="off" /></th>
</tr>
</thead>
<tbody id="manage-account-liderados-linked-body"></tbody>
</table>
</div>
<div class="dual-select__footer" id="manage-account-liderados-linked-footer"></div>
</div>
</div>
<p class="modal-error" id="manage-account-liderados-error"></p> <p class="modal-error" id="manage-account-liderados-error"></p>
</div> </div>
<div class="modal-actions"> <div class="modal-actions">
@ -576,6 +619,7 @@
</div> </div>
<script src="{% static 'js/api.js' %}"></script> <script src="{% static 'js/api.js' %}"></script>
<script src="{% static 'js/dual-select.js' %}"></script>
<script src="{% static 'js/profiles.js' %}"></script> <script src="{% static 'js/profiles.js' %}"></script>
<script src="{% static 'js/auth.js' %}"></script> <script src="{% static 'js/auth.js' %}"></script>
<script src="{% static 'js/access.js' %}"></script> <script src="{% static 'js/access.js' %}"></script>

View File

@ -577,19 +577,62 @@
</div> </div>
<div class="modal-overlay" id="manage-account-modal" hidden> <div class="modal-overlay" id="manage-account-modal" hidden>
<div class="modal-card"> <div class="modal-card" id="manage-account-modal-card">
<h2 class="modal-card__title">Gerenciar Usuário</h2> <h2 class="modal-card__title">Gerenciar Usuário</h2>
<div class="modal-field"> <div class="modal-field">
<button type="button" class="btn-outline" id="manage-account-change-password-btn">Alterar senha</button> <button type="button" class="btn-outline" id="manage-account-change-password-btn">Alterar senha</button>
</div> </div>
<div class="modal-field" id="manage-account-liderados-field" hidden> <div class="modal-field" id="manage-account-liderados-field" hidden>
<label>Usuários sob sua liderança</label> <label>Usuários sob sua liderança</label>
<input type="text" id="manage-account-liderados-search" class="checklist-search" placeholder="Buscar por nome ou departamento..." autocomplete="off" /> <div class="dual-select">
<label class="modal-checkbox checklist-select-all"> <div class="dual-select__panel">
<input type="checkbox" id="manage-account-liderados-select-all" /> <div class="dual-select__panel-header">
Marcar todos os resultados da busca <button type="button" class="btn-solid" id="manage-account-liderados-available-link-btn" disabled>Vincular</button>
</label> </div>
<div class="checklist-box" id="manage-account-liderados-checklist"></div> <div class="dual-select__table-wrap">
<table class="dual-select__table">
<thead>
<tr>
<th><input type="checkbox" id="manage-account-liderados-available-select-all" aria-label="Selecionar todos" /></th>
<th class="dual-select__sort" id="manage-account-liderados-available-sort-nome">Nome <span class="dual-select__sort-icon">↕</span></th>
<th>Departamento</th>
</tr>
<tr class="dual-select__filters">
<th></th>
<th><input type="text" id="manage-account-liderados-available-search-nome" placeholder="Buscar por nome..." autocomplete="off" /></th>
<th><input type="text" id="manage-account-liderados-available-search-departamento" placeholder="Buscar por departamento..." autocomplete="off" /></th>
</tr>
</thead>
<tbody id="manage-account-liderados-available-body"></tbody>
</table>
</div>
<div class="dual-select__footer" id="manage-account-liderados-available-footer"></div>
</div>
<div class="dual-select__panel">
<div class="dual-select__panel-header">
<button type="button" class="btn-danger-outline" id="manage-account-liderados-linked-unlink-btn" disabled>Desvincular</button>
</div>
<div class="dual-select__table-wrap">
<table class="dual-select__table">
<thead>
<tr>
<th><input type="checkbox" id="manage-account-liderados-linked-select-all" aria-label="Selecionar todos" /></th>
<th class="dual-select__sort" id="manage-account-liderados-linked-sort-nome">Nome <span class="dual-select__sort-icon">↕</span></th>
<th>Departamento</th>
</tr>
<tr class="dual-select__filters">
<th></th>
<th><input type="text" id="manage-account-liderados-linked-search-nome" placeholder="Buscar por nome..." autocomplete="off" /></th>
<th><input type="text" id="manage-account-liderados-linked-search-departamento" placeholder="Buscar por departamento..." autocomplete="off" /></th>
</tr>
</thead>
<tbody id="manage-account-liderados-linked-body"></tbody>
</table>
</div>
<div class="dual-select__footer" id="manage-account-liderados-linked-footer"></div>
</div>
</div>
<p class="modal-error" id="manage-account-liderados-error"></p> <p class="modal-error" id="manage-account-liderados-error"></p>
</div> </div>
<div class="modal-actions"> <div class="modal-actions">
@ -654,6 +697,7 @@
</div> </div>
<script src="{% static 'js/api.js' %}"></script> <script src="{% static 'js/api.js' %}"></script>
<script src="{% static 'js/dual-select.js' %}"></script>
<script src="{% static 'js/profiles.js' %}"></script> <script src="{% static 'js/profiles.js' %}"></script>
<script src="{% static 'js/auth.js' %}"></script> <script src="{% static 'js/auth.js' %}"></script>
<script src="{% static 'js/access.js' %}"></script> <script src="{% static 'js/access.js' %}"></script>

View File

@ -753,19 +753,62 @@
</div> </div>
<div class="modal-overlay" id="manage-account-modal" hidden> <div class="modal-overlay" id="manage-account-modal" hidden>
<div class="modal-card"> <div class="modal-card" id="manage-account-modal-card">
<h2 class="modal-card__title">Gerenciar Usuário</h2> <h2 class="modal-card__title">Gerenciar Usuário</h2>
<div class="modal-field"> <div class="modal-field">
<button type="button" class="btn-outline" id="manage-account-change-password-btn">Alterar senha</button> <button type="button" class="btn-outline" id="manage-account-change-password-btn">Alterar senha</button>
</div> </div>
<div class="modal-field" id="manage-account-liderados-field" hidden> <div class="modal-field" id="manage-account-liderados-field" hidden>
<label>Usuários sob sua liderança</label> <label>Usuários sob sua liderança</label>
<input type="text" id="manage-account-liderados-search" class="checklist-search" placeholder="Buscar por nome ou departamento..." autocomplete="off" /> <div class="dual-select">
<label class="modal-checkbox checklist-select-all"> <div class="dual-select__panel">
<input type="checkbox" id="manage-account-liderados-select-all" /> <div class="dual-select__panel-header">
Marcar todos os resultados da busca <button type="button" class="btn-solid" id="manage-account-liderados-available-link-btn" disabled>Vincular</button>
</label> </div>
<div class="checklist-box" id="manage-account-liderados-checklist"></div> <div class="dual-select__table-wrap">
<table class="dual-select__table">
<thead>
<tr>
<th><input type="checkbox" id="manage-account-liderados-available-select-all" aria-label="Selecionar todos" /></th>
<th class="dual-select__sort" id="manage-account-liderados-available-sort-nome">Nome <span class="dual-select__sort-icon">↕</span></th>
<th>Departamento</th>
</tr>
<tr class="dual-select__filters">
<th></th>
<th><input type="text" id="manage-account-liderados-available-search-nome" placeholder="Buscar por nome..." autocomplete="off" /></th>
<th><input type="text" id="manage-account-liderados-available-search-departamento" placeholder="Buscar por departamento..." autocomplete="off" /></th>
</tr>
</thead>
<tbody id="manage-account-liderados-available-body"></tbody>
</table>
</div>
<div class="dual-select__footer" id="manage-account-liderados-available-footer"></div>
</div>
<div class="dual-select__panel">
<div class="dual-select__panel-header">
<button type="button" class="btn-danger-outline" id="manage-account-liderados-linked-unlink-btn" disabled>Desvincular</button>
</div>
<div class="dual-select__table-wrap">
<table class="dual-select__table">
<thead>
<tr>
<th><input type="checkbox" id="manage-account-liderados-linked-select-all" aria-label="Selecionar todos" /></th>
<th class="dual-select__sort" id="manage-account-liderados-linked-sort-nome">Nome <span class="dual-select__sort-icon">↕</span></th>
<th>Departamento</th>
</tr>
<tr class="dual-select__filters">
<th></th>
<th><input type="text" id="manage-account-liderados-linked-search-nome" placeholder="Buscar por nome..." autocomplete="off" /></th>
<th><input type="text" id="manage-account-liderados-linked-search-departamento" placeholder="Buscar por departamento..." autocomplete="off" /></th>
</tr>
</thead>
<tbody id="manage-account-liderados-linked-body"></tbody>
</table>
</div>
<div class="dual-select__footer" id="manage-account-liderados-linked-footer"></div>
</div>
</div>
<p class="modal-error" id="manage-account-liderados-error"></p> <p class="modal-error" id="manage-account-liderados-error"></p>
</div> </div>
<div class="modal-actions"> <div class="modal-actions">
@ -830,6 +873,7 @@
</div> </div>
<script src="{% static 'js/api.js' %}"></script> <script src="{% static 'js/api.js' %}"></script>
<script src="{% static 'js/dual-select.js' %}"></script>
<script src="{% static 'js/profiles.js' %}"></script> <script src="{% static 'js/profiles.js' %}"></script>
<script src="{% static 'js/auth.js' %}"></script> <script src="{% static 'js/auth.js' %}"></script>
<script src="{% static 'js/access.js' %}"></script> <script src="{% static 'js/access.js' %}"></script>

View File

@ -363,19 +363,62 @@
</div> </div>
<div class="modal-overlay" id="manage-account-modal" hidden> <div class="modal-overlay" id="manage-account-modal" hidden>
<div class="modal-card"> <div class="modal-card" id="manage-account-modal-card">
<h2 class="modal-card__title">Gerenciar Usuário</h2> <h2 class="modal-card__title">Gerenciar Usuário</h2>
<div class="modal-field"> <div class="modal-field">
<button type="button" class="btn-outline" id="manage-account-change-password-btn">Alterar senha</button> <button type="button" class="btn-outline" id="manage-account-change-password-btn">Alterar senha</button>
</div> </div>
<div class="modal-field" id="manage-account-liderados-field" hidden> <div class="modal-field" id="manage-account-liderados-field" hidden>
<label>Usuários sob sua liderança</label> <label>Usuários sob sua liderança</label>
<input type="text" id="manage-account-liderados-search" class="checklist-search" placeholder="Buscar por nome ou departamento..." autocomplete="off" /> <div class="dual-select">
<label class="modal-checkbox checklist-select-all"> <div class="dual-select__panel">
<input type="checkbox" id="manage-account-liderados-select-all" /> <div class="dual-select__panel-header">
Marcar todos os resultados da busca <button type="button" class="btn-solid" id="manage-account-liderados-available-link-btn" disabled>Vincular</button>
</label> </div>
<div class="checklist-box" id="manage-account-liderados-checklist"></div> <div class="dual-select__table-wrap">
<table class="dual-select__table">
<thead>
<tr>
<th><input type="checkbox" id="manage-account-liderados-available-select-all" aria-label="Selecionar todos" /></th>
<th class="dual-select__sort" id="manage-account-liderados-available-sort-nome">Nome <span class="dual-select__sort-icon">↕</span></th>
<th>Departamento</th>
</tr>
<tr class="dual-select__filters">
<th></th>
<th><input type="text" id="manage-account-liderados-available-search-nome" placeholder="Buscar por nome..." autocomplete="off" /></th>
<th><input type="text" id="manage-account-liderados-available-search-departamento" placeholder="Buscar por departamento..." autocomplete="off" /></th>
</tr>
</thead>
<tbody id="manage-account-liderados-available-body"></tbody>
</table>
</div>
<div class="dual-select__footer" id="manage-account-liderados-available-footer"></div>
</div>
<div class="dual-select__panel">
<div class="dual-select__panel-header">
<button type="button" class="btn-danger-outline" id="manage-account-liderados-linked-unlink-btn" disabled>Desvincular</button>
</div>
<div class="dual-select__table-wrap">
<table class="dual-select__table">
<thead>
<tr>
<th><input type="checkbox" id="manage-account-liderados-linked-select-all" aria-label="Selecionar todos" /></th>
<th class="dual-select__sort" id="manage-account-liderados-linked-sort-nome">Nome <span class="dual-select__sort-icon">↕</span></th>
<th>Departamento</th>
</tr>
<tr class="dual-select__filters">
<th></th>
<th><input type="text" id="manage-account-liderados-linked-search-nome" placeholder="Buscar por nome..." autocomplete="off" /></th>
<th><input type="text" id="manage-account-liderados-linked-search-departamento" placeholder="Buscar por departamento..." autocomplete="off" /></th>
</tr>
</thead>
<tbody id="manage-account-liderados-linked-body"></tbody>
</table>
</div>
<div class="dual-select__footer" id="manage-account-liderados-linked-footer"></div>
</div>
</div>
<p class="modal-error" id="manage-account-liderados-error"></p> <p class="modal-error" id="manage-account-liderados-error"></p>
</div> </div>
<div class="modal-actions"> <div class="modal-actions">
@ -440,6 +483,7 @@
</div> </div>
<script src="{% static 'js/api.js' %}"></script> <script src="{% static 'js/api.js' %}"></script>
<script src="{% static 'js/dual-select.js' %}"></script>
<script src="{% static 'js/profiles.js' %}"></script> <script src="{% static 'js/profiles.js' %}"></script>
<script src="{% static 'js/auth.js' %}"></script> <script src="{% static 'js/auth.js' %}"></script>
<script src="{% static 'js/access.js' %}"></script> <script src="{% static 'js/access.js' %}"></script>

View File

@ -328,11 +328,61 @@
</div> </div>
<div class="pa-tab-panel" data-pa-tab-panel="usuarios" hidden> <div class="pa-tab-panel" data-pa-tab-panel="usuarios" hidden>
<div class="pa-users-add"> <div class="pa-users-dual">
<select id="pa-user-add-select"></select> <div class="pa-users-panel">
<button type="button" class="btn-solid" id="pa-user-add-btn">Vincular</button> <div class="pa-users-panel__header">
<button type="button" class="icon-btn" id="pa-users-available-refresh-btn" aria-label="Atualizar lista">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M23 4v6h-6M1 20v-6h6" stroke-linecap="round" stroke-linejoin="round"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<button type="button" class="btn-solid" id="pa-users-link-btn" disabled>Vincular</button>
</div>
<div class="pa-table-wrap">
<table class="pa-table pa-users-table">
<thead>
<tr>
<th class="pa-users-table__checkbox"><input type="checkbox" id="pa-users-available-select-all" aria-label="Selecionar todos" /></th>
<th class="pa-users-table__sort" id="pa-users-available-sort-nome">Nome Usuário <span class="ua-sort-icon">↕</span></th>
<th>E-mail</th>
</tr>
<tr class="pa-users-table__filters">
<th></th>
<th><input type="text" id="pa-users-available-search-nome" placeholder="Buscar por nome..." autocomplete="off" /></th>
<th><input type="text" id="pa-users-available-search-email" placeholder="Buscar por e-mail..." autocomplete="off" /></th>
</tr>
</thead>
<tbody id="pa-users-available-body"></tbody>
</table>
</div>
<div class="pa-footer" id="pa-users-available-footer"></div>
</div>
<div class="pa-users-panel">
<div class="pa-users-panel__header">
<button type="button" class="icon-btn" id="pa-users-linked-refresh-btn" aria-label="Atualizar lista">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M23 4v6h-6M1 20v-6h6" stroke-linecap="round" stroke-linejoin="round"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<button type="button" class="btn-danger-outline" id="pa-users-unlink-btn" disabled>Desvincular</button>
</div>
<div class="pa-table-wrap">
<table class="pa-table pa-users-table">
<thead>
<tr>
<th class="pa-users-table__checkbox"><input type="checkbox" id="pa-users-linked-select-all" aria-label="Selecionar todos" /></th>
<th class="pa-users-table__sort" id="pa-users-linked-sort-nome">Nome Usuário <span class="ua-sort-icon">↕</span></th>
<th>E-mail</th>
</tr>
<tr class="pa-users-table__filters">
<th></th>
<th><input type="text" id="pa-users-linked-search-nome" placeholder="Buscar por nome..." autocomplete="off" /></th>
<th><input type="text" id="pa-users-linked-search-email" placeholder="Buscar por e-mail..." autocomplete="off" /></th>
</tr>
</thead>
<tbody id="pa-users-linked-body"></tbody>
</table>
</div>
<div class="pa-footer" id="pa-users-linked-footer"></div>
</div>
</div> </div>
<div class="pa-user-list" id="pa-user-list"></div>
</div> </div>
<div class="pa-edit-actions"> <div class="pa-edit-actions">
@ -346,19 +396,62 @@
<div class="modal-overlay" id="manage-account-modal" hidden> <div class="modal-overlay" id="manage-account-modal" hidden>
<div class="modal-card"> <div class="modal-card" id="manage-account-modal-card">
<h2 class="modal-card__title">Gerenciar Usuário</h2> <h2 class="modal-card__title">Gerenciar Usuário</h2>
<div class="modal-field"> <div class="modal-field">
<button type="button" class="btn-outline" id="manage-account-change-password-btn">Alterar senha</button> <button type="button" class="btn-outline" id="manage-account-change-password-btn">Alterar senha</button>
</div> </div>
<div class="modal-field" id="manage-account-liderados-field" hidden> <div class="modal-field" id="manage-account-liderados-field" hidden>
<label>Usuários sob sua liderança</label> <label>Usuários sob sua liderança</label>
<input type="text" id="manage-account-liderados-search" class="checklist-search" placeholder="Buscar por nome ou departamento..." autocomplete="off" /> <div class="dual-select">
<label class="modal-checkbox checklist-select-all"> <div class="dual-select__panel">
<input type="checkbox" id="manage-account-liderados-select-all" /> <div class="dual-select__panel-header">
Marcar todos os resultados da busca <button type="button" class="btn-solid" id="manage-account-liderados-available-link-btn" disabled>Vincular</button>
</label> </div>
<div class="checklist-box" id="manage-account-liderados-checklist"></div> <div class="dual-select__table-wrap">
<table class="dual-select__table">
<thead>
<tr>
<th><input type="checkbox" id="manage-account-liderados-available-select-all" aria-label="Selecionar todos" /></th>
<th class="dual-select__sort" id="manage-account-liderados-available-sort-nome">Nome <span class="dual-select__sort-icon">↕</span></th>
<th>Departamento</th>
</tr>
<tr class="dual-select__filters">
<th></th>
<th><input type="text" id="manage-account-liderados-available-search-nome" placeholder="Buscar por nome..." autocomplete="off" /></th>
<th><input type="text" id="manage-account-liderados-available-search-departamento" placeholder="Buscar por departamento..." autocomplete="off" /></th>
</tr>
</thead>
<tbody id="manage-account-liderados-available-body"></tbody>
</table>
</div>
<div class="dual-select__footer" id="manage-account-liderados-available-footer"></div>
</div>
<div class="dual-select__panel">
<div class="dual-select__panel-header">
<button type="button" class="btn-danger-outline" id="manage-account-liderados-linked-unlink-btn" disabled>Desvincular</button>
</div>
<div class="dual-select__table-wrap">
<table class="dual-select__table">
<thead>
<tr>
<th><input type="checkbox" id="manage-account-liderados-linked-select-all" aria-label="Selecionar todos" /></th>
<th class="dual-select__sort" id="manage-account-liderados-linked-sort-nome">Nome <span class="dual-select__sort-icon">↕</span></th>
<th>Departamento</th>
</tr>
<tr class="dual-select__filters">
<th></th>
<th><input type="text" id="manage-account-liderados-linked-search-nome" placeholder="Buscar por nome..." autocomplete="off" /></th>
<th><input type="text" id="manage-account-liderados-linked-search-departamento" placeholder="Buscar por departamento..." autocomplete="off" /></th>
</tr>
</thead>
<tbody id="manage-account-liderados-linked-body"></tbody>
</table>
</div>
<div class="dual-select__footer" id="manage-account-liderados-linked-footer"></div>
</div>
</div>
<p class="modal-error" id="manage-account-liderados-error"></p> <p class="modal-error" id="manage-account-liderados-error"></p>
</div> </div>
<div class="modal-actions"> <div class="modal-actions">
@ -455,6 +548,7 @@
</div> </div>
<script src="{% static 'js/api.js' %}"></script> <script src="{% static 'js/api.js' %}"></script>
<script src="{% static 'js/dual-select.js' %}"></script>
<script src="{% static 'js/profiles.js' %}"></script> <script src="{% static 'js/profiles.js' %}"></script>
<script src="{% static 'js/auth.js' %}"></script> <script src="{% static 'js/auth.js' %}"></script>
<script src="{% static 'js/access.js' %}"></script> <script src="{% static 'js/access.js' %}"></script>

View File

@ -360,19 +360,62 @@
</div> </div>
<div class="modal-overlay" id="manage-account-modal" hidden> <div class="modal-overlay" id="manage-account-modal" hidden>
<div class="modal-card"> <div class="modal-card" id="manage-account-modal-card">
<h2 class="modal-card__title">Gerenciar Usuário</h2> <h2 class="modal-card__title">Gerenciar Usuário</h2>
<div class="modal-field"> <div class="modal-field">
<button type="button" class="btn-outline" id="manage-account-change-password-btn">Alterar senha</button> <button type="button" class="btn-outline" id="manage-account-change-password-btn">Alterar senha</button>
</div> </div>
<div class="modal-field" id="manage-account-liderados-field" hidden> <div class="modal-field" id="manage-account-liderados-field" hidden>
<label>Usuários sob sua liderança</label> <label>Usuários sob sua liderança</label>
<input type="text" id="manage-account-liderados-search" class="checklist-search" placeholder="Buscar por nome ou departamento..." autocomplete="off" /> <div class="dual-select">
<label class="modal-checkbox checklist-select-all"> <div class="dual-select__panel">
<input type="checkbox" id="manage-account-liderados-select-all" /> <div class="dual-select__panel-header">
Marcar todos os resultados da busca <button type="button" class="btn-solid" id="manage-account-liderados-available-link-btn" disabled>Vincular</button>
</label> </div>
<div class="checklist-box" id="manage-account-liderados-checklist"></div> <div class="dual-select__table-wrap">
<table class="dual-select__table">
<thead>
<tr>
<th><input type="checkbox" id="manage-account-liderados-available-select-all" aria-label="Selecionar todos" /></th>
<th class="dual-select__sort" id="manage-account-liderados-available-sort-nome">Nome <span class="dual-select__sort-icon">↕</span></th>
<th>Departamento</th>
</tr>
<tr class="dual-select__filters">
<th></th>
<th><input type="text" id="manage-account-liderados-available-search-nome" placeholder="Buscar por nome..." autocomplete="off" /></th>
<th><input type="text" id="manage-account-liderados-available-search-departamento" placeholder="Buscar por departamento..." autocomplete="off" /></th>
</tr>
</thead>
<tbody id="manage-account-liderados-available-body"></tbody>
</table>
</div>
<div class="dual-select__footer" id="manage-account-liderados-available-footer"></div>
</div>
<div class="dual-select__panel">
<div class="dual-select__panel-header">
<button type="button" class="btn-danger-outline" id="manage-account-liderados-linked-unlink-btn" disabled>Desvincular</button>
</div>
<div class="dual-select__table-wrap">
<table class="dual-select__table">
<thead>
<tr>
<th><input type="checkbox" id="manage-account-liderados-linked-select-all" aria-label="Selecionar todos" /></th>
<th class="dual-select__sort" id="manage-account-liderados-linked-sort-nome">Nome <span class="dual-select__sort-icon">↕</span></th>
<th>Departamento</th>
</tr>
<tr class="dual-select__filters">
<th></th>
<th><input type="text" id="manage-account-liderados-linked-search-nome" placeholder="Buscar por nome..." autocomplete="off" /></th>
<th><input type="text" id="manage-account-liderados-linked-search-departamento" placeholder="Buscar por departamento..." autocomplete="off" /></th>
</tr>
</thead>
<tbody id="manage-account-liderados-linked-body"></tbody>
</table>
</div>
<div class="dual-select__footer" id="manage-account-liderados-linked-footer"></div>
</div>
</div>
<p class="modal-error" id="manage-account-liderados-error"></p> <p class="modal-error" id="manage-account-liderados-error"></p>
</div> </div>
<div class="modal-actions"> <div class="modal-actions">
@ -437,6 +480,7 @@
</div> </div>
<script src="{% static 'js/api.js' %}"></script> <script src="{% static 'js/api.js' %}"></script>
<script src="{% static 'js/dual-select.js' %}"></script>
<script src="{% static 'js/profiles.js' %}"></script> <script src="{% static 'js/profiles.js' %}"></script>
<script src="{% static 'js/auth.js' %}"></script> <script src="{% static 'js/auth.js' %}"></script>
<script src="{% static 'js/access.js' %}"></script> <script src="{% static 'js/access.js' %}"></script>

View File

@ -578,19 +578,62 @@
</div> </div>
<div class="modal-overlay" id="manage-account-modal" hidden> <div class="modal-overlay" id="manage-account-modal" hidden>
<div class="modal-card"> <div class="modal-card" id="manage-account-modal-card">
<h2 class="modal-card__title">Gerenciar Usuário</h2> <h2 class="modal-card__title">Gerenciar Usuário</h2>
<div class="modal-field"> <div class="modal-field">
<button type="button" class="btn-outline" id="manage-account-change-password-btn">Alterar senha</button> <button type="button" class="btn-outline" id="manage-account-change-password-btn">Alterar senha</button>
</div> </div>
<div class="modal-field" id="manage-account-liderados-field" hidden> <div class="modal-field" id="manage-account-liderados-field" hidden>
<label>Usuários sob sua liderança</label> <label>Usuários sob sua liderança</label>
<input type="text" id="manage-account-liderados-search" class="checklist-search" placeholder="Buscar por nome ou departamento..." autocomplete="off" /> <div class="dual-select">
<label class="modal-checkbox checklist-select-all"> <div class="dual-select__panel">
<input type="checkbox" id="manage-account-liderados-select-all" /> <div class="dual-select__panel-header">
Marcar todos os resultados da busca <button type="button" class="btn-solid" id="manage-account-liderados-available-link-btn" disabled>Vincular</button>
</label> </div>
<div class="checklist-box" id="manage-account-liderados-checklist"></div> <div class="dual-select__table-wrap">
<table class="dual-select__table">
<thead>
<tr>
<th><input type="checkbox" id="manage-account-liderados-available-select-all" aria-label="Selecionar todos" /></th>
<th class="dual-select__sort" id="manage-account-liderados-available-sort-nome">Nome <span class="dual-select__sort-icon">↕</span></th>
<th>Departamento</th>
</tr>
<tr class="dual-select__filters">
<th></th>
<th><input type="text" id="manage-account-liderados-available-search-nome" placeholder="Buscar por nome..." autocomplete="off" /></th>
<th><input type="text" id="manage-account-liderados-available-search-departamento" placeholder="Buscar por departamento..." autocomplete="off" /></th>
</tr>
</thead>
<tbody id="manage-account-liderados-available-body"></tbody>
</table>
</div>
<div class="dual-select__footer" id="manage-account-liderados-available-footer"></div>
</div>
<div class="dual-select__panel">
<div class="dual-select__panel-header">
<button type="button" class="btn-danger-outline" id="manage-account-liderados-linked-unlink-btn" disabled>Desvincular</button>
</div>
<div class="dual-select__table-wrap">
<table class="dual-select__table">
<thead>
<tr>
<th><input type="checkbox" id="manage-account-liderados-linked-select-all" aria-label="Selecionar todos" /></th>
<th class="dual-select__sort" id="manage-account-liderados-linked-sort-nome">Nome <span class="dual-select__sort-icon">↕</span></th>
<th>Departamento</th>
</tr>
<tr class="dual-select__filters">
<th></th>
<th><input type="text" id="manage-account-liderados-linked-search-nome" placeholder="Buscar por nome..." autocomplete="off" /></th>
<th><input type="text" id="manage-account-liderados-linked-search-departamento" placeholder="Buscar por departamento..." autocomplete="off" /></th>
</tr>
</thead>
<tbody id="manage-account-liderados-linked-body"></tbody>
</table>
</div>
<div class="dual-select__footer" id="manage-account-liderados-linked-footer"></div>
</div>
</div>
<p class="modal-error" id="manage-account-liderados-error"></p> <p class="modal-error" id="manage-account-liderados-error"></p>
</div> </div>
<div class="modal-actions"> <div class="modal-actions">
@ -655,6 +698,7 @@
</div> </div>
<script src="{% static 'js/api.js' %}"></script> <script src="{% static 'js/api.js' %}"></script>
<script src="{% static 'js/dual-select.js' %}"></script>
<script src="{% static 'js/profiles.js' %}"></script> <script src="{% static 'js/profiles.js' %}"></script>
<script src="{% static 'js/auth.js' %}"></script> <script src="{% static 'js/auth.js' %}"></script>
<script src="{% static 'js/access.js' %}"></script> <script src="{% static 'js/access.js' %}"></script>

View File

@ -275,13 +275,18 @@
</div> </div>
<div class="pa-table-wrap"> <div class="pa-table-wrap">
<table class="pa-table"> <table class="pa-table" id="ua-table">
<thead> <thead>
<tr> <tr>
<th style="width:160px;">Login</th> <th data-sort="username" style="width:130px;">Login <span class="ua-sort-icon">↕</span></th>
<th>Nome</th> <th data-sort="nome">Nome <span class="ua-sort-icon">↕</span></th>
<th style="width:220px;">Perfil de Acesso</th> <th data-sort="codigo_folha" style="width:110px;">Cód. Folha <span class="ua-sort-icon">↕</span></th>
<th style="width:100px;">Status</th> <th data-sort="codigo_questor" style="width:120px;">Cód. Questor <span class="ua-sort-icon">↕</span></th>
<th data-sort="codigo_tareffa" style="width:120px;">Cód. Tareffa <span class="ua-sort-icon">↕</span></th>
<th data-sort="codigo_contabit" style="width:120px;">Cód. Contabit <span class="ua-sort-icon">↕</span></th>
<th data-sort="ramal" style="width:90px;">Ramal <span class="ua-sort-icon">↕</span></th>
<th style="width:180px;">Perfil de Acesso</th>
<th data-sort="status" style="width:100px;">Status <span class="ua-sort-icon">↕</span></th>
<th style="width:130px;"></th> <th style="width:130px;"></th>
</tr> </tr>
</thead> </thead>
@ -329,6 +334,58 @@
</div> </div>
</div> </div>
<div class="ua-section">
<h3 class="ua-section-title">Dados Cadastrais</h3>
<div class="pa-field-row pa-field-row--auto">
<div class="pa-field">
<label for="ua-email">E-mail</label>
<input type="email" id="ua-email" placeholder="Ex.: maria.silva@depaulacontadores.com.br" autocomplete="off" />
</div>
<div class="pa-field">
<label for="ua-codigo-folha">Código da Folha</label>
<div class="ua-field-link">
<input type="text" id="ua-codigo-folha" placeholder="Ex.: 567/327" autocomplete="off" />
<button type="button" class="ua-vincular-btn" disabled title="Vinculação com sistema externo ainda não implementada">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 17H7a5 5 0 0 1 0-10h2" stroke-linecap="round"/><path d="M15 7h2a5 5 0 0 1 0 10h-2" stroke-linecap="round"/><path d="M8 12h8" stroke-linecap="round"/></svg>
Vincular
</button>
</div>
</div>
<div class="pa-field">
<label for="ua-codigo-questor">Código do Questor</label>
<div class="ua-field-link">
<input type="text" id="ua-codigo-questor" placeholder="Não cadastrado" autocomplete="off" />
<button type="button" class="ua-vincular-btn" disabled title="Vinculação com sistema externo ainda não implementada">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 17H7a5 5 0 0 1 0-10h2" stroke-linecap="round"/><path d="M15 7h2a5 5 0 0 1 0 10h-2" stroke-linecap="round"/><path d="M8 12h8" stroke-linecap="round"/></svg>
Vincular
</button>
</div>
</div>
<div class="pa-field">
<label for="ua-codigo-tareffa">Código do Tareffa</label>
<div class="ua-field-link">
<input type="text" id="ua-codigo-tareffa" placeholder="Não cadastrado" autocomplete="off" />
<button type="button" class="ua-vincular-btn" disabled title="Vinculação com sistema externo ainda não implementada">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 17H7a5 5 0 0 1 0-10h2" stroke-linecap="round"/><path d="M15 7h2a5 5 0 0 1 0 10h-2" stroke-linecap="round"/><path d="M8 12h8" stroke-linecap="round"/></svg>
Vincular
</button>
</div>
</div>
<div class="pa-field">
<label for="ua-codigo-contabit">Código do Contabit</label>
<input type="text" id="ua-codigo-contabit" placeholder="Não cadastrado" autocomplete="off" />
</div>
<div class="pa-field">
<label for="ua-ramal">Ramal</label>
<input type="text" id="ua-ramal" placeholder="Não cadastrado" autocomplete="off" />
</div>
<div class="pa-field">
<label for="ua-data-aniversario">Data de Aniversário</label>
<input type="date" id="ua-data-aniversario" />
</div>
</div>
</div>
<div class="ua-section"> <div class="ua-section">
<h3 class="ua-section-title">Permissões</h3> <h3 class="ua-section-title">Permissões</h3>
<div class="pa-field-row ua-permissions-row"> <div class="pa-field-row ua-permissions-row">
@ -356,45 +413,54 @@
</label> </label>
<div class="pa-field ua-liderados-field" id="ua-liderados-field" hidden> <div class="pa-field ua-liderados-field" id="ua-liderados-field" hidden>
<label>Usuários sob liderança</label> <label>Usuários sob liderança</label>
<input type="text" id="ua-liderados-search" class="checklist-search" placeholder="Buscar por nome ou departamento..." autocomplete="off" /> <div class="dual-select">
<label class="modal-checkbox checklist-select-all"> <div class="dual-select__panel">
<input type="checkbox" id="ua-liderados-select-all" /> <div class="dual-select__panel-header">
Marcar todos os resultados da busca <button type="button" class="btn-solid" id="ua-liderados-available-link-btn" disabled>Vincular</button>
</label>
<div class="checklist-box" id="ua-liderados-checklist"></div>
</div> </div>
<div class="dual-select__table-wrap">
<table class="dual-select__table">
<thead>
<tr>
<th><input type="checkbox" id="ua-liderados-available-select-all" aria-label="Selecionar todos" /></th>
<th class="dual-select__sort" id="ua-liderados-available-sort-nome">Nome <span class="dual-select__sort-icon">↕</span></th>
<th>Departamento</th>
</tr>
<tr class="dual-select__filters">
<th></th>
<th><input type="text" id="ua-liderados-available-search-nome" placeholder="Buscar por nome..." autocomplete="off" /></th>
<th><input type="text" id="ua-liderados-available-search-departamento" placeholder="Buscar por departamento..." autocomplete="off" /></th>
</tr>
</thead>
<tbody id="ua-liderados-available-body"></tbody>
</table>
</div>
<div class="dual-select__footer" id="ua-liderados-available-footer"></div>
</div> </div>
<div class="ua-section"> <div class="dual-select__panel">
<h3 class="ua-section-title">Dados Cadastrais</h3> <div class="dual-select__panel-header">
<div class="pa-field-row pa-field-row--auto"> <button type="button" class="btn-danger-outline" id="ua-liderados-linked-unlink-btn" disabled>Desvincular</button>
<div class="pa-field">
<label for="ua-email">E-mail</label>
<input type="email" id="ua-email" placeholder="Ex.: maria.silva@depaulacontadores.com.br" autocomplete="off" />
</div> </div>
<div class="pa-field"> <div class="dual-select__table-wrap">
<label for="ua-codigo-folha">Código da Folha</label> <table class="dual-select__table">
<input type="text" id="ua-codigo-folha" placeholder="Ex.: 567/327" autocomplete="off" /> <thead>
<tr>
<th><input type="checkbox" id="ua-liderados-linked-select-all" aria-label="Selecionar todos" /></th>
<th class="dual-select__sort" id="ua-liderados-linked-sort-nome">Nome <span class="dual-select__sort-icon">↕</span></th>
<th>Departamento</th>
</tr>
<tr class="dual-select__filters">
<th></th>
<th><input type="text" id="ua-liderados-linked-search-nome" placeholder="Buscar por nome..." autocomplete="off" /></th>
<th><input type="text" id="ua-liderados-linked-search-departamento" placeholder="Buscar por departamento..." autocomplete="off" /></th>
</tr>
</thead>
<tbody id="ua-liderados-linked-body"></tbody>
</table>
</div> </div>
<div class="pa-field"> <div class="dual-select__footer" id="ua-liderados-linked-footer"></div>
<label for="ua-codigo-questor">Código do Questor</label>
<input type="text" id="ua-codigo-questor" placeholder="Não cadastrado" autocomplete="off" />
</div> </div>
<div class="pa-field">
<label for="ua-codigo-tareffa">Código do Tareffa</label>
<input type="text" id="ua-codigo-tareffa" placeholder="Não cadastrado" autocomplete="off" />
</div>
<div class="pa-field">
<label for="ua-codigo-contabit">Código do Contabit</label>
<input type="text" id="ua-codigo-contabit" placeholder="Não cadastrado" autocomplete="off" />
</div>
<div class="pa-field">
<label for="ua-ramal">Ramal</label>
<input type="text" id="ua-ramal" placeholder="Não cadastrado" autocomplete="off" />
</div>
<div class="pa-field">
<label for="ua-data-aniversario">Data de Aniversário</label>
<input type="date" id="ua-data-aniversario" />
</div> </div>
</div> </div>
</div> </div>
@ -413,19 +479,62 @@
<div class="modal-overlay" id="manage-account-modal" hidden> <div class="modal-overlay" id="manage-account-modal" hidden>
<div class="modal-card"> <div class="modal-card" id="manage-account-modal-card">
<h2 class="modal-card__title">Gerenciar Usuário</h2> <h2 class="modal-card__title">Gerenciar Usuário</h2>
<div class="modal-field"> <div class="modal-field">
<button type="button" class="btn-outline" id="manage-account-change-password-btn">Alterar senha</button> <button type="button" class="btn-outline" id="manage-account-change-password-btn">Alterar senha</button>
</div> </div>
<div class="modal-field" id="manage-account-liderados-field" hidden> <div class="modal-field" id="manage-account-liderados-field" hidden>
<label>Usuários sob sua liderança</label> <label>Usuários sob sua liderança</label>
<input type="text" id="manage-account-liderados-search" class="checklist-search" placeholder="Buscar por nome ou departamento..." autocomplete="off" /> <div class="dual-select">
<label class="modal-checkbox checklist-select-all"> <div class="dual-select__panel">
<input type="checkbox" id="manage-account-liderados-select-all" /> <div class="dual-select__panel-header">
Marcar todos os resultados da busca <button type="button" class="btn-solid" id="manage-account-liderados-available-link-btn" disabled>Vincular</button>
</label> </div>
<div class="checklist-box" id="manage-account-liderados-checklist"></div> <div class="dual-select__table-wrap">
<table class="dual-select__table">
<thead>
<tr>
<th><input type="checkbox" id="manage-account-liderados-available-select-all" aria-label="Selecionar todos" /></th>
<th class="dual-select__sort" id="manage-account-liderados-available-sort-nome">Nome <span class="dual-select__sort-icon">↕</span></th>
<th>Departamento</th>
</tr>
<tr class="dual-select__filters">
<th></th>
<th><input type="text" id="manage-account-liderados-available-search-nome" placeholder="Buscar por nome..." autocomplete="off" /></th>
<th><input type="text" id="manage-account-liderados-available-search-departamento" placeholder="Buscar por departamento..." autocomplete="off" /></th>
</tr>
</thead>
<tbody id="manage-account-liderados-available-body"></tbody>
</table>
</div>
<div class="dual-select__footer" id="manage-account-liderados-available-footer"></div>
</div>
<div class="dual-select__panel">
<div class="dual-select__panel-header">
<button type="button" class="btn-danger-outline" id="manage-account-liderados-linked-unlink-btn" disabled>Desvincular</button>
</div>
<div class="dual-select__table-wrap">
<table class="dual-select__table">
<thead>
<tr>
<th><input type="checkbox" id="manage-account-liderados-linked-select-all" aria-label="Selecionar todos" /></th>
<th class="dual-select__sort" id="manage-account-liderados-linked-sort-nome">Nome <span class="dual-select__sort-icon">↕</span></th>
<th>Departamento</th>
</tr>
<tr class="dual-select__filters">
<th></th>
<th><input type="text" id="manage-account-liderados-linked-search-nome" placeholder="Buscar por nome..." autocomplete="off" /></th>
<th><input type="text" id="manage-account-liderados-linked-search-departamento" placeholder="Buscar por departamento..." autocomplete="off" /></th>
</tr>
</thead>
<tbody id="manage-account-liderados-linked-body"></tbody>
</table>
</div>
<div class="dual-select__footer" id="manage-account-liderados-linked-footer"></div>
</div>
</div>
<p class="modal-error" id="manage-account-liderados-error"></p> <p class="modal-error" id="manage-account-liderados-error"></p>
</div> </div>
<div class="modal-actions"> <div class="modal-actions">
@ -490,6 +599,7 @@
</div> </div>
<script src="{% static 'js/api.js' %}"></script> <script src="{% static 'js/api.js' %}"></script>
<script src="{% static 'js/dual-select.js' %}"></script>
<script src="{% static 'js/profiles.js' %}"></script> <script src="{% static 'js/profiles.js' %}"></script>
<script src="{% static 'js/auth.js' %}"></script> <script src="{% static 'js/auth.js' %}"></script>
<script src="{% static 'js/access.js' %}"></script> <script src="{% static 'js/access.js' %}"></script>