portal_publico/docs/calendario-individual/calendario-individual.md

20 KiB

Calendário Individual e Widgets

Movido do CLAUDE.md da raiz em 2026-08-26 para reduzir conflito de edição entre aplicações. Ver CLAUDE.md na raiz para arquitetura geral/transversal do Portal (modelo de permissões, API, CSS, etc.). Este arquivo não é auto-carregado pelo Claude Code (não há pacote Python dedicado, o código vive em portal_api/models.py/views.py/serializers.py junto com o resto) — leia manualmente ao mexer nesta aplicação. Inclui também o sistema genérico de Widgets de portal.html (portal_api/models.py WidgetUsuario, static/js/widgets.js), já que "Calendário Individual" foi o primeiro tipo de widget e os dois nasceram juntos.

Compromissos (CompromissoAgenda) têm um dono (dono, FK) e um campo visibilidade ("somente_eu"/"departamento"/"todos", substituiu a antiga flag booleana compartilhado_com_perfil — perfil de acesso deixou de ser o critério de compartilhamento). GET /api/compromissos/ (CompromissoAgendaViewSet.get_queryset) já retorna: (a) sempre os próprios compromissos do usuário; (b) todo compromisso com visibilidade="todos", pra qualquer usuário do portal, sem checar perfil/departamento; (c) compromissos com visibilidade="departamento" só se departamento_compartilhado (FK, on_delete=SET_NULL) for um dos departamentos do usuário logado — a lógica de "visível para quem" mora só no backend. O campo sou_dono (calculado no serializer) substitui o antigo ownerLogin === login do cliente; só o dono edita/exclui (CompromissoAgendaViewSet.get_object levanta PermissionDenied se não for o dono tentando escrever).

