v-sistec-features 1.31.11 → 1.31.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,211 +1,284 @@
1
1
  <!--
2
2
  ========
3
- Este arquivo representa o componente base de visualização e processamento em grade (matriz) da agenda.
4
- Ele centraliza o cálculo de colspan dos horários, a busca de dados na API, a renderização da tabela
5
- e o controle dos modais de criação e detalhes de reserva, servindo de estrutura tanto para a visão
6
- múltipla com filtros quanto para a visão de recurso único.
3
+ Este arquivo representa o componente base da visão em matriz horizontal ('tabela').
4
+ Ele ordena a semana a partir do dia inicial da agenda, exibe a barra de data seguida pelos horários
5
+ daquele dia em cada bloco diário, utiliza o componente genérico BlocoArrastavel para seleção horizontal
6
+ e mescla células de reservas contínuas com colspan.
7
7
  ========
8
8
  -->
9
9
  <script setup lang="ts">
10
- import { computed, onMounted, watch, ref, inject } from 'vue';
11
- import { useAgenda } from '../composables/useAgenda';
10
+ import { computed, inject } from 'vue';
11
+ import { useCoresAgenda } from '../composables/useCoresAgenda';
12
+ import { useSelecaoArrasto } from '../composables/useSelecaoArrasto';
13
+ import {
14
+ SIGLAS_DIAS_JS,
15
+ extrairDataHoraLocal,
16
+ extrairHoraFormatada,
17
+ criarDataLocal,
18
+ mesclarSlotsReservados,
19
+ useGradeSemanal
20
+ } from '../composables/useGradeCompartilhada';
21
+ import BlocoArrastavel from './BlocoArrastavel.vue';
12
22
  import ModalReserva from './ModalReserva.vue';
13
23
  import ModalDetalhesReserva from './ModalDetalhesReserva.vue';
14
24
 
25
+ interface MetadadoColuna {
26
+ corFundo: string | null;
27
+ corBorda: string;
28
+ fimDeSubbloco: boolean;
29
+ fimDeBlocoPrincipal: boolean;
30
+ }
31
+
15
32
  const props = defineProps<{
16
33
  idsRecursosVisiveis: string[];
17
34
  usaInterceptador?: boolean;
18
35
  tituloPadrao?: string;
19
36
  ocultarTituloModal?: boolean;
20
37
  mensagemVazio?: string;
38
+ configuracaoBlocos?: any;
21
39
  }>();
22
40
 
23
41
  const api = inject('api-instance-agenda') as any;
24
- const emit = defineEmits(['before-save']);
42
+ defineEmits(['before-save']);
43
+
44
+ const { obterCoresBloco } = useCoresAgenda();
25
45
 
26
46
  const {
27
47
  dadosMatrix,
28
- buscarSlotsMatrix,
29
- buscarReservas,
30
- agendaSelecionada,
31
- dataSelecionada,
32
- converterParaUtc
33
- } = useAgenda(api);
34
-
35
- // Indica se a matriz está buscando e processando dados na API.
36
- const carregando = ref(false);
37
-
38
- // Controla a abertura do modal de criação de reserva.
39
- const modalAberto = ref(false);
40
-
41
- // Armazena os dados do slot livre clicado para preencher o modal de criação.
42
- const dadosParaModal = ref({});
43
-
44
- // Controla a abertura do modal de detalhes de uma reserva existente.
45
- const modalDetalhesAberto = ref(false);
48
+ carregando,
49
+ modalAberto,
50
+ dadosParaModal,
51
+ modalDetalhesAberto,
52
+ reservaSelecionadaDetalhes,
53
+ obterBlocosDoDia,
54
+ dispararBuscaSemanal
55
+ } = useGradeSemanal(api, () => props.configuracaoBlocos);
56
+
57
+ const controladorArrastoHorizontal = useSelecaoArrasto({
58
+ aoClicarItemIndisponivel: (item) => {
59
+ if (item?.slot?.reservado_slot && item?.slot?.detalhes_reserva) {
60
+ reservaSelecionadaDetalhes.value = item.slot.detalhes_reserva;
61
+ modalDetalhesAberto.value = true;
62
+ }
63
+ },
64
+ aoFinalizarSelecao: ({ itensSelecionados }) => {
65
+ const primeiro = itensSelecionados[0];
66
+ const ultimo = itensSelecionados[itensSelecionados.length - 1];
67
+ if (!primeiro?.slot || !ultimo?.slot) return;
68
+
69
+ const inicio = extrairDataHoraLocal(primeiro.slot.data_inicio_slot);
70
+ const fim = extrairDataHoraLocal(ultimo.slot.data_fim_slot);
71
+
72
+ dadosParaModal.value = {
73
+ id_recurso: Number(primeiro.idRecurso),
74
+ titulo_reserva: '',
75
+ data_inicio: inicio.data,
76
+ hora_inicio: inicio.hora,
77
+ data_fim: fim.data,
78
+ hora_fim: fim.hora
79
+ };
80
+
81
+ modalAberto.value = true;
82
+ }
83
+ });
46
84
 