Ao escolher visibilidade="departamento" no modal (calendario-individual.html/calendar-individual.js), um segundo campo aparece (#ic-event-department-field) pra escolher qual dos próprios departamentos do dono recebe o compartilhamento — decisão explícita do usuário, já que Usuario.departamentos é M2M (pode ter mais de um) e "meu departamento" sozinho seria ambíguo nesse caso. As opções desse <select> vêm de me.departamentos (adicionado a /api/me/ só pra isso — antes esse endpoint não expunha os próprios departamentos do usuário logado, só o de outros via UsuarioResumoSerializer). CompromissoAgendaSerializer.validate() exige departamento_compartilhado quando visibilidade="departamento" e recusa qualquer departamento que não esteja entre os do próprio dono (request.user.departamentos) — mesmo que o cliente tente forçar um id de departamento alheio no payload; para as outras duas visibilidades, departamento_compartilhado é sempre zerado no servidor, ignorando o que vier no payload.

Filtro por categoria e cores no calendário (.calendar-filters/.filter-chip em calendario.css — já existiam no CSS sem nenhum consumidor antes desta funcionalidade): dentro de .calendar-header, ao lado do título do mês e do botão "Novo Compromisso" (mesma linha, não numa faixa própria abaixo — flex-wrap: wrap no header cobre o caso de não caber tudo numa linha só), uma barra de chips clicáveis (multi-seleção, sem exclusividade) filtra o que aparece no calendário por pidEventoCategoria(ev) (calendar-individual.js), que não é exatamente ev.visibilidade — um compromisso "somente_eu" que não é meu (!ev.sou_dono) só pode ter chegado pela regra de liderança abaixo, então vira a categoria "equipe", distinta de "somente_eu" (meus próprios); e todo ev.eh_evento vira a categoria "evento" antes de qualquer outra checagem (ver "Eventos Corporativos" abaixo), mesmo já sendo sempre visibilidade="todos". As 5 categorias (somente_eu/departamento/todos/equipe/evento) têm cor fixa própria (.calendar-event--* em calendario.css) e o chip ativo de cada uma usa a mesma cor — o próprio filtro funciona como legenda. "somente_eu" é a exceção: usa --accent (o tema de cor que o usuário escolheu, não uma cor fixa); as outras quatro usam tokens fixos novos (--gold, reaproveitado do antigo "compartilhado"; --teal e --slate, adicionados só pra isso em tokens.css; --coral, adicionado depois só pra "evento" — todos com variante mais escura no tema claro pro contraste do texto escuro fixo #1a1721) — escolhidos deliberadamente fora das cores de tema selecionáveis (roxo/azul/verde/âmbar/rosa/vermelho) pra nunca coincidir visualmente com o que --accent pode assumir — exceto --coral, que é laranja e portanto não colide mesmo com "vermelho" na lista. O chip "Minha equipe" (#ic-filter-equipe) só aparece (hidden) se me.lideranca; o chip "Eventos" (data-filter="evento") é sempre visível, já que qualquer perfil pode ver eventos corporativos (só criar um é restrito). Por padrão todos os chips visíveis nascem is-active (mostra tudo que o usuário pode ver). Clique simples troca a seleção pra só aquele filtro (activeFilters.clear() + adiciona só o clicado), exceto se esse filtro já for o único ativo — nesse caso (activeFilters.size === 1 && activeFilters.has(filtro)) o clique volta pra visualização padrão (selecionarTodosOsFiltros(), todos os chips visíveis ativos), pra sempre existir um caminho de volta ao estado "ver tudo" sem precisar de Shift. Shift+clique acrescenta/remove esse filtro dos já selecionados (event.shiftKey, mesmo padrão de seleção de arquivos do SO) — é assim que dá pra combinar mais de uma categoria ao mesmo tempo.

Eventos Corporativos (CategoriaEvento, CompromissoAgenda.eh_evento/categoria/local/modalidade/descricao): compromissos com visibilidade em departamento/todos deixaram de ser livres pra qualquer usuário — criar ou editar um compromisso nesses dois níveis (o que inclui automaticamente todo eh_evento=True, já que a validação força visibilidade="todos" antes de checar permissão) agora exige apps["calendario-individual-criar-evento"] em permissoes["calendario-individual"] (CompromissoAgendaSerializer.validate()), permissão liberada só para "Integração e Inovação" no seed_portal.py por ora (mesmo cuidado de sempre: como calendario-individual está em BASE_KEYS, sem o override todo perfil nasceria podendo criar evento de departamento/todos e cadastrar categoria). Compromissos "somente_eu" continuam livres pra qualquer um, sem essa checagem.

  • CategoriaEvento (nome único + cor hex, validada por validar_cor_categoria_evento) é um cadastro simples via /api/categorias-evento/ — GET livre a qualquer autenticado (a cor/nome de uma categoria não é sigilosa), escrita restrita à mesma permissão acima. Não é uma lista fixa no código: quem tem a permissão cadastra categorias novas (ex.: "Reunião", "Treinamento") direto no modal de criar/editar compromisso (botão "+" ao lado do <select> de categoria, #ic-event-categoria-add-btn, que abre #ic-categoria-modal) — seed_portal.py popula CATEGORIAS_EVENTO_SEED como ponto de partida, mas a lista é editável dali em diante.
  • CompromissoAgenda.eh_evento marca um compromisso como evento formal (não uma reunião pessoal marcada como "todos") — o checkbox correspondente (#ic-event-eh-evento, dentro de #ic-event-eh-evento-field) só aparece pra quem tem a permissão de criar evento; marcá-lo força a visibilidade pra "Todos" no próprio formulário. local (texto livre), modalidade (presencial/remoto/hibrido, <select> #ic-event-modalidade) e descricao (texto livre) são campos extras só relevantes pra evento, mas tecnicamente gravam em qualquer compromisso (o formulário só os expõe quando aplicável). No popup somente-leitura (#ic-view-modal), cada um aparece como campo próprio (#ic-view-local-field/#ic-view-modalidade-field/#ic-view-descricao-field), escondido (hidden) quando vazio — mesmo padrão dos demais campos condicionais desse popup (ver "Pill do compromisso" abaixo).
  • Visualmente, um evento ganha um bucket de cor próprio (--coral) tanto no pill do calendário (.calendar-event--evento) quanto no chip de filtro "Eventos" — mesmo sendo sempre visibilidade="todos" por baixo, não se mistura visualmente com um "Todos" comum (ver parágrafo acima).

Pill do compromisso: sempre "HH:MM Título", nada mais — todo pill mostra só horário+título, nunca o nome do dono, pra manter o mesmo formato/tamanho independente da categoria ("simétrico", pedido explícito do usuário; a primeira versão acrescentava "— Nome do dono" direto no texto dos compromissos que não eram do usuário, o que descalibrava o visual porque nomes têm tamanhos bem diferentes). Todo pill é clicável (cursor:pointer na classe base .calendar-event, não só em --somente-eu): se ev.sou_dono, abre o modal de edição de sempre (#ic-modal, fecha também clicando fora — event.target === modal, mesmo padrão de links-ferramentas.js/ramais-lookup.js); senão, abre um modal novo, só leitura (#ic-view-modal/openViewModal(), mesmo fecha-ao-clicar-fora), com data/horário por extenso, dono_nome (rotulado "Agendado por:", não "Responsável" — mudança de nomenclatura pedida pelo usuário), o rótulo da visibilidade (PID_IC_VISIBILIDADE_LABELS, mapeia ev.visibilidade pro texto exibido nos chips) e, só quando ev.visibilidade === "departamento", o nome do departamento (ev.departamento_compartilhado_nome, campo já vinha do serializer). É esse popup — não o texto do pill — que carrega toda a informação que antes tentava caber na própria pílula.

Célula do dia com altura fixa, lista de compromissos rolável (.calendar-day/.calendar-day__events em calendario.css): .calendar-day tem height fixo (108px desktop, 76px no breakpoint mobile — antes era min-height, o que deixava a linha inteira da grade crescer quando um dia tinha muitos compromissos, desalinhando a altura de todas as células daquela semana). Os pills não são mais filhos diretos de .calendar-day — calendar-individual.js (render()) os agrupa num <div class="calendar-day__events"> (flex:1; min-height:0; overflow-y:auto) irmão de .calendar-day__header. .calendar-day (o item de grid, não só o __events interno) também precisa de min-height:0 + overflow:hidden — sem isso, o "tamanho mínimo automático" que grid/flexbox calculam por padrão pra um item (baseado no conteúdo, ignorando height explícito) ainda fazia a linha da grade crescer pra caber todos os pills, mesmo com a célula e o overflow-y:auto do __events configurados certinho por dentro — o corte real só acontece quando o próprio item de grid para de contribuir com seu min-content pro cálculo da altura da linha (min-height:0/overflow não-visible fazem isso). Resultado: o cabeçalho (número do dia + botão de adicionar) fica sempre fixo, todas as linhas da grade têm a mesma altura sempre, e uma barra de rolagem aparece dentro da célula só quando os compromissos daquele dia não cabem nos 108px/76px disponíveis.

Agenda completa do dia (#ic-day-modal, openDayModal() em calendar-individual.js): clicar no número do dia (.calendar-day__number, cursor:pointer + destaque no hover) abre um popup com todos os compromissos do dia (respeitando os filtros ativos, mesmo eventosDoDia usado pra desenhar a célula) mais o feriado, se houver — sem o corte de altura/rolagem da célula, já que o .day-modal-list (calendario.css) tem max-height:360px próprio, bem maior que os 108px da grade, e os pills ali dentro voltam a ter white-space:normal (podem quebrar linha) em vez do nowrap+ellipsis da grade, então nada aparece cortado. Pra evitar duplicar a criação dos pills em dois lugares (grade e popup), criarPillCompromisso(ev)/criarPillFeriado(iso, nome) foram extraídas como funções reaproveitadas por render() e por openDayModal() — mesmo elemento, mesmo clique (editar/ver detalhes/ver nome do feriado), só muda o container onde entram. Clicar num item dentro do popup fecha o popup da agenda antes de abrir o modal de destino (edição/visualização/feriado), pra não empilhar dois overlays ao mesmo tempo. O botão "Novo Compromisso" do popup pré-preenche a data com o dia clicado (mesmo mecanismo do "+" de cada célula).

Feriados no Calendário Individual (GET /api/feriados/?ano=AAAA, feriados_view em views.py): usa a lib holidays (PyPI, requirements.txt) pra devolver os feriados nacionais + estaduais do Paraná (holidays.Brazil(years=ano, subdiv="PR", language="pt_BR"), categoria public — o default da lib, exclui pontos facultativos tipo Carnaval/Corpus Christi) do ano pedido, como [{"data": "AAAA-MM-DD", "nome": "..."}]. language="pt_BR" é passado explicitamente — sem isso, a lib pode cair pro locale do processo do servidor (que nem sempre é pt_BR, ex.: environment com LANG/LANGUAGE em inglês) em vez do default_language da classe Brazil, fazendo os nomes virem em inglês ("Independence Day" em vez de "Independência do Brasil") mesmo com o resto do portal em português. De propósito não tem feriado municipal de Foz do Iguaçu aqui — nenhuma lib de feriados cobre granularidade de município brasileiro (a holidays só tem um caso especial hardcoded pra "São Paulo Capital", nada além disso), e manter uma lista municipal certa exigiria curadoria manual + atualização por decreto da Prefeitura a cada ano; o usuário decidiu deixar de fora por enquanto, só nacional/estadual mesmo. Se algum dia precisar do municipal, a rota certa é o usuário fornecer a lista oficial (decreto da Prefeitura) pra virar uma tabela fixa no código, não tentar adivinhar/inferir datas.

No frontend (calendar-individual.js), carregarFeriados(ano) busca e cacheia por ano (Map em memória, só refaz a requisição ao trocar de ano); render() busca também o ano anterior/seguinte quando o mês exibido encosta na borda do ano (janeiro/dezembro), já que os dias "fora do mês" na grade podem pertencer a um ano diferente de viewYear. Cada célula de dia feriado ganha a classe .is-feriado (fundo tingido de vermelho, rgba(var(--danger-rgb), 0.1)) e um pill (.calendar-day__holiday, mesmo visual dos pills de compromisso — .calendar-event, só que com fundo --danger fixo, não uma cor por categoria) com o nome do feriado. Esse pill é irmão de .calendar-day__header, fora de .calendar-day__events — fica sempre fixo no topo da célula, não rola junto com os compromissos do dia. Truncado com text-overflow:ellipsis quando o nome não cabe (alguns feriados vêm com dois nomes concatenados por ;, ex.: "Nossa Senhora do Rocio; Proclamação da República" — ver feriados_view); clicar no pill abre #ic-holiday-modal (openHolidayModal(), mesmo padrão dos outros modais — fecha clicando fora) mostrando a data e o nome completo sem corte. É só informativo, não bloqueia criar/editar compromisso nesse dia.

Gerente/coordenador vê a agenda individual da equipe: CompromissoAgendaViewSet.get_queryset acrescenta Q(visibilidade="somente_eu", dono__in=usuario.liderados.all()) às regras de visibilidade — ou seja, além de "todos" e "departamento" (ver acima), quem tem gente em Usuario.liderados (ver docs/perfis-usuarios/perfis-usuarios.md, seção "Liderança") também enxerga os compromissos privados ("somente_eu") de cada liderado, mas sem poder editá-los (sou_dono continua False pra esses, CompromissoAgendaViewSet.get_object já barra escrita de quem não é dono). Não há necessidade de checar usuario.lideranca explicitamente na query — liderados só é populado através de fluxos que já exigem esse flag (ver docs/perfis-usuarios/perfis-usuarios.md), então a cláusula é inofensiva (não casa nada) pra quem não lidera ninguém.

Lembrete com horário comercial (CompromissoAgenda.lembrete_antecedencia, opcional — "" = sem lembrete; "1h"/"2h"/"4h"/"24h"): não existe nenhum mecanismo de push/e-mail no projeto — o "lembrete" é só o momento a partir do qual o compromisso passa a aparecer no sino de notificações (notif-bell), que já era recalculado a cada carregamento de página (sem processo em segundo plano). CompromissoAgenda.calcular_notificar_em() (models.py) calcula esse horário contando lembrete_antecedencia horas de expediente (seg-sex, 8h-18h, COMPROMISSO_HORARIO_COMERCIAL_INICIO/_FIM) pra trás a partir de data+horario — fora do expediente não conta como antecedência "gasta", só é pulado de graça. Por isso um compromisso às 08h de segunda com lembrete de 4h não notifica às 04h (fora do expediente); o algoritmo (_janela_comercial/_dia_util_anterior, funções módulo-level) pula pro fechamento do expediente do dia útil anterior (sexta 18h) e só então desconta as 4h, resultando em sexta 14h. Sem horario definido no compromisso (evento de dia inteiro) ou sem lembrete_antecedencia, não há o que calcular e calcular_notificar_em() retorna None. O resultado é exposto só leitura via notificar_em no serializer (ISO datetime ou null); pidBuildEventNotifications() (notifications.js) só inclui um compromisso na lista do sino quando notificar_em não é nulo e já foi atingido (now >= notificar_em) — compromissos sem lembrete configurado simplesmente não aparecem no sino (comportamento diferente de antes da migração, quando todo compromisso futuro aparecia lá independente de qualquer configuração).

Widgets (portal.html)

widgets.js mantém um registro extensível PID_WIDGET_TYPES ({ "<chave>": { label, description, href, linkLabel, visibleIf? } }) — hoje existem "calendario-individual" e "links-favoritos" (ver docs/links-ferramentas-acessos-gerais/links-ferramentas-acessos-gerais.md). href/linkLabel alimentam o link de rodapé do card ("Ver X completo →"); antes de existir um segundo tipo de widget esse link era hardcoded pra calendario-individual.html, então ao adicionar um tipo novo sempre preencher os dois, senão o rodapé de todos os widgets aponta pro lugar errado. visibleIf(me) é opcional — quando presente, filtra o tipo tanto do picker (renderPicker()) quanto da grade já adicionada (renderWidgets()), usado pra widgets que exponham dado de um módulo com permissão própria (ex.: links-favoritos só aparece pra quem tem apps["links-ferramentas-visualizar"] em links-ferramentas). Para adicionar um novo tipo de widget: registrar a entrada em PID_WIDGET_TYPES e adicionar um case/if em widgetBodyFor() que retorne o HTML do corpo do card; o picker (#widget-picker-modal) e a grade (#widgets-grid) já lidam com adicionar/remover genericamente via /api/widgets/.

Reordenar e redimensionar widgets (WidgetUsuario.ordem/largura/altura, por usuário): .widgets-grid é display:flex; flex-wrap:wrap (não mais CSS Grid — precisava permitir que cada .widget-card tivesse largura/altura próprias e livres, incompatível com colunas de grid uniformes). Reordenar é drag-and-drop nativo HTML5 igual ao de Links & Ferramentas (dragstart/dragover/drop em #widgets-grid, PATCH /api/widgets/{tipo}/ só nos itens cujo ordem mudou) — a diferença é que o draggable="true" fica só em .widget-card__header (a barra de título), não no card inteiro, pra não conflitar com o handle nativo de resize (resize: both em .widget-card, ativo no canto inferior direito). Redimensionar usa esse resize: both do CSS (sem JS de arraste custom) — um ResizeObserver por card (observeWidgetSizes()) detecta a mudança de tamanho e salva largura/altura com debounce de 500ms; como o resize é 100% nativo do browser, não precisa nenhum cálculo manual de arraste. Como o conteúdo de um widget pode ficar maior que o espaço depois de encolhido, só .widget-card__body tem overflow-y: auto (o cabeçalho e o link de rodapé ficam fixos, só o corpo rola).

CSS

calendario.css (.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) e widgets.css (.widgets-*, .widget-card*, .widget-picker-* — só usado em portal.html).