47
- // Armazena o objeto da reserva clicada para exibição no modal de detalhes.
48
- const reservaSelecionadaDetalhes = ref<any>(null);
85
+ // Constrói os blocos principais, subdivisões, cores e bordas por coluna para o cabeçalho e células de um dia específico.
86
+ const construirCabecalhosDeBlocos = (slotsBase: { horaInicio: string; horaFim: string }[], siglaDia: string) => {
87
+ const totalSlots = slotsBase.length;
88
+ const listaBlocosConfig = obterBlocosDoDia(siglaDia);
89
+
90
+ const metadadosColunas: MetadadoColuna[] = slotsBase.map(() => ({
91
+ corFundo: null,
92
+ corBorda: 'var(--border-light)',
93
+ fimDeSubbloco: false,
94
+ fimDeBlocoPrincipal: false
95
+ }));
96
+
97
+ if (!listaBlocosConfig || listaBlocosConfig.length === 0) {
98
+ return {
99
+ temBlocos: false,
100
+ temSubblocos: false,
101
+ blocosPrincipais: [],
102
+ subblocos: [],
103
+ metadadosColunas
104
+ };
105
+ }
49
106
 
50
- // Recebe nada e devolve uma Promise.
51
- // É usada para buscar os slots da matriz e as reservas correspondentes à janela de 7 dias a partir da data selecionada.
52
- const dispararBuscaMatrix = async () => {
53
- const idAgenda = agendaSelecionada.value;
54
- if (!idAgenda) return;
107
+ const blocosPrincipais: any[] = [];
108
+ const subblocos: any[] = [];
109
+ let cursor = 0;
110
+ let algumSubblocoDefinido = false;
111
+
112
+ const adicionarSubbloco = (inicioIdx: number, tam: number, cor: string, corBorda: string, fimPrincipal: boolean, marcarSub: boolean) => {
113
+ const iniSub = slotsBase[inicioIdx];
114
+ const fimSub = slotsBase[inicioIdx + tam - 1];
115
+ subblocos.push({
116
+ colspan: tam,
117
+ cor,
118
+ corBorda,
119
+ fimDeBlocoPrincipal: fimPrincipal,
120
+ etiqueta: `${iniSub?.horaInicio ?? ''} - ${fimSub?.horaFim ?? ''}`
121
+ });
122
+ if (marcarSub) {
123
+ const metaFimSub = metadadosColunas[inicioIdx + tam - 1];
124
+ if (metaFimSub) metaFimSub.fimDeSubbloco = true;
125
+ }
126
+ };
55
127
 
56
- carregando.value = true;
128
+ listaBlocosConfig.forEach((blocoCfg: any, idxBloco: number) => {
129
+ if (cursor >= totalSlots) return;
57
130
 
58
- const valorData = dataSelecionada.value || new Date();
59
- let dataInicio: Date;
131
+ const qtdSolicitada = Number(blocoCfg.quantidade) || 0;
132
+ const qtdReal = Math.min(qtdSolicitada, totalSlots - cursor);
133
+ if (qtdReal <= 0) return;
60
134
 
61
- if (typeof valorData === 'string') {
62
- const apenasData = String(valorData).substring(0, 10);
63
- const [ano, mes, dia] = apenasData.split('-');
64
- dataInicio = new Date(Number(ano), Number(mes) - 1, Number(dia));
65
- } else {
66
- dataInicio = new Date(valorData);
67
- }
135
+ const { corFundo: corBloco, corBorda } = obterCoresBloco(idxBloco);
136
+ const slotPrimeiro = slotsBase[cursor];
137
+ const slotUltimo = slotsBase[cursor + qtdReal - 1];
68
138
 
69
- const dataFim = new Date(dataInicio);
70
- dataFim.setDate(dataInicio.getDate() + 6);
139
+ blocosPrincipais.push({
140
+ nome: blocoCfg.nome || `Bloco ${idxBloco + 1}`,
141
+ colspan: qtdReal,
142
+ cor: corBloco,
143
+ corBorda,
144
+ horaInicio: slotPrimeiro?.horaInicio ?? '',
145
+ horaFim: slotUltimo?.horaFim ?? ''
146
+ });
71
147
 
72
- const formatar = (dt: Date) => {
73
- const ano = dt.getFullYear();
74
- const mes = String(dt.getMonth() + 1).padStart(2, '0');
75
- const dia = String(dt.getDate()).padStart(2, '0');
76
- return `${ano}-${mes}-${dia}`;
77
- };
148
+ for (let i = 0; i < qtdReal; i++) {
149
+ const meta = metadadosColunas[cursor + i];
150
+ if (meta) {
151
+ meta.corFundo = corBloco;
152
+ meta.corBorda = corBorda;
153
+ }
154
+ }
155
+
156
+ const tamanhoSubFixo = Number(blocoCfg.subdivisao) || 0;
157
+ const listaSubCustom = Array.isArray(blocoCfg.subdivisoes) ? blocoCfg.subdivisoes : null;
158
+
159
+ if (listaSubCustom && listaSubCustom.length > 0) {
160
+ algumSubblocoDefinido = true;
161
+ let consumido = 0;
162
+ listaSubCustom.forEach((tamSub: number) => {
163
+ if (consumido >= qtdReal) return;
164
+ const tamEfetivo = Math.min(Number(tamSub) || 1, qtdReal - consumido);
165
+ const ehUltimo = (consumido + tamEfetivo) >= qtdReal;
166
+ adicionarSubbloco(cursor + consumido, tamEfetivo, corBloco, corBorda, ehUltimo, true);
167
+ consumido += tamEfetivo;
168
+ });
169
+ if (consumido < qtdReal) {
170
+ adicionarSubbloco(cursor + consumido, qtdReal - consumido, corBloco, corBorda, true, true);
171
+ }
172
+ } else if (tamanhoSubFixo > 0) {
173
+ algumSubblocoDefinido = true;
174
+ let consumido = 0;
175
+ while (consumido < qtdReal) {
176
+ const tamEfetivo = Math.min(tamanhoSubFixo, qtdReal - consumido);
177
+ const ehUltimo = (consumido + tamEfetivo) >= qtdReal;
178
+ adicionarSubbloco(cursor + consumido, tamEfetivo, corBloco, corBorda, ehUltimo, true);
179
+ consumido += tamEfetivo;
180
+ }
181
+ } else {
182
+ adicionarSubbloco(cursor, qtdReal, corBloco, corBorda, true, false);
183
+ }
184
+
185
+ const metaFimPrincipal = metadadosColunas[cursor + qtdReal - 1];
186
+ if (metaFimPrincipal) metaFimPrincipal.fimDeBlocoPrincipal = true;
187
+
188
+ cursor += qtdReal;
189
+ });
78
190
 
79
- const strInicio = formatar(dataInicio);
80
- const strFim = formatar(dataFim);
191
+ if (cursor < totalSlots) {
192
+ const restante = totalSlots - cursor;
193
+ const { corFundo: corRestante, corBorda: corBordaRestante } = obterCoresBloco(blocosPrincipais.length);
194
+ const slotPrimeiro = slotsBase[cursor];
195
+ const slotUltimo = slotsBase[totalSlots - 1];
196
+
197
+ blocosPrincipais.push({
198
+ nome: 'Outros',
199
+ colspan: restante,
200
+ cor: corRestante,
201
+ corBorda: corBordaRestante,
202
+ horaInicio: slotPrimeiro?.horaInicio ?? '',
203
+ horaFim: slotUltimo?.horaFim ?? ''
204
+ });
81
205
 
82
- const inicioUtc = encodeURIComponent(converterParaUtc(strInicio, '00:00'));
83
- const fimUtc = encodeURIComponent(converterParaUtc(strFim, '23:59'));
206
+ for (let i = 0; i < restante; i++) {
207
+ const meta = metadadosColunas[cursor + i];
208
+ if (meta) {
209
+ meta.corFundo = corRestante;
210
+ meta.corBorda = corBordaRestante;
211
+ }
212
+ }
84
213
 
85
- await buscarSlotsMatrix(idAgenda, inicioUtc, fimUtc);
86
- await buscarReservas(`${strInicio}T00:00:00`, `${strFim}T23:59:59`);
87
- carregando.value = false;
88
- };
214
+ adicionarSubbloco(cursor, restante, corRestante, corBordaRestante, true, false);
89
215
 
90
- onMounted(() => {
91
- dispararBuscaMatrix();
92
- });
93
-
94
- watch([dataSelecionada, agendaSelecionada], () => {
95
- dispararBuscaMatrix();
96
- });
216
+ const metaUltimo = metadadosColunas[totalSlots - 1];
217
+ if (metaUltimo) metaUltimo.fimDeBlocoPrincipal = true;
218
+ }
97
219
 
98
- // Recebe uma string ISO e devolve o total de minutos correspondente ao horário local.
99
- // É usada para posicionar e medir a duração dos slots na grade de horários.
100
- const extrairMinutos = (isoStr: string) => {
101
- const d = new Date(isoStr);
102
- return d.getHours() * 60 + d.getMinutes();
220
+ return {
221
+ temBlocos: blocosPrincipais.length > 0,
222
+ temSubblocos: algumSubblocoDefinido,
223
+ blocosPrincipais,
224
+ subblocos,
225
+ metadadosColunas
226
+ };
103
227
  };
104
228
 
105
- // Monta a lista ordenada de horários (em minutos) para o cabeçalho das colunas da tabela.
106
- const horariosGlobaisEmMinutos = computed(() => {
229
+ // Gera a lista ordenada de dias operacionais com cabeçalhos e células mescladas por colspan.
230
+ const diasProcessados = computed(() => {
107
231
  if (!dadosMatrix.value?.datas_matriz) return [];
108
- const setMinutos = new Set<number>();
109
-
110
- dadosMatrix.value.datas_matriz.forEach((dia: any) => {
111
- dia.recursos_slot.forEach((rec: any) => {
112
- rec.horarios_slot.forEach((slot: any) => {
113
- setMinutos.add(extrairMinutos(slot.data_inicio_slot));
114
- });
115
- });
116
- });
117
-
118
- return Array.from(setMinutos).sort((a, b) => a - b);
119
- });
120
232
 
121
- // Processa os dias, filtra os recursos com base na prop idsRecursosVisiveis e calcula o colspan de cada slot.
122
- const matrizProcessada = computed(() => {
123
- if (!dadosMatrix.value?.datas_matriz || horariosGlobaisEmMinutos.value.length === 0) return [];
233
+ const resultado: any[] = [];
124
234
 
125
- const mapaHorarios = horariosGlobaisEmMinutos.value;
235
+ dadosMatrix.value.datas_matriz.forEach((dia: any) => {
236
+ if (dia.dia_operacional_slot === false) return;
126
237
 
127
- return dadosMatrix.value.datas_matriz.map((dia: any) => {
128
- const recursosFiltrados = dia.recursos_slot.filter((res: any) =>
238
+ const recursosFiltrados = (dia.recursos_slot || []).filter((res: any) =>
129
239
  props.idsRecursosVisiveis.includes(String(res.id_recurso))
130
240
  );
241
+ if (recursosFiltrados.length === 0) return;
242
+
243
+ const slotsValidosBase = (recursosFiltrados[0].horarios_slot || []).filter((s: any) => !s.bloqueado_slot);
244
+ if (slotsValidosBase.length === 0) return;
245
+
246
+ const definicaoColunas = slotsValidosBase.map((s: any) => ({
247
+ horaInicio: extrairHoraFormatada(s.data_inicio_slot),
248
+ horaFim: extrairHoraFormatada(s.data_fim_slot)
249
+ }));
250
+
251
+ const siglaDia = SIGLAS_DIAS_JS[criarDataLocal(dia.data_slot).getDay()] || 'SEG';
252
+ const infoBlocos = construirCabecalhosDeBlocos(definicaoColunas, siglaDia);
253
+
254
+ const recursosProcessados = recursosFiltrados.map((rec: any) => {
255
+ const slotsBrutos = (rec.horarios_slot || []).filter((s: any) => !s.bloqueado_slot);
256
+ return {
257
+ id_recurso: rec.id_recurso,
258
+ nome_recurso: rec.nome_recurso,
259
+ slotsBrutos,
260
+ horarios_slot: mesclarSlotsReservados(slotsBrutos, 'colspan')
261
+ };
262
+ });
131
263
 
132
- if (recursosFiltrados.length === 0) return null;
133
-
134
- const recursosComColspan = recursosFiltrados.map((rec: any) => {
135
- const slotsCalculados = rec.horarios_slot.map((slot: any) => {
136
- const inicioMin = extrairMinutos(slot.data_inicio_slot);
137
- let fimMin = extrairMinutos(slot.data_fim_slot);
138
-
139
- if (fimMin === 0 && new Date(slot.data_fim_slot) > new Date(slot.data_inicio_slot)) {
140
- fimMin = 24 * 60;
141
- }
142
-
143
- let tamanhoColspan = 0;
144
- for (let h = 0; h < mapaHorarios.length; h++) {
145
- if ((mapaHorarios[h] ?? 0) >= inicioMin && (mapaHorarios[h] ?? 0) < fimMin) {
146
- tamanhoColspan++;
147
- }
148
- }
149
- return { ...slot, colspan: tamanhoColspan > 0 ? tamanhoColspan : 1 };
150
- });
151
- return { ...rec, horarios_slot: slotsCalculados };
264
+ resultado.push({
265
+ data_slot: dia.data_slot,
266
+ colunasHorarios: definicaoColunas,
267
+ ...infoBlocos,
268
+ recursos_slot: recursosProcessados
152
269
  });
270
+ });
153
271
 
154
- return { ...dia, recursos_slot: recursosComColspan };
155
- }).filter((dia: any) => dia !== null);
272
+ return resultado;
156
273
  });
157
274
 
158
- // Recebe minutos totais e devolve a string formatada HH:MM.
159
- // É usada para exibir os horários no cabeçalho da tabela.
160
- const formatarHoraDeMinutos = (minutosTotais: number) => {
161
- const horas = Math.floor(minutosTotais / 60);
162
- const minutos = minutosTotais % 60;
163
- return `${String(horas).padStart(2, '0')}:${String(minutos).padStart(2, '0')}`;
164
- };
165
-
166
- // Recebe a string YYYY-MM-DD do dia e devolve o texto por extenso em português.
167
- // É usada nas barras divisórias de cada dia na tabela.
168
275
  const formatarData = (isoData: string) => {
169
- const apenasData = String(isoData).substring(0, 10);
170
- const [ano, mes, dia] = apenasData.split('-');
171
- const data = new Date(Number(ano), Number(mes) - 1, Number(dia));
172
- return data.toLocaleDateString('pt-BR', { weekday: 'long', day: '2-digit', month: 'long', year: 'numeric' });
173
- };
174
-
175
- // Recebe o ID do recurso e o objeto slot clicado e não devolve retorno.
176
- // Se estiver reservado, abre o modal de detalhes; se estiver livre e reservável, abre o modal de criação.
177
- const aoClicarSlotMatrix = (idRecurso: number, slot: any) => {
178
- if (slot.reservado_slot && slot.detalhes_reserva) {
179
- reservaSelecionadaDetalhes.value = slot.detalhes_reserva;
180
- modalDetalhesAberto.value = true;
181
- return;
182
- }
183
-
184
- if (!slot.reservavel_slot) return;
185
-
186
- const extrair = (isoString: string) => {
187
- const d = new Date(isoString);
188
- const ano = d.getFullYear();
189
- const mes = String(d.getMonth() + 1).padStart(2, '0');
190
- const dia = String(d.getDate()).padStart(2, '0');
191
- const horas = String(d.getHours()).padStart(2, '0');
192
- const min = String(d.getMinutes()).padStart(2, '0');
193
- return { data: `${ano}-${mes}-${dia}`, hora: `${horas}:${min}` };
194
- };
195
-
196
- const inicio = extrair(slot.data_inicio_slot);
197
- const fim = extrair(slot.data_fim_slot);
198
-
199
- dadosParaModal.value = {
200
- id_recurso: idRecurso,
201
- titulo_reserva: '',
202
- data_inicio: inicio.data,
203
- hora_inicio: inicio.hora,
204
- data_fim: fim.data,
205
- hora_fim: fim.hora
206
- };
207
-
208
- modalAberto.value = true;
276
+ return criarDataLocal(isoData).toLocaleDateString('pt-BR', {
277
+ weekday: 'long',
278
+ day: '2-digit',
279
+ month: 'long',
280
+ year: 'numeric'
281
+ });
209
282
  };
210
283
  </script>
211
284
 
@@ -216,11 +289,11 @@ const aoClicarSlotMatrix = (idRecurso: number, slot: any) => {
216
289
  :dados-iniciais="dadosParaModal"
217
290
  :usa-interceptador="props.usaInterceptador"
218
291
  :ocultar-titulo-modal="props.ocultarTituloModal"
219
- @before-save="$emit('before-save',$event)"
220
- @salvo="dispararBuscaMatrix"
292
+ @before-save="$emit('before-save', $event)"
293
+ @salvo="dispararBuscaSemanal"
221
294
  >
222
- <template #campos-extras>
223
- <slot name="campos-extras"></slot>
295
+ <template v-for="(_, nomeSlot) in $slots" :key="nomeSlot" #[nomeSlot]="slotProps">
296
+ <slot v-if="nomeSlot !== 'topo'" :name="nomeSlot" v-bind="slotProps ?? {}"></slot>
224
297
  </template>
225
298
  </ModalReserva>
226
299
 
@@ -228,7 +301,7 @@ const aoClicarSlotMatrix = (idRecurso: number, slot: any) => {
228
301
  v-model="modalDetalhesAberto"
229
302
  :reserva="reservaSelecionadaDetalhes"
230
303
  :titulo-padrao="props.tituloPadrao"
231
- @excluido="dispararBuscaMatrix"
304
+ @excluido="dispararBuscaSemanal"
232
305
  />
233
306
 
234
307
  <div class="aviso-vazio" v-if="carregando">
@@ -239,52 +312,125 @@ const aoClicarSlotMatrix = (idRecurso: number, slot: any) => {
239
312
  <div class="matriz-conteudo" v-else-if="dadosMatrix.datas_matriz">
240
313
  <slot name="topo"></slot>
241
314
 
242
- <div class="tabela-wrapper" v-if="matrizProcessada.length > 0">
243
- <table class="tabela-premium">
244
- <thead>
245
- <tr>
246
- <th class="coluna-fixa cabecalho-canto">Nome</th>
247
- <th v-for="minuto in horariosGlobaisEmMinutos" :key="minuto">
248
- {{ formatarHoraDeMinutos(minuto) }}
249
- </th>
250
- </tr>
251
- </thead>
252
- <tbody>
253
- <template v-for="dia in matrizProcessada" :key="dia.data_slot">
315
+ <div class="tabelas-scroll-container" v-if="diasProcessados.length > 0">
316
+ <div
317
+ class="bloco-dia-tabela"
318
+ v-for="dia in diasProcessados"
319
+ :key="dia.data_slot"
320
+ >
321
+ <table class="tabela-premium">
322
+ <thead>
254
323
  <tr class="linha-data">
255
- <td :colspan="horariosGlobaisEmMinutos.length + 1">
324
+ <th :colspan="dia.colunasHorarios.length + 1" class="th-barra-dia">
256
325
  <div class="barra-data-full">
257
326
  <span class="texto-data">{{ formatarData(dia.data_slot) }}</span>
258
327
  </div>
259
- </td>
328
+ </th>
260
329
  </tr>
261
330
 
331
+ <tr v-if="dia.temBlocos" class="linha-blocos-principais">
332
+ <th
333
+ class="coluna-fixa cabecalho-canto"
334
+ :rowspan="dia.temSubblocos ? 3 : 2"
335
+ >
336
+ Nome
337
+ </th>
338
+ <th
339
+ v-for="(bloco, idxB) in dia.blocosPrincipais"
340
+ :key="idxB"
341
+ :colspan="bloco.colspan"
342
+ :style="{
343
+ backgroundColor: bloco.cor,
344
+ borderColor: bloco.corBorda
345
+ }"
346
+ class="th-bloco-principal borda-col-bloco-principal"
347
+ >
348
+ <div class="titulo-bloco-principal">{{ bloco.nome }}</div>
349
+ <div class="subtitulo-bloco-principal">{{ bloco.horaInicio }} - {{ bloco.horaFim }}</div>
350
+ </th>
351
+ </tr>
352
+
353
+ <tr v-if="dia.temBlocos && dia.temSubblocos" class="linha-subblocos">
354
+ <th
355
+ v-for="(sub, idxS) in dia.subblocos"
356
+ :key="idxS"
357
+ :colspan="sub.colspan"
358
+ :style="{
359
+ backgroundColor: sub.cor,
360
+ borderColor: sub.corBorda
361
+ }"
362
+ class="th-subbloco"
363
+ :class="{
364
+ 'borda-col-bloco-principal': sub.fimDeBlocoPrincipal,
365
+ 'borda-col-subbloco': !sub.fimDeBlocoPrincipal
366
+ }"
367
+ >
368
+ {{ sub.etiqueta }}
369
+ </th>
370
+ </tr>
371
+
372
+ <tr class="linha-horarios-individuais">
373
+ <th v-if="!dia.temBlocos" class="coluna-fixa cabecalho-canto">Nome</th>
374
+ <th
375
+ v-for="(col, idxH) in dia.colunasHorarios"
376
+ :key="idxH"
377
+ :style="{
378
+ backgroundColor: dia.metadadosColunas[idxH]?.corFundo || 'var(--bg-primary)',
379
+ borderColor: dia.metadadosColunas[idxH]?.corBorda || 'var(--border-light)'
380
+ }"
381
+ class="th-horario-individual"
382
+ :class="{
383
+ 'borda-col-subbloco': dia.metadadosColunas[idxH]?.fimDeSubbloco,
384
+ 'borda-col-bloco-principal': dia.metadadosColunas[idxH]?.fimDeBlocoPrincipal
385
+ }"
386
+ >
387
+ <div class="empilhado-horarios">
388
+ <span class="hora-topo">{{ col.horaInicio }}</span>
389
+ <span class="hora-base">{{ col.horaFim }}</span>
390
+ </div>
391
+ </th>
392
+ </tr>
393
+ </thead>
394
+
395
+ <tbody>
262
396
  <tr v-for="recurso in dia.recursos_slot" :key="recurso.id_recurso">
263
397
  <td class="coluna-fixa nome-recurso">{{ recurso.nome_recurso }}</td>
264
- <td
265
- v-for="slot in recurso.horarios_slot"
266
- :key="slot.data_inicio_slot"
267
- :colspan="slot.colspan"
268
- >
269
- <div
270
- class="bloco-tempo"
398
+ <template v-for="slot in recurso.horarios_slot" :key="slot.data_inicio_slot">
399
+ <BlocoArrastavel
400
+ v-if="!slot.ocultaPorColspan"
401
+ tag="td"
402
+ :grupo="`${dia.data_slot}__${recurso.id_recurso}`"
403
+ :indice="slot.indiceColuna"
404
+ :item="{ slot, idRecurso: recurso.id_recurso, dataSlot: dia.data_slot }"
405
+ :disponivel="Boolean(slot.reservavel_slot && !slot.reservado_slot)"
406
+ :controlador="controladorArrastoHorizontal"
407
+ :colspan="slot.colspan || 1"
408
+ class="td-celula-slot"
271
409
  :class="{
272
- 'reservado': slot.reservado_slot,
273
- 'bloqueado': !slot.reservavel_slot && !slot.reservado_slot,
274
- 'livre': slot.reservavel_slot && !slot.reservado_slot,
275
- 'api-forjada': slot.detalhes_reserva?.origem_criacao_reserva === 'API'
410
+ 'slot-ocupado': slot.reservado_slot,
411
+ 'slot-api': slot.detalhes_reserva?.origem_criacao_reserva === 'API',
412
+ 'slot-selecionado-arrasto': controladorArrastoHorizontal.verificarSelecionado(`${dia.data_slot}__${recurso.id_recurso}`, slot.indiceColuna),
413
+ 'borda-col-subbloco': dia.metadadosColunas[slot.indiceColuna + (slot.colspan || 1) - 1]?.fimDeSubbloco,
414
+ 'borda-col-bloco-principal': dia.metadadosColunas[slot.indiceColuna + (slot.colspan || 1) - 1]?.fimDeBlocoPrincipal
415
+ }"
416
+ :style="{
417
+ backgroundColor: controladorArrastoHorizontal.verificarSelecionado(`${dia.data_slot}__${recurso.id_recurso}`, slot.indiceColuna)
418
+ ? 'var(--color-brand-light)'
419
+ : (dia.metadadosColunas[slot.indiceColuna]?.corFundo || 'var(--bg-primary)'),
420
+ borderColor: dia.metadadosColunas[slot.indiceColuna + (slot.colspan || 1) - 1]?.corBorda || 'var(--border-light)'
276
421
  }"
277
- @click="aoClicarSlotMatrix(recurso.id_recurso, slot)"
278
422
  >
279
- <span class="etiqueta-reserva" v-if="slot.reservado_slot">
280
- {{ slot.detalhes_reserva?.titulo_reserva?.trim() || props.tituloPadrao }}
281
- </span>
282
- </div>
283
- </td>
423
+ <div class="conteudo-slot-matrix" v-if="slot.reservado_slot">
424
+ <span class="etiqueta-reserva">
425
+ {{ slot.detalhes_reserva?.titulo_reserva?.trim() || props.tituloPadrao || 'Reservado' }}
426
+ </span>
427
+ </div>
428
+ </BlocoArrastavel>
429
+ </template>
284
430
  </tr>
285
- </template>
286
- </tbody>
287
- </table>
431
+ </tbody>
432
+ </table>
433
+ </div>
288
434
  </div>
289
435
 
290
436
  <div class="aviso-vazio" v-else>
@@ -295,79 +441,61 @@ const aoClicarSlotMatrix = (idRecurso: number, slot: any) => {
295
441
  </template>
296
442
 
297
443
  <style scoped>
298
- .matriz-container { width: 100%; height: 100%; background: var(--bg-primary); display: flex; flex-direction: column; }
444
+ .matriz-container { width: 100%; height: 100%; background: var(--bg-primary); display: flex; flex-direction: column; user-select: none; }
299
445
  .matriz-conteudo { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
300
446
  .aviso-vazio { display: flex; flex-direction: column; justify-content: center; align-items: center; flex-grow: 1; color: var(--text-secondary); font-weight: 500; font-size: 1rem; gap: 12px; }
301
447
  .spinner { width: 32px; height: 32px; border: 3px solid var(--border-light); border-top-color: var(--color-brand); border-radius: 50%; animation: spin 1s linear infinite; }
302
448
  @keyframes spin { to { transform: rotate(360deg); } }
303
- .tabela-wrapper {
304
- flex-grow: 1;
305
- overflow: auto;
306
- background: var(--bg-primary);
307
- }
308
- .tabela-premium { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 12.5px; text-align: center; table-layout: fixed; }
309
- .tabela-premium th, .tabela-premium td { padding: 3px; min-width: 65px; height: 48px; border-bottom: 1px solid var(--border-light); border-right: 1px solid var(--border-light); }
310
- .tabela-premium th:first-child, .tabela-premium td:first-child { border-left: 1px solid var(--border-light); }
311
- .tabela-premium thead th { border-top: 1px solid var(--border-light); background: var(--bg-primary); position: sticky; top: 0; z-index: 3; font-weight: 700; color: var(--text-primary); box-shadow: var(--shadow-sm); }
312
- .coluna-fixa { width: 200px; text-align: left; background: var(--bg-primary); position: sticky; left: 0; z-index: 1; }
313
- .cabecalho-canto { z-index: 4 !important; padding-left: 24px !important; }
314
449
 
315
- /* Célula do dia mantendo a colagem nas laterais (sem aquele gap) */
316
- .tabela-premium td {
317
- background: var(--bg-primary);
450
+ .tabelas-scroll-container { flex-grow: 1; overflow: auto; background: var(--bg-primary); display: flex; flex-direction: column; gap: 18px; padding-bottom: 24px; }
451
+ .bloco-dia-tabela { width: 100%; }
452
+ .tabela-premium { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 12.5px; text-align: center; }
453
+ .tabela-premium th, .tabela-premium td { padding: 4px 6px; min-width: 68px; height: 44px; border-bottom: 1px solid var(--border-light); border-right: 1px solid var(--border-light); box-sizing: border-box; }
454
+ .tabela-premium th:first-child, .tabela-premium td:first-child { border-left: 1px solid var(--border-light); }
455
+ .tabela-premium thead th { background: var(--bg-primary); font-weight: 700; color: var(--text-primary); }
456
+
457
+ .coluna-fixa { width: 200px; min-width: 200px; max-width: 200px; text-align: left; background: var(--bg-primary); position: sticky; left: 0; z-index: 2; border-right: 2px solid var(--border-light) !important; }
458
+ .cabecalho-canto { z-index: 3 !important; padding-left: 24px !important; background: var(--bg-primary) !important; }
459
+ .th-barra-dia { padding: 0 !important; height: auto !important; border-top: 1px solid #005291 !important; border-bottom: 1px solid #005291 !important; }
460
+ .th-bloco-principal { color: #111827 !important; padding: 6px 4px !important; height: 36px !important; border-bottom-width: 1.5px !important; border-bottom-style: solid !important; }
461
+ .titulo-bloco-principal { font-size: 13px; font-weight: 800; letter-spacing: 0.3px; line-height: 1.2; text-transform: uppercase; }
462
+ .subtitulo-bloco-principal { font-size: 10.5px; font-weight: 700; opacity: 0.9; }
463
+ .th-subbloco { color: #111827 !important; font-size: 11px !important; font-weight: 700 !important; height: 26px !important; padding: 2px 4px !important; border-bottom-width: 1.5px !important; border-bottom-style: solid !important; }
464
+ .th-horario-individual { height: 42px !important; padding: 4px 2px !important; color: #111827 !important; border-bottom-width: 2px !important; border-bottom-style: solid !important; }
465
+ .empilhado-horarios { display: flex; flex-direction: column; align-items: center; justify-content: center; line-height: 1.15; gap: 2px; }
466
+ .hora-topo, .hora-base { font-size: 11.5px; font-weight: 700; color: #111827; }
467
+
468
+ .td-celula-slot { cursor: pointer; transition: filter 0.12s ease, box-shadow 0.12s ease; vertical-align: middle; color: #111827 !important; }
469
+ .td-celula-slot:hover { filter: brightness(0.93); box-shadow: inset 0 0 0 1.5px var(--color-brand); }
470
+ .slot-selecionado-arrasto { box-shadow: inset 0 0 0 2px var(--color-brand) !important; }
471
+ .slot-ocupado {
472
+ font-weight: 700;
473
+ background-color: #e5e7eb !important;
474
+ background-image: repeating-linear-gradient(45deg, rgba(0, 0, 0, 0.06), rgba(0, 0, 0, 0.06) 10px, transparent 10px, transparent 20px) !important;
318
475
  }
319
476
 
320
-
321
- .barra-data-full {
322
- display: flex;
323
- align-items: center;
324
- justify-content: center;
325
- width: 100%;
326
- /* mistura 30% da sua cor com 70% de branco */
327
- background: color-mix(in srgb, var(--color-brand) 30%, white) !important;
328
- color: var(--text-primary);
329
- padding: 10px 0;
330
- border-radius: 0;
331
- box-shadow: none;
332
- box-sizing: border-box;
333
- border: none;
334
- text-align: center;
335
- }
336
- .texto-data {
337
-
338
- font-weight: 700; font-size: 13.5px; text-transform: capitalize; letter-spacing: 0.5px; }
339
- .nome-recurso {
340
- font-weight: 600;
341
- color: var(--text-primary);
342
- background: var(--bg-primary) !important;
343
- padding-left: 24px !important;
344
- box-shadow: 4px 0 8px rgba(0,0,0,0.02);
345
- }
346
- .linha-data td {
347
- background: var(--bg-secondary) !important;
348
- border: none !important;
349
- border-top: 1px solid var(--border-light) !important;
350
- border-bottom: 1px solid var(--border-light) !important;
351
- padding: 0 !important;
352
- }
353
- .bloco-tempo { display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; border-radius: 6px; transition: all 0.2s ease; box-sizing: border-box; padding: 4px; }
354
- .bloqueado { background-color: var(--border-light); background-image: repeating-linear-gradient(45deg, transparent, transparent 8px, rgba(0, 0, 0, 0.08) 8px, rgba(0, 0, 0, 0.08) 16px); cursor: not-allowed; border: 1px solid var(--border-editor); }
355
- .livre { background-color: transparent; cursor: pointer; }
356
- .livre:hover { background-color: var(--color-brand-light); box-shadow: inset 0 0 0 1px var(--color-brand); }
357
- .reservado { background: var(--color-brand); color: var(--text-inverse); border: 1px solid var(--color-brand); cursor: pointer; }
358
- .reservado:hover { filter: brightness(1.08); }
359
- .api-forjada { background: var(--color-success); color: var(--text-inverse); border: 1px solid var(--color-success); cursor: pointer; }
360
- .etiqueta-reserva { font-size: 11.5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: block; width: 100%; font-weight: 600; }
477
+ .conteudo-slot-matrix { display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; }
478
+ .etiqueta-reserva { font-size: 12px; font-weight: 700; color: #111827; line-height: 1.25; word-break: break-word; }
479
+ .borda-col-subbloco { border-right-width: 2px !important; }
480
+ .borda-col-bloco-principal { border-right-width: 2.5px !important; }
481
+ .barra-data-full { display: flex; align-items: center; justify-content: center; width: 100%; background-color: #0066B3 !important; color: #FFFFFF !important; padding: 9px 0; box-sizing: border-box; text-align: center; }
482
+ .texto-data { font-weight: 800; font-size: 13.5px; text-transform: uppercase; letter-spacing: 0.5px; }
483
+ .nome-recurso { font-weight: 700; color: var(--text-primary); background: var(--bg-primary) !important; padding-left: 20px !important; padding-right: 12px !important; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; box-shadow: 4px 0 8px rgba(0,0,0,0.03); }
361
484
  </style>
362
485
 
363
486
  <style>
364
-
365
- [data-bs-theme="dark"] .matriz-container .barra-data-full {
366
- background: color-mix(in srgb, var(--color-brand) 70%, black) !important;
367
- color:white;
368
- font-weight: bold;
369
- text-shadow: 0 0 2px rgba(0,0,0,0.3);
370
-
487
+ [data-bs-theme="dark"] .th-bloco-principal,
488
+ [data-bs-theme="dark"] .th-subbloco,
489
+ [data-bs-theme="dark"] .th-horario-individual,
490
+ [data-bs-theme="dark"] .hora-topo,
491
+ [data-bs-theme="dark"] .hora-base,
492
+ [data-bs-theme="dark"] .td-celula-slot,
493
+ [data-bs-theme="dark"] .etiqueta-reserva {
494
+ color: var(--text-primary) !important;
371
495
  }
372
496
 
497
+ [data-bs-theme="dark"] .slot-ocupado {
498
+ background-color: #374151 !important;
499
+ background-image: repeating-linear-gradient(45deg, rgba(255, 255, 255, 0.03), rgba(255, 255, 255, 0.03) 10px, transparent 10px, transparent 20px) !important;
500
+ }
373
501
  </style>