Repository: AppCivico/smae
Pull request #243: feat: tela de edição em lote com revisão, campo dinâmico e integração com backend
Author: login: Eduruiz
Created: 2025-04-10T22:58:18Z | Merged: 2025-04-11T17:16:42Z


- Cria componente CampoDinamico.vue para renderização dinâmica de inputs conforme o tipo
- Adiciona suporte a campos estáticos e dinâmicos (selects, datas, números e textos)
- Implementa modo de revisão com campos bloqueados
- Monta payload compatível para o backend
- Integra submissão com edicoesEmLoteStore e alertas de sucesso/erro
- Adiciona diversos campos no formSchema com metadados para edição em lote


Diff:
diff --git a/frontend/src/components/alteracaoEmLotes.componentes/CampoDinamico.vue b/frontend/src/components/alteracaoEmLotes.componentes/CampoDinamico.vue
new file mode 100644
index 0000000000..8b6c2d6afd
--- /dev/null
+++ b/frontend/src/components/alteracaoEmLotes.componentes/CampoDinamico.vue
@@ -0,0 +1,177 @@
+<script setup>
+import { onMounted, watch } from 'vue';
+import { Field, ErrorMessage } from 'vee-validate';
+import SmaeNumberInput from '@/components/camposDeFormulario/SmaeNumberInput.vue';
+import SmaeDateInput from '@/components/camposDeFormulario/SmaeDateInput.vue';
+
+const props = defineProps({
+  idx: Number,
+  config: Object,
+  modelValue: null,
+  errors: Object,
+  loadingOptions: Object,
+  storeInstances: Object,
+  fontesEstaticas: Object,
+  readonly: Boolean,
+});
+
+const emit = defineEmits(['update:modelValue']);
+
+function updateValue(value) {
+  emit('update:modelValue', value);
+}
+
+async function fetchOptionsIfNeeded() {
+  const meta = props.config?.meta;
+  if (
+    props.config?.tipo !== 'select-dinamico'
+    || !meta?.storeKey
+    || !meta?.fetchAction
+    || (!meta.listState && !meta.getterKey)
+  ) return;
+
+  const store = props.storeInstances[meta.storeKey];
+  if (!store || typeof store[meta.fetchAction] !== 'function') return;
+
+  const dataKey = meta.getterKey || meta.listState;
+  if (Array.isArray(store[dataKey]) && store[dataKey].length > 0) return;
+
+  await store[meta.fetchAction]();
+}
+
+watch(() => props.config?.tipo, async (tipo) => {
+  if (tipo === 'select-dinamico') await fetchOptionsIfNeeded();
+});
+
+onMounted(() => {
+  if (props.config?.tipo === 'select-dinamico') {
+    fetchOptionsIfNeeded();
+  }
+});
+
+function getOptionsForField(config) {
+  const meta = config?.meta;
+  if (!meta) return [];
+
+  if (config.tipo === 'select-estatico') {
+    return props.fontesEstaticas?.[meta.optionSource] || [];
+  }
+
+  if (config.tipo === 'select-dinamico') {
+    const store = props.storeInstances[meta.storeKey];
+    const list = meta.getterKey ? store[meta.getterKey] : store[meta.listState];
+    if (!list || !Array.isArray(list)) return [];
+
+    const getLabel = typeof meta.optionLabel === 'function'
+      ? meta.optionLabel
+      : (item) => item[meta.optionLabel];
+
+    return list.map((item) => ({
+      value: item[meta.optionValue],
+      label: getLabel(item),
+    }));
+  }
+
+  return [];
+}
+</script>
+
+<template>
+  <SmaeNumberInput
+    v-if="config?.tipo === 'number'"
+    :name="`edicoes[${idx}].valor`"
+    :model-value="modelValue"
+    class="inputtext light"
+    :aria-readonly="readonly"
+    :readonly="readonly"
+    @update:modelValue="updateValue"
+  />
+
+  <Field
+    v-if="config?.tipo === 'text'"
+    :name="`edicoes[${idx}].valor`"
+    type="text"
+    class="inputtext light"
+    :aria-readonly="readonly"
+    :readonly="readonly"
+    :model-value="modelValue"
+    @update:modelValue="updateValue"
+  />
+
+  <SmaeDateInput
+    v-if="config?.tipo === 'date'"
+    :name="`edicoes[${idx}].valor`"
+    :model-value="modelValue"
+    class="inputtext light"
+    :aria-readonly="readonly"
+    :readonly="readonly"
+    @update:modelValue="updateValue"
+  />
+
+  <Field
+    v-if="config?.tipo === 'select-estatico'"
+    :name="`edicoes[${idx}].valor`"
+    as="select"
+    class="inputtext light"
+    :model-value="modelValue"
+    :aria-readonly="readonly"
+    @mousedown="readonly && $event.preventDefault()"
+    @keydown="readonly && $event.preventDefault()"
+    @focus="readonly && $event.target.blur()"
+    @update:modelValue="updateValue"
+  >
+    <option
+      value=""
+      disabled
+    >
+      Selecione...
+    </option>
+    <option
+      v-for="opcao in getOptionsForField(config)"
+      :key="opcao.value"
+      :value="opcao.value"
+    >
+      {{ opcao.label }}
+    </option>
+  </Field>
+
+  <div v-if="config?.tipo === 'select-dinamico'">
+    <div
+      v-if="loadingOptions?.[`${idx}-${config?.meta?.storeKey}`]"
+      class="inputtext light mb1 disabled-placeholder"
+    >
+      Carregando opções...
+    </div>
+    <Field
+      v-else
+      :name="`edicoes[${idx}].valor`"
+      as="select"
+      class="inputtext light"
+      :model-value="modelValue"
+      :aria-readonly="readonly"
+      @mousedown="readonly && $event.preventDefault()"
+      @keydown="readonly && $event.preventDefault()"
+      @focus="readonly && $event.target.blur()"
+      @update:modelValue="updateValue"
+    >
+      <option
+        value=""
+        disabled
+      >
+        Selecione...
+      </option>
+      <option
+        v-for="opcao in getOptionsForField(config)"
+        :key="opcao.value"
+        :value="opcao.value"
+      >
+        {{ opcao.label }}
+      </option>
+    </Field>
+  </div>
+
+  <ErrorMessage
+    class="error-msg"
+    :name="`edicoes[${idx}].valor`"
+  />
+</template>
diff --git a/frontend/src/components/camposDeFormulario/SmaeDateInput.vue b/frontend/src/components/camposDeFormulario/SmaeDateInput.vue
new file mode 100644
index 0000000000..8d824f20c9
--- /dev/null
+++ b/frontend/src/components/camposDeFormulario/SmaeDateInput.vue
@@ -0,0 +1,78 @@
+<script setup>
+import { useField } from 'vee-validate';
+import {
+  computed,
+  toRef,
+} from 'vue';
+import { parseISO, isValid, format } from 'date-fns';
+
+const props = defineProps({
+  modelValue: {
+    type: [Date, String, null],
+    default: null,
+  },
+  name: {
+    type: String,
+    default: '',
+  },
+  converterPara: {
+    default: 'date',
+    type: String,
+    validator(valor) {
+      return ['date', 'string', 'text'].includes(valor);
+    },
+  },
+});
+
+const emit = defineEmits(['update:modelValue']);
+const nome = toRef(props, 'name');
+
+const { handleChange } = useField(nome, undefined, {
+  // eslint-disable-next-line no-nested-ternary
+  initialValue: props.modelValue
+    ? (
+      ['string', 'text'].includes(props.converterPara.toLowerCase())
+        ? String(props.modelValue)
+        : new Date(props.modelValue)
+    )
+    : null,
+});
+
+const valorDigitado = computed({
+  get() {
+    if (!props.modelValue || props.modelValue === '') return null;
+
+    const data = props.modelValue instanceof Date
+      ? props.modelValue
+      : parseISO(props.modelValue);
+
+    return isValid(data) ? format(data, 'yyyy-MM-dd') : null;
+  },
+
+  set(novoValor) {
+    const estaVazio = novoValor === '' || novoValor === null;
+
+    let valorFinal = null;
+
+    if (!estaVazio) {
+      const data = parseISO(novoValor);
+      if (isValid(data)) {
+        valorFinal = ['string', 'text'].includes(props.converterPara.toLowerCase())
+          ? format(data, 'yyyy-MM-dd')
+          : data;
+      }
+    }
+
+    handleChange(valorFinal);
+    emit('update:modelValue', valorFinal);
+  },
+});
+</script>
+
+<template>
+  <input
+    v-model="valorDigitado"
+    type="date"
+    :name="nome"
+  >
+</template>
diff --git a/frontend/src/components/camposDeFormulario/SmaeNumberInput.vue b/frontend/src/components/camposDeFormulario/SmaeNumberInput.vue
new file mode 100644
index 0000000000..e3ab5d4f5a
--- /dev/null
+++ b/frontend/src/components/camposDeFormulario/SmaeNumberInput.vue
@@ -0,0 +1,98 @@
+<script setup>
+import { useField } from 'vee-validate';
+import {
+  computed,
+  toRef,
+} from 'vue';
+
+const props = defineProps({
+  modelValue: {
+    type: [Number, String, null],
+    default: null,
+  },
+  name: {
+    type: String,
+    default: '',
+  },
+  converterPara: {
+    default: 'number',
+    type: String,
+    validator(valor) {
+      return ['number', 'string', 'text'].includes(valor);
+    },
+  },
+  max: {
+    type: Number,
+    default: 0,
+  },
+});
+
+const emit = defineEmits(['update:modelValue']);
+const name = toRef(props, 'name');
+
+const validarValorMaximo = (max, value) => {
+  if (max > 0) {
+    return value >= max ? max : value;
+  }
+  return value;
+};
+
+const { handleChange } = useField(name, undefined, {
+  // eslint-disable-next-line no-nested-ternary
+  initialValue: props.modelValue
+    ? (
+      ['string', 'text'].includes(props.converterPara.toLowerCase())
+        ? String(props.modelValue)
+        : Number(props.modelValue)
+    )
+    : null,
+});
+
+const typedValue = computed({
+  get() {
+    return props.modelValue === '' || props.modelValue === null
+      ? null
+      : props.modelValue;
+  },
+  set(newValue) {
+    const isEmpty = newValue === '' || newValue === null;
+
+    const parseToNumber = (val) => {
+      if (typeof val === 'string') {
+        const numericString = val.replace(/[^\d,.-]/g, '').replace(',', '.');
+        const parsed = parseFloat(numericString);
+        return Number.isNaN(parsed) ? null : parsed;
+      }
+
+      return typeof val === 'number' ? val : null;
+    };
+
+    const convertIfNeeded = (val) => {
+      if (val === null) return null;
+
+      let result = validarValorMaximo(Number(props.max), val);
+      if (['string', 'text'].includes(props.converterPara.toLowerCase())) {
+        result = String(result);
+      }
+      return result;
+    };
+
+    const cleanValue = isEmpty
+      ? null
+      : convertIfNeeded(parseToNumber(newValue));
+
+    handleChange(cleanValue);
+    emit('update:modelValue', cleanValue);
+  },
+});
+</script>
+
+<template>
+  <input
+    v-model="typedValue"
+    pattern="[0-9]*"
+    type="text"
+    inputmode="numeric"
+    :name="name"
+  >
+</template>
diff --git a/frontend/src/consts/formSchemas.js b/frontend/src/consts/formSchemas.js
index d485d45380..1c7c9cfd6e 100644
--- a/frontend/src/consts/formSchemas.js
+++ b/frontend/src/consts/formSchemas.js
@@ -1243,7 +1243,15 @@ export const obras = object({
   equipamento_id: number()
     .label('Equipamento/Estrutura pública')
     .min(1, 'Equipamento/Estrutura pública inválida')
-    .nullable(),
+    .nullable()
+    .meta({
+      permite_edicao_em_massa: true,
+      storeKey: 'equipamentos',
+      fetchAction: 'buscarTudo',
+      listState: 'lista',
+      optionValue: 'id',
+      optionLabel: 'nome',
+    }),
   fonte_recursos: array()
     .label('Fontes de recursos')
     .nullable()
@@ -1294,7 +1302,15 @@ export const obras = object({
   grupo_tematico_id: number()
     .label('Grupo temático')
     .min(1, 'Grupo temático inválido')
-    .required(),
+    .required()
+    .meta({
+      permite_edicao_em_massa: true,
+      storeKey: 'grupos_tematicos',
+      fetchAction: 'buscarTudo',
+      listState: 'lista',
+      optionValue: 'id',
+      optionLabel: 'nome',
+    }),
   iniciativa_id: number()
     .when(['origem_tipo', 'atividade_id'], {
       is: (origemTipo, atividadeId) => origemTipo === 'PdmSistema' && atividadeId,
@@ -1304,29 +1320,37 @@ export const obras = object({
   mdo_detalhamento: string()
     .label('Detalhamento/Escopo da obra')
     .max(50000)
-    .nullable(),
+    .nullable()
+    .meta({ permite_edicao_em_massa: true }),
   mdo_n_familias_beneficiadas: number()
     .label('Número de famílias beneficiadas')
+    .nullable()
     .min(0)
-    .nullable(),
+    .meta({ permite_edicao_em_massa: true }),
   mdo_n_unidades_habitacionais: number()
     .label('Número de unidades')
     .min(0)
-    .nullable(),
+    .nullable()
+    .meta({ permite_edicao_em_massa: true }),
   mdo_n_unidades_atendidas: number()
     .label('Número de unidades atendidas até o momento')
     .min(0)
     .nullable()
-    .transform((v) => (v === '' || Number.isNaN(v) ? null : v)),
+    .transform((v) => (v === '' || Number.isNaN(v) ? null : v))
+    .meta({ permite_edicao_em_massa: true }),
   mdo_observacoes: string()
     .label('Observações')
     .max(1024)
-    .nullable(),
+    .nullable()
+    .meta({ permite_edicao_em_massa: true }),
   mdo_previsao_inauguracao: date()
     .label('Data de inauguração planejada')
     .max(dataMax)
     .min(dataMin)
-    .nullable(),
+    .nullable()
+    .transform((curr, orig) => (orig === '' ? null : curr))
+    .typeError('Informe uma data válida (AAAA-MM-DD)')
+    .meta({ permite_edicao_em_massa: true }),
   meta_codigo: string()
     .label('Código da Meta')
     .when(['origem_tipo'], {
@@ -1352,11 +1376,27 @@ export const obras = object({
   orgao_executor_id: number()
     .label('Secretaria/órgão executor')
     .min(1, 'Secretaria/órgão executor inválidos')
-    .nullable(),
+    .nullable()
+    .meta({
+      permite_edicao_em_massa: true,
+      storeKey: 'órgãos',
+      fetchAction: 'getAll',
+      listState: 'organs',
+      optionValue: 'id',
+      optionLabel: (item) => `${item.sigla} - ${item.descricao}`,
+    }),
   orgao_gestor_id: number()
     .label('Órgão gestor do portfólio')
     .min(1, 'Órgão inválido')
-    .required(),
+    .required()
+    .meta({
+      permite_edicao_em_massa: true,
+      storeKey: 'órgãos',
+      fetchAction: 'getAll',
+      listState: 'organs',
+      optionValue: 'id',
+      optionLabel: (item) => `${item.sigla} - ${item.descricao}`,
+    }),
   orgao_origem_id: number()
     .label('Secretaria/órgão de origem')
     .min(1, 'Secretaria/órgão de origem inválidos')
@@ -1364,7 +1404,15 @@ export const obras = object({
   orgao_responsavel_id: number()
     .label('Órgão responsável pela obra')
     .min(1, 'Órgão responsável pela obra inválidos')
-    .nullable(),
+    .nullable()
+    .meta({
+      permite_edicao_em_massa: true,
+      storeKey: 'órgãos',
+      fetchAction: 'getAll',
+      listState: 'organs',
+      optionValue: 'id',
+      optionLabel: (item) => `${item.sigla} - ${item.descricao}`,
+    }),
   orgaos_colaboradores: string()
     .label('Órgãos colaboradores da obra')
     .nullable(),
@@ -1423,12 +1471,19 @@ export const obras = object({
     .label('Previsão de início')
     .max(dataMax)
     .min(dataMin)
-    .nullable(),
+    .nullable()
+    .meta({ permite_edicao_em_massa: true }),
   previsao_termino: date()
     .label('Previsão de término')
     .max(dataMax)
-    .min(ref('previsao_inicio'), 'Precisa ser posterior à data de início')
-    .nullable(),
+    .when('previsao_inicio', (inicio, schema) => {
+      if (inicio) {
+        return schema.min(inicio, 'Precisa ser posterior à data de início');
+      }
+      return schema;
+    })
+    .nullable()
+    .meta({ permite_edicao_em_massa: true }),
   programa_id: number()
     .label('Programa Habitacional')
     .positive()
@@ -1466,11 +1521,18 @@ export const obras = object({
     .nullable(),
   secretario: string()
     .label('Secretário gestor do portfólio')
-    .nullable(),
+    .nullable()
+    .meta({ permite_edicao_em_massa: true }),
   status: mixed()
     .label('Status')
     .oneOf(Object.keys(statusObras))
-    .required(),
+    .required()
+    .meta({
+      permite_edicao_em_massa: true,
+      optionSource: 'statusObras',
+      optionValue: 'value',
+      optionLabel: 'label',
+    }),
   tags: array()
     .label('Etiquetas')
     .of(
@@ -1481,7 +1543,15 @@ export const obras = object({
   tipo_intervencao_id: number()
     .label('Tipo de obra/intervenção')
     .min(1, 'Tipo de obra/intervenção inválido')
-    .required(),
+    .required()
+    .meta({
+      permite_edicao_em_massa: true,
+      storeKey: 'tipos_de_intervencao',
+      fetchAction: 'buscarTudo',
+      listState: 'lista',
+      optionValue: 'id',
+      optionLabel: 'nome',
+    }),
   tolerancia_atraso: number()
     .label('Percentual de tolerância com atraso')
     .min(0)
diff --git a/frontend/src/router/edicoesEmLote.js b/frontend/src/router/edicoesEmLote.js
index eea58ea37e..18c08df264 100644
--- a/frontend/src/router/edicoesEmLote.js
+++ b/frontend/src/router/edicoesEmLote.js
@@ -71,7 +71,7 @@ export default {
               name: 'edicoesEmLoteObrasNovoConstruir',
               component: () => import('@/views/edicoesEmLote/obras/EdicoesEmLoteObrasConstruir.vue'),
               meta: {
-                rotaDeEscape: 'edicoesEmLoteObrasNovo',
+                rotaDeEscape: 'edicoesEmLoteObras',
               },
             },
             {
diff --git a/frontend/src/views/edicoesEmLote/EdicoesEmLoteResumo.vue b/frontend/src/views/edicoesEmLote/EdicoesEmLoteResumo.vue
index 706c68d9b0..8f74ba5be7 100644
--- a/frontend/src/views/edicoesEmLote/EdicoesEmLoteResumo.vue
+++ b/frontend/src/views/edicoesEmLote/EdicoesEmLoteResumo.vue
@@ -1,4 +1,14 @@
-<script setup>
+<script setup lang="ts">
+import { useEdicoesEmLoteStore } from '@/stores/edicoesEmLote.store';
+import { storeToRefs } from 'pinia';
+import { useRoute } from 'vue-router';
+
+const route = useRoute();
+
+const edicoesEmLoteStore = useEdicoesEmLoteStore(route.meta.tipoDeAcoesEmLote as string);
+const { emFoco } = storeToRefs(edicoesEmLoteStore);
+
+edicoesEmLoteStore.buscarItem(route.params.edicaoEmLoteId as unknown as number);
 </script>
 <template>
   <CabecalhoDePagina />
@@ -6,4 +16,8 @@
   <p class="debug">
     Aqui carregamos os detalhes de uma edição criada.
   </p>
+
+  <pre>
+    emFoco: {{ emFoco }}
+  </pre>
 </template>
diff --git a/frontend/src/views/edicoesEmLote/obras/EdicoesEmLoteObrasConstruir.vue b/frontend/src/views/edicoesEmLote/obras/EdicoesEmLoteObrasConstruir.vue
index 290067e8f1..b585b079bd 100644
--- a/frontend/src/views/edicoesEmLote/obras/EdicoesEmLoteObrasConstruir.vue
+++ b/frontend/src/views/edicoesEmLote/obras/EdicoesEmLoteObrasConstruir.vue
@@ -1,11 +1,396 @@
 <script setup>
-// Aqui montamos a edição
+import {
+  object,
+  array,
+  string,
+  mixed,
+  lazy,
+} from 'yup';
+import { useEdicoesEmLoteStore } from '@/stores/edicoesEmLote.store';
+import {
+  Field, FieldArray, ErrorMessage, useForm,
+} from 'vee-validate';
+import { computed, ref } from 'vue';
+import { useAlertStore } from '@/stores/alert.store';
+import { useEquipamentosStore } from '@/stores/equipamentos.store';
+import { useGruposTematicosStore } from '@/stores/gruposTematicos.store';
+import { useOrgansStore } from '@/stores/organs.store';
+import { useTiposDeIntervencaoStore } from '@/stores/tiposDeIntervencao.store';
+import rawStatusObras from '@/consts/statusObras';
+import { obras as schemaObras } from '@/consts/formSchemas';
+import LabelFromYup from '@/components/camposDeFormulario/LabelFromYup.vue';
+import CabecalhoDePagina from '@/components/CabecalhoDePagina.vue';
+import CampoDinamico from '@/components/alteracaoEmLotes.componentes/CampoDinamico.vue';
+import SmaeFieldsetSubmit from '@/components/SmaeFieldsetSubmit.vue';
+import { useRoute, useRouter } from 'vue-router';
+
+const router = useRouter();
+const route = useRoute();
+const alertStore = useAlertStore();
+const edicoesEmLoteStore = useEdicoesEmLoteStore(route.meta.tipoDeAcoesEmLote);
+
+const modoRevisao = ref(false);
+
+const storeInstances = {
+  equipamentos: useEquipamentosStore(),
+  grupos_tematicos: useGruposTematicosStore(),
+  órgãos: useOrgansStore(),
+  tipos_de_intervencao: useTiposDeIntervencaoStore(),
+};
+
+const fontesEstaticas = {
+  statusObras: Object.values(rawStatusObras).map((item) => ({
+    value: item.valor,
+    label: item.nome,
+  })),
+};
+
+const loadingOptions = ref({});
+
+const schema = object({
+  edicoes: array().of(
+    object({
+      propriedade: string().required('Selecione o campo'),
+      valor: lazy((value, { parent }) => {
+        const { propriedade } = parent;
+        if (!propriedade) {
+          return mixed().nullable();
+        }
+        const campoSchemaOriginal = schemaObras.fields[propriedade];
+
+        if (!campoSchemaOriginal) {
+          return mixed().required('Configuração inválida.');
+        }
+        return campoSchemaOriginal.required('Informe o novo valor');
+      }),
+    }),
+  ).min(1, 'Adicione pelo menos uma edição'),
+});
+
+const {
+  values, errors, handleSubmit, setFieldValue,
+} = useForm({
+  validationSchema: schema,
+  initialValues: {
+    edicoes: [],
+  },
+});
+
+const camposDisponiveisParaEdicao = computed(() => Object.entries(schemaObras.fields)
+  .filter(([_, fieldSchema]) => fieldSchema.meta()?.permite_edicao_em_massa)
+  .map(([fieldName, fieldSchema]) => ({
+    value: fieldName,
+    label: fieldSchema.spec.label || fieldName,
+  })));
+
+function getOpcoesDisponiveis(rowIndex) {
+  const propriedadesSelecionadas = values.edicoes
+    ?.map((edicao, index) => (index !== rowIndex ? edicao.propriedade : null))
+    .filter((prop) => prop != null && prop !== '');
+
+  return camposDisponiveisParaEdicao.value.filter(
+    (opcao) => !propriedadesSelecionadas.includes(opcao.value),
+  );
+}
+
+function detectarTipoCampo(campoSchema, meta) {
+  if (meta.optionSource && fontesEstaticas[meta.optionSource]) {
+    return 'select-estatico';
+  }
+
+  if (meta.storeKey) {
+    return 'select-dinamico';
+  }
+
+  const tipoPorClasse = {
+    NumberSchema: 'number',
+    StringSchema: 'text',
+    ArraySchema: 'array',
+    DateSchema: 'date',
+    ObjectSchema: 'object',
+  };
+
+  const nomeDaClasse = campoSchema.constructor.name;
+  return tipoPorClasse[nomeDaClasse] || 'text';
+}
+
+function obterConfiguracaoCampo(nomeCampo) {
+  const campoSchema = schemaObras.fields[nomeCampo];
+  if (!campoSchema) return null;
+
+  const meta = campoSchema.meta?.() || {};
+  const tipo = detectarTipoCampo(campoSchema, meta);
+
+  return {
+    schema: campoSchema,
+    tipo,
+    label: campoSchema.spec?.label || nomeCampo,
+    meta,
+  };
+}
+
+function campoConfig(idx) {
+  const prop = values.edicoes?.[idx]?.propriedade;
+  return prop ? obterConfiguracaoCampo(prop) : null;
+}
+
+const onSubmit = handleSubmit(async (valores) => {
+  if (!modoRevisao.value) {
+    modoRevisao.value = true;
+    return;
+  }
+
+  const payload = {
+    tipo: route.meta.tipoDeAcoesEmLote,
+    ids: edicoesEmLoteStore.idsSelecionados,
+    ops: valores.edicoes.map((edicao) => ({
+      col: edicao.propriedade,
+      set: edicao.valor,
+    })),
+  };
+
+  try {
+    if (await edicoesEmLoteStore.salvarItem(payload)) {
+      alertStore.success('Edição realizada com sucesso.');
+
+      const rotaDeEscape = route.meta?.rotaDeEscape;
+      await edicoesEmLoteStore.limparIdsSelecionados();
+
+      if (rotaDeEscape) {
+        router.push(typeof rotaDeEscape === 'string' ? { name: rotaDeEscape } : rotaDeEscape);
+      }
+    }
+  } catch (error) {
+    alertStore.error(error);
+  }
+});
+
+async function fetchOptionsIfNeeded(rowIndex, fieldConfig) {
+  const { meta } = fieldConfig;
+
+  if (!meta?.storeKey || !meta.fetchAction || (!meta.listState && !meta.getterKey)) {
+    console.warn('Configuração de metadados incompleta:', fieldConfig);
+    return;
+  }
+
+  const store = storeInstances[meta.storeKey];
+  if (!store) {
+    console.error(`Store não encontrada para a chave: ${meta.storeKey}`);
+    return;
+  }
+
+  const dataSourceKey = meta.getterKey || meta.listState;
+  const jaTemDados = store[dataSourceKey]
+    && Array.isArray(store[dataSourceKey])
+    && store[dataSourceKey].length > 0;
+
+  if (jaTemDados) {
+    return;
+  }
+
+  const loadingKey = `${rowIndex}-${meta.storeKey}`;
+
+  if (loadingOptions.value[loadingKey]) {
+    return;
+  }
+
+  loadingOptions.value[loadingKey] = true;
+
+  try {
+    const fetchFunction = store[meta.fetchAction];
+    if (typeof fetchFunction !== 'function') {
+      throw new Error(`Ação '${meta.fetchAction}' não encontrada na store ${meta.storeKey}`);
+    }
+
+    await fetchFunction();
+  } catch (error) {
+    console.error(`Erro ao buscar dados para ${meta.storeKey}:`, error);
+  } finally {
+    loadingOptions.value[loadingKey] = false;
+  }
+}
+
+function handlePropertyChange(event, idx) {
+  const propriedadeSelecionada = event.target.value;
+  setFieldValue(`edicoes[${idx}].valor`, null);
+
+  if (propriedadeSelecionada) {
+    const fieldConfig = obterConfiguracaoCampo(propriedadeSelecionada);
+    if (fieldConfig?.tipo === 'select-dinamico' && fieldConfig.meta?.storeKey) {
+      fetchOptionsIfNeeded(idx, fieldConfig);
+    }
+  }
+}
 </script>
 
 <template>
   <CabecalhoDePagina />
-
-  <p class="debug">
-    Aqui montamos a edição.
+  <p v-if="modoRevisao">
+    Essa alteração será aplicada nas
+    {{ edicoesEmLoteStore.idsSelecionados.length }} obras selecionadas. Confirma a edição?
+  </p>
+  <p v-else>
+    Selecione o item que deseja editar nas
+    {{ edicoesEmLoteStore.idsSelecionados.length }} obras selecionadas.
   </p>
+  <form @submit.prevent="onSubmit">
+    <div class="mb2">
+      <FieldArray
+        v-slot="{ fields, push, remove }"
+        name="edicoes"
+      >
+        <div
+          v-for="(field, idx) in fields"
+          :key="field.key"
+          class="flex g2 mb1 items-start"
+        >
+          <div class="f1">
+            <LabelFromYup
+              :name="`edicoes[${idx}].propriedade`"
+              label="Editar"
+              class="tc300"
+            >
+              Editar
+            </LabelFromYup>
+            <Field
+              :name="`edicoes[${idx}].propriedade`"
+              as="select"
+              class="inputtext light mb1"
+              :class="{ error: errors?.[`edicoes[${idx}].propriedade`] }"
+              :aria-disabled="modoRevisao"
+              @mousedown="modoRevisao && $event.preventDefault()"
+              @keydown="modoRevisao && $event.preventDefault()"
+              @focus="modoRevisao && $event.target.blur()"
+              @change="(event) => handlePropertyChange(event, idx)"
+            >
+              <option
+                value=""
+                disabled
+              >
+                Selecione...
+              </option>
+              <option
+                v-for="opcao in getOpcoesDisponiveis(idx)"
+                :key="opcao.value"
+                :value="opcao.value"
+              >
+                {{ opcao.label }}
+              </option>
+            </Field>
+            <ErrorMessage
+              class="error-msg"
+              :name="`edicoes[${idx}].propriedade`"
+            />
+          </div>
+
+          <div class="f1">
+            <template v-if="values.edicoes?.[idx]?.propriedade">
+              <LabelFromYup
+                :name="`edicoes[${idx}].valor`"
+                class="tc300"
+              >
+                {{ campoConfig(idx).label || 'Valor' }}
+              </LabelFromYup>
+              <CampoDinamico
+                v-model="values.edicoes[idx].valor"
+                :idx="idx"
+                :config="campoConfig(idx)"
+                :errors="errors"
+                :loading-options="loadingOptions"
+                :store-instances="storeInstances"
+                :fontes-estaticas="fontesEstaticas"
+                :readonly="modoRevisao"
+              />
+            </template>
+            <template v-else>
+              <div class="label tc300">
+                Selecione
+              </div>
+              <div class="inputtext light mb1 disabled-placeholder">
+                Selecione um campo à esquerda
+              </div>
+              <ErrorMessage
+                class="error-msg"
+                :name="`edicoes[${idx}].valor`"
+              />
+            </template>
+          </div>
+
+          <button
+            v-if="!modoRevisao"
+            type="button"
+            class="like-a__text addlink self-end mb-1"
+            aria-label="Remover Edição"
+            title="Remover Edição"
+            @click="remove(idx)"
+          >
+            <svg
+              width="20"
+              height="20"
+            ><use xlink:href="#i_remove" /></svg>
+          </button>
+        </div>
+
+        <button
+          v-if="!modoRevisao"
+          class="like-a__text addlink"
+          type="button"
+          @click="push({ propriedade: '', valor: null })"
+        >
+          <svg
+            width="20"
+            height="20"
+          ><use xlink:href="#i_+" /></svg>Adicionar Edição
+        </button>
+      </FieldArray>
+      <ErrorMessage
+        name="edicoes"
+        class="error-msg mt1"
+      />
+    </div>
+
+    <SmaeFieldsetSubmit>
+      <template v-if="!modoRevisao">
+        <button
+          type="submit"
+          class="btn big"
+          :disabled="Object.keys(errors).length > 0 || values.edicoes?.length === 0"
+        >
+          Aplicar Edição
+        </button>
+      </template>
+
+      <template v-else>
+        <button
+          type="button"
+          class="btn big outline bgnone tcprimary"
+          @click="modoRevisao = false"
+        >
+          Retornar
+        </button>
+        <button
+          type="submit"
+          class="btn big"
+          @click="confirmarEdicao"
+        >
+          Confirmar Edição
+        </button>
+      </template>
+    </SmaeFieldsetSubmit>
+  </form>
 </template>
+
+<style scoped>
+.disabled-placeholder {
+  color: #aaa;
+  background-color: #f9f9f9;
+  border: 1px solid #ddd;
+  padding: 0.5rem;
+  font-size: 0.875rem;
+  line-height: 1.5;
+  border-radius: 4px;
+  height: 38px;
+  display: flex;
+  align-items: center;
+}
+</style>


Reviews:
user: login: robsonsobral
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-10T23:07:02Z
user: login: robsonsobral
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-10T23:12:09Z
user: login: robsonsobral
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-11T00:40:07Z
user: login: robsonsobral
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-11T00:44:20Z
user: login: GustavoFSoares
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-11T12:53:59Z
user: login: GustavoFSoares
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-11T12:54:32Z
user: login: GustavoFSoares
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-11T12:59:06Z
user: login: GustavoFSoares
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-11T13:03:34Z
user: login: Eduruiz
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-11T15:41:05Z
user: login: Eduruiz
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-11T16:02:09Z
user: login: Eduruiz
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-11T16:14:19Z
user: login: Eduruiz
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-11T16:28:32Z
user: login: Eduruiz
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-11T16:36:29Z
user: login: Eduruiz
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-11T16:37:21Z
user: login: Eduruiz
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-11T16:37:59Z
user: login: robsonsobral
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-11T16:52:37Z
user: login: robsonsobral
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-11T16:55:05Z
user: login: robsonsobral
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-11T16:56:00Z
user: login: robsonsobral
body: Você e seus arrays...
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-04-11T16:56:26Z

Review comments:
diff_hunk: @@ -1,11 +1,384 @@
 <script setup>
-// Aqui montamos a edição
+import * as yup from 'yup';
+import { useEdicoesEmLoteStore } from '@/stores/edicoesEmLote.store';
+import {
+  Field, FieldArray, ErrorMessage, useForm,
+} from 'vee-validate';
+import { computed, ref } from 'vue';
+import { useAlertStore } from '@/stores/alert.store';
+import { useEquipamentosStore } from '@/stores/equipamentos.store';
+import { useGruposTematicosStore } from '@/stores/gruposTematicos.store';
+import { useOrgansStore } from '@/stores/organs.store';
+import { useTiposDeIntervencaoStore } from '@/stores/tiposDeIntervencao.store';
+import rawStatusObras from '@/consts/statusObras';
+import { obras as schemaObras } from '@/consts/formSchemas';
+import LabelFromYup from '@/components/camposDeFormulario/LabelFromYup.vue';
+import CabecalhoDePagina from '@/components/CabecalhoDePagina.vue';
+import CampoDinamico from '@/components/alteracaoEmLotes.componentes/CampoDinamico.vue';
+import SmaeFieldsetSubmit from '@/components/SmaeFieldsetSubmit.vue';
+import { useRoute, useRouter } from 'vue-router';
+
+const router = useRouter();
+const route = useRoute();
+const alertStore = useAlertStore();
+const edicoesEmLoteStore = useEdicoesEmLoteStore(route.meta.tipoDeAcoesEmLote);
+
+const modoRevisao = ref(false);
+
+const storeInstances = {
+  equipamentos: useEquipamentosStore(),
+  grupos_tematicos: useGruposTematicosStore(),
+  órgãos: useOrgansStore(),
+  tipos_de_intervencao: useTiposDeIntervencaoStore(),
+};
+
+const fontesEstaticas = {
+  statusObras: Object.values(rawStatusObras).map((item) => ({
+    value: item.valor,
+    label: item.nome,
+  })),
+};
+
+const loadingOptions = ref({});
+
+const schema = yup.object({
+  edicoes: yup.array().of(
+    yup.object({
+      propriedade: yup.string().required('Selecione o campo'),
+      valor: yup.lazy((value, { parent }) => {
+        const { propriedade } = parent;
+        if (!propriedade) {
+          return yup.mixed().nullable();
+        }
+        const campoSchemaOriginal = schemaObras.fields[propriedade];
+
+        if (!campoSchemaOriginal) {
+          return yup.mixed().required('Configuração inválida.');
+        }
+        return campoSchemaOriginal.required('Informe o novo valor');
+      }),
+    }),
+  ).min(1, 'Adicione pelo menos uma edição'),
+});
+
+const {
+  values, errors, handleSubmit, setFieldValue,
+} = useForm({
+  validationSchema: schema,
+  initialValues: {
+    edicoes: [],
+  },
+});
+
+const camposDisponiveisParaEdicao = computed(() => Object.entries(schemaObras.fields)
+  .filter(([_, fieldSchema]) => fieldSchema.meta()?.permite_edicao_em_massa)
+  .map(([fieldName, fieldSchema]) => ({
+    value: fieldName,
+    label: fieldSchema.spec.label || fieldName,
+  })));
+
+function getOpcoesDisponiveis(rowIndex) {
+  const propriedadesSelecionadas = values.edicoes
+    ?.map((edicao, index) => (index !== rowIndex ? edicao.propriedade : null))
+    .filter((prop) => prop != null && prop !== '');
+
+  return camposDisponiveisParaEdicao.value.filter(
+    (opcao) => !propriedadesSelecionadas.includes(opcao.value),
+  );
+}
+
+function detectarTipoCampo(campoSchema, meta) {
+  if (meta.optionSource && fontesEstaticas[meta.optionSource]) {
+    return 'select-estatico';
+  }
+
+  if (meta.storeKey) {
+    return 'select-dinamico';
+  }
+
+  const tipoPorClasse = {
+    NumberSchema: 'number',
+    StringSchema: 'text',
+    ArraySchema: 'array',
+    DateSchema: 'date',
+    ObjectSchema: 'object',
+  };
+
+  const nomeDaClasse = campoSchema.constructor.name;
+  return tipoPorClasse[nomeDaClasse] || 'text';
+}
+
+function obterConfiguracaoCampo(nomeCampo) {
+  const campoSchema = schemaObras.fields[nomeCampo];
+  if (!campoSchema) return null;
+
+  const meta = campoSchema.meta?.() || {};
+  const tipo = detectarTipoCampo(campoSchema, meta);
+
+  return {
+    schema: campoSchema,
+    tipo,
+    label: campoSchema.spec?.label || nomeCampo,
+    meta,
+  };
+}
+
+function campoConfig(idx) {
+  const prop = values.edicoes?.[idx]?.propriedade;
+  return prop ? obterConfiguracaoCampo(prop) : null;
+}
+
+const onSubmit = handleSubmit(async (valores) => {
+  if (!modoRevisao.value) {
+    modoRevisao.value = true;
+    return;
+  }
+
+  const payload = {
+    tipo: route.meta.tipoDeAcoesEmLote,
+    ids: edicoesEmLoteStore.idsSelecionados,
+    ops: valores.edicoes.map((edicao) => ({
+      col: edicao.propriedade,
+      set: String(edicao.valor),
+    })),
+  };
+
+  try {
+    if (await edicoesEmLoteStore.salvarItem(payload)) {
+      alertStore.success('Edição realizada com sucesso.');
+
+      const rotaDeEscape = route.meta?.rotaDeEscape;
+
+      if (rotaDeEscape) {
+        router.push(typeof rotaDeEscape === 'string' ? { name: rotaDeEscape } : rotaDeEscape);
+      }
+    }
+  } catch (error) {
+    alertStore.error(error);
+  }
+});
+
+async function fetchOptionsIfNeeded(rowIndex, fieldConfig) {
+  const { meta } = fieldConfig;
+  if (!meta?.storeKey || !meta.fetchAction || (!meta.listState && !meta.getterKey)) {
+    console.warn('Configuração de metadados incompleta:', fieldConfig);
+    return;
+  }
+
+  const store = storeInstances[meta.storeKey];
+  if (!store) {
+    console.error(`Store não encontrada para a chave: ${meta.storeKey}`);
+    return;
+  }
+
+  const dataSourceKey = meta.getterKey || meta.listState;
+
+  if (
+    store[dataSourceKey]
+      && Array.isArray(store[dataSourceKey]) && store[dataSourceKey].length > 0) {
+    return;
+  }
+
+  const loadingKey = `${rowIndex}-${meta.storeKey}`;
+  if (loadingOptions.value[loadingKey]) {
+    return;
+  }
+
+  loadingOptions.value = { ...loadingOptions.value, [loadingKey]: true };
+
+  try {
+    if (typeof store[meta.fetchAction] !== 'function') {
+      throw new Error(`Ação '${meta.fetchAction}' não encontrada na store ${meta.storeKey}`);
+    }
+    await store[meta.fetchAction]();
+  } catch (error) {
+    console.error(`Erro ao buscar dados para ${meta.storeKey}:`, error);
+  } finally {
+    loadingOptions.value = { ...loadingOptions.value, [loadingKey]: false };
+  }
+}
+
+function handlePropertyChange(event, idx) {
+  const propriedadeSelecionada = event.target.value;
+  setFieldValue(`edicoes[${idx}].valor`, null);
+
+  if (propriedadeSelecionada) {
+    const fieldConfig = obterConfiguracaoCampo(propriedadeSelecionada);
+    if (fieldConfig?.tipo === 'select-dinamico' && fieldConfig.meta?.storeKey) {
+      fetchOptionsIfNeeded(idx, fieldConfig);
+    }
+  }
+}
 </script>
 
 <template>
   <CabecalhoDePagina />
-
-  <p class="debug">
-    Aqui montamos a edição.
+  <p v-if="modoRevisao">
+    Essa alteração será aplicada nas
+    {{ edicoesEmLoteStore.idsSelecionados.length }} obras selecionadas. Confirma a edição?
   </p>
+  <p v-else>
+    Selecione o item que deseja editar nas
+    {{ edicoesEmLoteStore.idsSelecionados.length }} obras selecionadas.
+  </p>
+  <form @submit.prevent="onSubmit">
+    <div class="mb2">
+      <FieldArray
+        v-slot="{ fields, push, remove }"
+        name="edicoes"
+      >
+        <div
+          v-for="(field, idx) in fields"
+          :key="field.key"
+          class="flex g2 mb1 items-start"
+        >
+          <div class="f1">
+            <LabelFromYup
+              :name="`edicoes[${idx}].propriedade`"
+              label="Editar"
+              class="tc300"
+            >
+              Editar
+            </LabelFromYup>
+            <Field
+              :name="`edicoes[${idx}].propriedade`"
+              as="select"
+              class="inputtext light mb1"
+              :class="{ error: errors?.[`edicoes[${idx}].propriedade`] }"
+              :aria-disabled="modoRevisao"
+              @mousedown="modoRevisao && $event.preventDefault()"
+              @keydown="modoRevisao && $event.preventDefault()"
+              @focus="modoRevisao && $event.target.blur()"
+              @change="(event) => handlePropertyChange(event, idx)"
+            >
+              <option
+                value=""
+                disabled
+              >
+                Selecione...
+              </option>
+              <option
+                v-for="opcao in getOpcoesDisponiveis(idx)"
+                :key="opcao.value"
+                :value="opcao.value"
+              >
+                {{ opcao.label }}
+              </option>
+            </Field>
+            <ErrorMessage
+              class="error-msg"
+              :name="`edicoes[${idx}].propriedade`"
+            />
+          </div>
+
+          <div class="f1">
+            <template v-if="values.edicoes?.[idx]?.propriedade">
+              <LabelFromYup
+                :name="`edicoes[${idx}].valor`"
+                class="tc300"
+              >
+                {{ campoConfig(idx).label || 'Valor' }}
+              </LabelFromYup>
+              <CampoDinamico
+                v-model="values.edicoes[idx].valor"
+                :idx="idx"
+                :config="campoConfig(idx)"
+                :errors="errors"
+                :loading-options="loadingOptions"
+                :store-instances="storeInstances"
+                :fontes-estaticas="fontesEstaticas"
+                :readonly="modoRevisao"
+              />
+            </template>
+            <template v-else>
+              <div class="label tc300">
+                Selecione
+              </div>
+              <div class="inputtext light mb1 disabled-placeholder">
+                Selecione um campo à esquerda
+              </div>
+              <ErrorMessage
+                class="error-msg"
+                :name="`edicoes[${idx}].valor`"
+              />
+            </template>
+          </div>
+
+          <button
+            v-if="!modoRevisao"
+            type="button"
+            class="like-a__text addlink self-end mb-1"
+            aria-label="Remover Edição"
+            title="Remover Edição"
+            @click="remove(idx)"
+          >
+            <svg
+              width="20"
+              height="20"
+            ><use xlink:href="#i_remove" /></svg>
+          </button>
+        </div>
+
+        <button
+          v-if="!modoRevisao"
+          class="like-a__text addlink"
+          type="button"
+          @click="push({ propriedade: '', valor: null })"
+        >
+          <svg
+            width="20"
+            height="20"
+          ><use xlink:href="#i_+" /></svg>Adicionar Edição
+        </button>
+      </FieldArray>
+      <ErrorMessage
+        name="edicoes"
+        class="error-msg mt1"
+      />
+    </div>
+
+    <SmaeFieldsetSubmit>
+      <template v-if="!modoRevisao">
+        <button
+          type="submit"
+          class="btn big"
+          :disabled="Object.keys(errors).length > 0 || values.edicoes?.length === 0"
+        >
+          Aplicar Edição
+        </button>
+      </template>
+
+      <template v-else>
+        <button
path: frontend/src/views/edicoesEmLote/obras/EdicoesEmLoteObrasConstruir.vue
user: login: robsonsobral
body: Acho que esse deveria ser um link.
created_at: 2025-04-10T23:07:02Z
author_association: COLLABORATOR
diff_hunk: @@ -1,11 +1,384 @@
 <script setup>
-// Aqui montamos a edição
+import * as yup from 'yup';
+import { useEdicoesEmLoteStore } from '@/stores/edicoesEmLote.store';
+import {
+  Field, FieldArray, ErrorMessage, useForm,
+} from 'vee-validate';
+import { computed, ref } from 'vue';
+import { useAlertStore } from '@/stores/alert.store';
+import { useEquipamentosStore } from '@/stores/equipamentos.store';
+import { useGruposTematicosStore } from '@/stores/gruposTematicos.store';
+import { useOrgansStore } from '@/stores/organs.store';
+import { useTiposDeIntervencaoStore } from '@/stores/tiposDeIntervencao.store';
+import rawStatusObras from '@/consts/statusObras';
+import { obras as schemaObras } from '@/consts/formSchemas';
+import LabelFromYup from '@/components/camposDeFormulario/LabelFromYup.vue';
+import CabecalhoDePagina from '@/components/CabecalhoDePagina.vue';
+import CampoDinamico from '@/components/alteracaoEmLotes.componentes/CampoDinamico.vue';
+import SmaeFieldsetSubmit from '@/components/SmaeFieldsetSubmit.vue';
+import { useRoute, useRouter } from 'vue-router';
+
+const router = useRouter();
+const route = useRoute();
+const alertStore = useAlertStore();
+const edicoesEmLoteStore = useEdicoesEmLoteStore(route.meta.tipoDeAcoesEmLote);
+
+const modoRevisao = ref(false);
+
+const storeInstances = {
+  equipamentos: useEquipamentosStore(),
+  grupos_tematicos: useGruposTematicosStore(),
+  órgãos: useOrgansStore(),
+  tipos_de_intervencao: useTiposDeIntervencaoStore(),
+};
+
+const fontesEstaticas = {
+  statusObras: Object.values(rawStatusObras).map((item) => ({
+    value: item.valor,
+    label: item.nome,
+  })),
+};
+
+const loadingOptions = ref({});
+
+const schema = yup.object({
+  edicoes: yup.array().of(
+    yup.object({
+      propriedade: yup.string().required('Selecione o campo'),
+      valor: yup.lazy((value, { parent }) => {
+        const { propriedade } = parent;
+        if (!propriedade) {
+          return yup.mixed().nullable();
+        }
+        const campoSchemaOriginal = schemaObras.fields[propriedade];
+
+        if (!campoSchemaOriginal) {
+          return yup.mixed().required('Configuração inválida.');
+        }
+        return campoSchemaOriginal.required('Informe o novo valor');
+      }),
+    }),
+  ).min(1, 'Adicione pelo menos uma edição'),
+});
+
+const {
+  values, errors, handleSubmit, setFieldValue,
+} = useForm({
+  validationSchema: schema,
+  initialValues: {
+    edicoes: [],
+  },
+});
+
+const camposDisponiveisParaEdicao = computed(() => Object.entries(schemaObras.fields)
+  .filter(([_, fieldSchema]) => fieldSchema.meta()?.permite_edicao_em_massa)
+  .map(([fieldName, fieldSchema]) => ({
+    value: fieldName,
+    label: fieldSchema.spec.label || fieldName,
+  })));
+
+function getOpcoesDisponiveis(rowIndex) {
+  const propriedadesSelecionadas = values.edicoes
path: frontend/src/views/edicoesEmLote/obras/EdicoesEmLoteObrasConstruir.vue
user: login: robsonsobral
body: deus quer um reduce aqui.
created_at: 2025-04-10T23:10:42Z
author_association: COLLABORATOR
diff_hunk: @@ -1,11 +1,384 @@
 <script setup>
-// Aqui montamos a edição
+import * as yup from 'yup';
+import { useEdicoesEmLoteStore } from '@/stores/edicoesEmLote.store';
+import {
+  Field, FieldArray, ErrorMessage, useForm,
+} from 'vee-validate';
+import { computed, ref } from 'vue';
+import { useAlertStore } from '@/stores/alert.store';
+import { useEquipamentosStore } from '@/stores/equipamentos.store';
+import { useGruposTematicosStore } from '@/stores/gruposTematicos.store';
+import { useOrgansStore } from '@/stores/organs.store';
+import { useTiposDeIntervencaoStore } from '@/stores/tiposDeIntervencao.store';
+import rawStatusObras from '@/consts/statusObras';
+import { obras as schemaObras } from '@/consts/formSchemas';
+import LabelFromYup from '@/components/camposDeFormulario/LabelFromYup.vue';
+import CabecalhoDePagina from '@/components/CabecalhoDePagina.vue';
+import CampoDinamico from '@/components/alteracaoEmLotes.componentes/CampoDinamico.vue';
+import SmaeFieldsetSubmit from '@/components/SmaeFieldsetSubmit.vue';
+import { useRoute, useRouter } from 'vue-router';
+
+const router = useRouter();
+const route = useRoute();
+const alertStore = useAlertStore();
+const edicoesEmLoteStore = useEdicoesEmLoteStore(route.meta.tipoDeAcoesEmLote);
+
+const modoRevisao = ref(false);
+
+const storeInstances = {
+  equipamentos: useEquipamentosStore(),
+  grupos_tematicos: useGruposTematicosStore(),
+  órgãos: useOrgansStore(),
+  tipos_de_intervencao: useTiposDeIntervencaoStore(),
+};
+
+const fontesEstaticas = {
+  statusObras: Object.values(rawStatusObras).map((item) => ({
+    value: item.valor,
+    label: item.nome,
+  })),
+};
+
+const loadingOptions = ref({});
+
+const schema = yup.object({
+  edicoes: yup.array().of(
+    yup.object({
+      propriedade: yup.string().required('Selecione o campo'),
+      valor: yup.lazy((value, { parent }) => {
+        const { propriedade } = parent;
+        if (!propriedade) {
+          return yup.mixed().nullable();
+        }
+        const campoSchemaOriginal = schemaObras.fields[propriedade];
+
+        if (!campoSchemaOriginal) {
+          return yup.mixed().required('Configuração inválida.');
+        }
+        return campoSchemaOriginal.required('Informe o novo valor');
+      }),
+    }),
+  ).min(1, 'Adicione pelo menos uma edição'),
+});
+
+const {
+  values, errors, handleSubmit, setFieldValue,
+} = useForm({
+  validationSchema: schema,
+  initialValues: {
+    edicoes: [],
+  },
+});
+
+const camposDisponiveisParaEdicao = computed(() => Object.entries(schemaObras.fields)
+  .filter(([_, fieldSchema]) => fieldSchema.meta()?.permite_edicao_em_massa)
path: frontend/src/views/edicoesEmLote/obras/EdicoesEmLoteObrasConstruir.vue
user: login: robsonsobral
body: deus quer um reduce aqui
created_at: 2025-04-10T23:10:55Z
author_association: COLLABORATOR
diff_hunk: @@ -1,11 +1,384 @@
 <script setup>
-// Aqui montamos a edição
+import * as yup from 'yup';
path: frontend/src/views/edicoesEmLote/obras/EdicoesEmLoteObrasConstruir.vue
user: login: robsonsobral
body: Importar as partes do YUP melhora a performance.
created_at: 2025-04-10T23:11:52Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,78 @@
+<script setup>
+import { useField } from 'vee-validate';
+import {
+  computed,
+  toRef,
+} from 'vue';
+import { parseISO, isValid, format } from 'date-fns';
+
+const props = defineProps({
+  modelValue: {
+    type: [Date, String, null],
+    default: null,
+  },
+  name: {
+    type: String,
+    default: '',
+  },
+  converterPara: {
+    default: 'date',
+    type: String,
+    validator(valor) {
+      return ['date', 'string', 'text'].includes(valor);
+    },
+  },
+});
+
+const emit = defineEmits(['update:modelValue']);
+const nome = toRef(props, 'name');
+
+const { handleChange } = useField(nome, undefined, {
+  // eslint-disable-next-line no-nested-ternary
+  initialValue: props.modelValue
+    ? (
+      ['string', 'text'].includes(props.converterPara.toLowerCase())
+        ? String(props.modelValue)
+        : new Date(props.modelValue)
+    )
+    : null,
+});
+
+const valorDigitado = computed({
path: frontend/src/components/camposDeFormulario/SmaeDateInput.vue
user: login: robsonsobral
body: O @GustavoFSoares acha que isso é magia negra.
created_at: 2025-04-11T00:40:07Z
author_association: COLLABORATOR
diff_hunk: @@ -1352,19 +1376,43 @@ export const obras = object({
   orgao_executor_id: number()
     .label('Secretaria/órgão executor')
     .min(1, 'Secretaria/órgão executor inválidos')
-    .nullable(),
+    .nullable()
+    .meta({
+      permite_edicao_em_massa: true,
+      storeKey: 'órgãos',
+      fetchAction: 'getAll',
+      listState: 'organs',
+      optionValue: 'id',
+      optionLabel: (item) => `${item.sigla} - ${item.descricao}`,
path: frontend/src/consts/formSchemas.js
user: login: robsonsobral
body: Isso ficou bem bom!
created_at: 2025-04-11T00:44:20Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,78 @@
+<script setup>
+import { useField } from 'vee-validate';
+import {
+  computed,
+  toRef,
+} from 'vue';
+import { parseISO, isValid, format } from 'date-fns';
+
+const props = defineProps({
+  modelValue: {
+    type: [Date, String, null],
+    default: null,
+  },
+  name: {
+    type: String,
+    default: '',
+  },
+  converterPara: {
+    default: 'date',
+    type: String,
+    validator(valor) {
+      return ['date', 'string', 'text'].includes(valor);
+    },
+  },
+});
+
+const emit = defineEmits(['update:modelValue']);
+const nome = toRef(props, 'name');
+
+const { handleChange } = useField(nome, undefined, {
+  // eslint-disable-next-line no-nested-ternary
+  initialValue: props.modelValue
+    ? (
+      ['string', 'text'].includes(props.converterPara.toLowerCase())
+        ? String(props.modelValue)
+        : new Date(props.modelValue)
+    )
+    : null,
+});
+
+const valorDigitado = computed({
path: frontend/src/components/camposDeFormulario/SmaeDateInput.vue
user: login: GustavoFSoares
body: eca kk
created_at: 2025-04-11T12:53:59Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,78 @@
+<script setup>
+import { useField } from 'vee-validate';
+import {
+  computed,
+  toRef,
+} from 'vue';
+import { parseISO, isValid, format } from 'date-fns';
+
+const props = defineProps({
+  modelValue: {
+    type: [Date, String, null],
+    default: null,
+  },
+  name: {
+    type: String,
+    default: '',
+  },
+  converterPara: {
+    default: 'date',
+    type: String,
+    validator(valor) {
+      return ['date', 'string', 'text'].includes(valor);
+    },
+  },
+});
+
+const emit = defineEmits(['update:modelValue']);
+const nome = toRef(props, 'name');
+
+const { handleChange } = useField(nome, undefined, {
+  // eslint-disable-next-line no-nested-ternary
+  initialValue: props.modelValue
+    ? (
+      ['string', 'text'].includes(props.converterPara.toLowerCase())
+        ? String(props.modelValue)
+        : new Date(props.modelValue)
+    )
+    : null,
+});
+
+const valorDigitado = computed({
path: frontend/src/components/camposDeFormulario/SmaeDateInput.vue
user: login: GustavoFSoares
body: mas de fato é relevante
created_at: 2025-04-11T12:54:32Z
author_association: COLLABORATOR
diff_hunk: @@ -1,11 +1,384 @@
 <script setup>
-// Aqui montamos a edição
+import * as yup from 'yup';
+import { useEdicoesEmLoteStore } from '@/stores/edicoesEmLote.store';
+import {
+  Field, FieldArray, ErrorMessage, useForm,
+} from 'vee-validate';
+import { computed, ref } from 'vue';
+import { useAlertStore } from '@/stores/alert.store';
+import { useEquipamentosStore } from '@/stores/equipamentos.store';
+import { useGruposTematicosStore } from '@/stores/gruposTematicos.store';
+import { useOrgansStore } from '@/stores/organs.store';
+import { useTiposDeIntervencaoStore } from '@/stores/tiposDeIntervencao.store';
+import rawStatusObras from '@/consts/statusObras';
+import { obras as schemaObras } from '@/consts/formSchemas';
+import LabelFromYup from '@/components/camposDeFormulario/LabelFromYup.vue';
+import CabecalhoDePagina from '@/components/CabecalhoDePagina.vue';
+import CampoDinamico from '@/components/alteracaoEmLotes.componentes/CampoDinamico.vue';
+import SmaeFieldsetSubmit from '@/components/SmaeFieldsetSubmit.vue';
+import { useRoute, useRouter } from 'vue-router';
+
+const router = useRouter();
+const route = useRoute();
+const alertStore = useAlertStore();
+const edicoesEmLoteStore = useEdicoesEmLoteStore(route.meta.tipoDeAcoesEmLote);
+
+const modoRevisao = ref(false);
+
+const storeInstances = {
+  equipamentos: useEquipamentosStore(),
+  grupos_tematicos: useGruposTematicosStore(),
+  órgãos: useOrgansStore(),
+  tipos_de_intervencao: useTiposDeIntervencaoStore(),
+};
+
+const fontesEstaticas = {
+  statusObras: Object.values(rawStatusObras).map((item) => ({
+    value: item.valor,
+    label: item.nome,
+  })),
+};
+
+const loadingOptions = ref({});
+
+const schema = yup.object({
+  edicoes: yup.array().of(
+    yup.object({
+      propriedade: yup.string().required('Selecione o campo'),
+      valor: yup.lazy((value, { parent }) => {
+        const { propriedade } = parent;
+        if (!propriedade) {
+          return yup.mixed().nullable();
+        }
+        const campoSchemaOriginal = schemaObras.fields[propriedade];
+
+        if (!campoSchemaOriginal) {
+          return yup.mixed().required('Configuração inválida.');
+        }
+        return campoSchemaOriginal.required('Informe o novo valor');
+      }),
+    }),
+  ).min(1, 'Adicione pelo menos uma edição'),
+});
+
+const {
+  values, errors, handleSubmit, setFieldValue,
+} = useForm({
+  validationSchema: schema,
+  initialValues: {
+    edicoes: [],
+  },
+});
+
+const camposDisponiveisParaEdicao = computed(() => Object.entries(schemaObras.fields)
+  .filter(([_, fieldSchema]) => fieldSchema.meta()?.permite_edicao_em_massa)
+  .map(([fieldName, fieldSchema]) => ({
+    value: fieldName,
+    label: fieldSchema.spec.label || fieldName,
+  })));
+
+function getOpcoesDisponiveis(rowIndex) {
+  const propriedadesSelecionadas = values.edicoes
+    ?.map((edicao, index) => (index !== rowIndex ? edicao.propriedade : null))
+    .filter((prop) => prop != null && prop !== '');
+
+  return camposDisponiveisParaEdicao.value.filter(
+    (opcao) => !propriedadesSelecionadas.includes(opcao.value),
+  );
+}
+
+function detectarTipoCampo(campoSchema, meta) {
+  if (meta.optionSource && fontesEstaticas[meta.optionSource]) {
+    return 'select-estatico';
+  }
+
+  if (meta.storeKey) {
+    return 'select-dinamico';
+  }
+
+  const tipoPorClasse = {
+    NumberSchema: 'number',
+    StringSchema: 'text',
+    ArraySchema: 'array',
+    DateSchema: 'date',
+    ObjectSchema: 'object',
+  };
+
+  const nomeDaClasse = campoSchema.constructor.name;
+  return tipoPorClasse[nomeDaClasse] || 'text';
+}
+
+function obterConfiguracaoCampo(nomeCampo) {
+  const campoSchema = schemaObras.fields[nomeCampo];
+  if (!campoSchema) return null;
+
+  const meta = campoSchema.meta?.() || {};
+  const tipo = detectarTipoCampo(campoSchema, meta);
+
+  return {
+    schema: campoSchema,
+    tipo,
+    label: campoSchema.spec?.label || nomeCampo,
+    meta,
+  };
+}
+
+function campoConfig(idx) {
+  const prop = values.edicoes?.[idx]?.propriedade;
+  return prop ? obterConfiguracaoCampo(prop) : null;
+}
+
+const onSubmit = handleSubmit(async (valores) => {
+  if (!modoRevisao.value) {
+    modoRevisao.value = true;
+    return;
+  }
+
+  const payload = {
+    tipo: route.meta.tipoDeAcoesEmLote,
+    ids: edicoesEmLoteStore.idsSelecionados,
+    ops: valores.edicoes.map((edicao) => ({
+      col: edicao.propriedade,
+      set: String(edicao.valor),
+    })),
+  };
+
+  try {
+    if (await edicoesEmLoteStore.salvarItem(payload)) {
+      alertStore.success('Edição realizada com sucesso.');
+
+      const rotaDeEscape = route.meta?.rotaDeEscape;
+
+      if (rotaDeEscape) {
+        router.push(typeof rotaDeEscape === 'string' ? { name: rotaDeEscape } : rotaDeEscape);
+      }
+    }
+  } catch (error) {
+    alertStore.error(error);
+  }
+});
+
+async function fetchOptionsIfNeeded(rowIndex, fieldConfig) {
+  const { meta } = fieldConfig;
+  if (!meta?.storeKey || !meta.fetchAction || (!meta.listState && !meta.getterKey)) {
+    console.warn('Configuração de metadados incompleta:', fieldConfig);
+    return;
+  }
+
+  const store = storeInstances[meta.storeKey];
+  if (!store) {
+    console.error(`Store não encontrada para a chave: ${meta.storeKey}`);
+    return;
+  }
+
+  const dataSourceKey = meta.getterKey || meta.listState;
+
+  if (
+    store[dataSourceKey]
+      && Array.isArray(store[dataSourceKey]) && store[dataSourceKey].length > 0) {
+    return;
+  }
+
+  const loadingKey = `${rowIndex}-${meta.storeKey}`;
+  if (loadingOptions.value[loadingKey]) {
+    return;
+  }
+
+  loadingOptions.value = { ...loadingOptions.value, [loadingKey]: true };
path: frontend/src/views/edicoesEmLote/obras/EdicoesEmLoteObrasConstruir.vue
user: login: GustavoFSoares
body: Deveríamos utilizar um deepClone aqui @robsonsobral ?
created_at: 2025-04-11T12:59:06Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,78 @@
+<script setup>
+import { useField } from 'vee-validate';
+import {
+  computed,
+  toRef,
+} from 'vue';
+import { parseISO, isValid, format } from 'date-fns';
+
+const props = defineProps({
+  modelValue: {
+    type: [Date, String, null],
+    default: null,
+  },
+  name: {
+    type: String,
+    default: '',
+  },
+  converterPara: {
+    default: 'date',
+    type: String,
+    validator(valor) {
+      return ['date', 'string', 'text'].includes(valor);
+    },
+  },
+});
+
+const emit = defineEmits(['update:modelValue']);
+const nome = toRef(props, 'name');
+
+const { handleChange } = useField(nome, undefined, {
path: frontend/src/components/camposDeFormulario/SmaeDateInput.vue
user: login: GustavoFSoares
body: @Eduruiz @robsonsobral 

Agora está funcionando mas gostaria de levantar a discussão:
Vocês não concordam que jogar o VeeValidate para dentro do input não é acoplar muito as lógicas?
Eu costumo preferir o input ser apenas um input e emitir um `evento` para o pai, e o pai normalmente é um `<Field>` esperando um evento `change` ou algo do tipo.

Tenho medo de acoplar porque as coisas costumam ser editadas e não sabemos de onde vem, sendo difícil debugar
created_at: 2025-04-11T13:03:34Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,78 @@
+<script setup>
+import { useField } from 'vee-validate';
+import {
+  computed,
+  toRef,
+} from 'vue';
+import { parseISO, isValid, format } from 'date-fns';
+
+const props = defineProps({
+  modelValue: {
+    type: [Date, String, null],
+    default: null,
+  },
+  name: {
+    type: String,
+    default: '',
+  },
+  converterPara: {
+    default: 'date',
+    type: String,
+    validator(valor) {
+      return ['date', 'string', 'text'].includes(valor);
+    },
+  },
+});
+
+const emit = defineEmits(['update:modelValue']);
+const nome = toRef(props, 'name');
+
+const { handleChange } = useField(nome, undefined, {
+  // eslint-disable-next-line no-nested-ternary
+  initialValue: props.modelValue
+    ? (
+      ['string', 'text'].includes(props.converterPara.toLowerCase())
+        ? String(props.modelValue)
+        : new Date(props.modelValue)
+    )
+    : null,
+});
+
+const valorDigitado = computed({
path: frontend/src/components/camposDeFormulario/SmaeDateInput.vue
user: login: Eduruiz
body: mas você acha que fica claro @GustavoFSoares? ou muito difícil de ler?
created_at: 2025-04-11T15:41:05Z
author_association: COLLABORATOR
diff_hunk: @@ -1,11 +1,384 @@
 <script setup>
-// Aqui montamos a edição
+import * as yup from 'yup';
+import { useEdicoesEmLoteStore } from '@/stores/edicoesEmLote.store';
+import {
+  Field, FieldArray, ErrorMessage, useForm,
+} from 'vee-validate';
+import { computed, ref } from 'vue';
+import { useAlertStore } from '@/stores/alert.store';
+import { useEquipamentosStore } from '@/stores/equipamentos.store';
+import { useGruposTematicosStore } from '@/stores/gruposTematicos.store';
+import { useOrgansStore } from '@/stores/organs.store';
+import { useTiposDeIntervencaoStore } from '@/stores/tiposDeIntervencao.store';
+import rawStatusObras from '@/consts/statusObras';
+import { obras as schemaObras } from '@/consts/formSchemas';
+import LabelFromYup from '@/components/camposDeFormulario/LabelFromYup.vue';
+import CabecalhoDePagina from '@/components/CabecalhoDePagina.vue';
+import CampoDinamico from '@/components/alteracaoEmLotes.componentes/CampoDinamico.vue';
+import SmaeFieldsetSubmit from '@/components/SmaeFieldsetSubmit.vue';
+import { useRoute, useRouter } from 'vue-router';
+
+const router = useRouter();
+const route = useRoute();
+const alertStore = useAlertStore();
+const edicoesEmLoteStore = useEdicoesEmLoteStore(route.meta.tipoDeAcoesEmLote);
+
+const modoRevisao = ref(false);
+
+const storeInstances = {
+  equipamentos: useEquipamentosStore(),
+  grupos_tematicos: useGruposTematicosStore(),
+  órgãos: useOrgansStore(),
+  tipos_de_intervencao: useTiposDeIntervencaoStore(),
+};
+
+const fontesEstaticas = {
+  statusObras: Object.values(rawStatusObras).map((item) => ({
+    value: item.valor,
+    label: item.nome,
+  })),
+};
+
+const loadingOptions = ref({});
+
+const schema = yup.object({
+  edicoes: yup.array().of(
+    yup.object({
+      propriedade: yup.string().required('Selecione o campo'),
+      valor: yup.lazy((value, { parent }) => {
+        const { propriedade } = parent;
+        if (!propriedade) {
+          return yup.mixed().nullable();
+        }
+        const campoSchemaOriginal = schemaObras.fields[propriedade];
+
+        if (!campoSchemaOriginal) {
+          return yup.mixed().required('Configuração inválida.');
+        }
+        return campoSchemaOriginal.required('Informe o novo valor');
+      }),
+    }),
+  ).min(1, 'Adicione pelo menos uma edição'),
+});
+
+const {
+  values, errors, handleSubmit, setFieldValue,
+} = useForm({
+  validationSchema: schema,
+  initialValues: {
+    edicoes: [],
+  },
+});
+
+const camposDisponiveisParaEdicao = computed(() => Object.entries(schemaObras.fields)
+  .filter(([_, fieldSchema]) => fieldSchema.meta()?.permite_edicao_em_massa)
path: frontend/src/views/edicoesEmLote/obras/EdicoesEmLoteObrasConstruir.vue
user: login: Eduruiz
body: ![image](https://github.com/user-attachments/assets/a79a7bcc-1253-44ce-a91e-5b6edf7ccdf5)
created_at: 2025-04-11T16:02:09Z
author_association: COLLABORATOR
diff_hunk: @@ -1,11 +1,384 @@
 <script setup>
-// Aqui montamos a edição
+import * as yup from 'yup';
+import { useEdicoesEmLoteStore } from '@/stores/edicoesEmLote.store';
+import {
+  Field, FieldArray, ErrorMessage, useForm,
+} from 'vee-validate';
+import { computed, ref } from 'vue';
+import { useAlertStore } from '@/stores/alert.store';
+import { useEquipamentosStore } from '@/stores/equipamentos.store';
+import { useGruposTematicosStore } from '@/stores/gruposTematicos.store';
+import { useOrgansStore } from '@/stores/organs.store';
+import { useTiposDeIntervencaoStore } from '@/stores/tiposDeIntervencao.store';
+import rawStatusObras from '@/consts/statusObras';
+import { obras as schemaObras } from '@/consts/formSchemas';
+import LabelFromYup from '@/components/camposDeFormulario/LabelFromYup.vue';
+import CabecalhoDePagina from '@/components/CabecalhoDePagina.vue';
+import CampoDinamico from '@/components/alteracaoEmLotes.componentes/CampoDinamico.vue';
+import SmaeFieldsetSubmit from '@/components/SmaeFieldsetSubmit.vue';
+import { useRoute, useRouter } from 'vue-router';
+
+const router = useRouter();
+const route = useRoute();
+const alertStore = useAlertStore();
+const edicoesEmLoteStore = useEdicoesEmLoteStore(route.meta.tipoDeAcoesEmLote);
+
+const modoRevisao = ref(false);
+
+const storeInstances = {
+  equipamentos: useEquipamentosStore(),
+  grupos_tematicos: useGruposTematicosStore(),
+  órgãos: useOrgansStore(),
+  tipos_de_intervencao: useTiposDeIntervencaoStore(),
+};
+
+const fontesEstaticas = {
+  statusObras: Object.values(rawStatusObras).map((item) => ({
+    value: item.valor,
+    label: item.nome,
+  })),
+};
+
+const loadingOptions = ref({});
+
+const schema = yup.object({
+  edicoes: yup.array().of(
+    yup.object({
+      propriedade: yup.string().required('Selecione o campo'),
+      valor: yup.lazy((value, { parent }) => {
+        const { propriedade } = parent;
+        if (!propriedade) {
+          return yup.mixed().nullable();
+        }
+        const campoSchemaOriginal = schemaObras.fields[propriedade];
+
+        if (!campoSchemaOriginal) {
+          return yup.mixed().required('Configuração inválida.');
+        }
+        return campoSchemaOriginal.required('Informe o novo valor');
+      }),
+    }),
+  ).min(1, 'Adicione pelo menos uma edição'),
+});
+
+const {
+  values, errors, handleSubmit, setFieldValue,
+} = useForm({
+  validationSchema: schema,
+  initialValues: {
+    edicoes: [],
+  },
+});
+
+const camposDisponiveisParaEdicao = computed(() => Object.entries(schemaObras.fields)
+  .filter(([_, fieldSchema]) => fieldSchema.meta()?.permite_edicao_em_massa)
+  .map(([fieldName, fieldSchema]) => ({
+    value: fieldName,
+    label: fieldSchema.spec.label || fieldName,
+  })));
+
+function getOpcoesDisponiveis(rowIndex) {
+  const propriedadesSelecionadas = values.edicoes
+    ?.map((edicao, index) => (index !== rowIndex ? edicao.propriedade : null))
+    .filter((prop) => prop != null && prop !== '');
+
+  return camposDisponiveisParaEdicao.value.filter(
+    (opcao) => !propriedadesSelecionadas.includes(opcao.value),
+  );
+}
+
+function detectarTipoCampo(campoSchema, meta) {
+  if (meta.optionSource && fontesEstaticas[meta.optionSource]) {
+    return 'select-estatico';
+  }
+
+  if (meta.storeKey) {
+    return 'select-dinamico';
+  }
+
+  const tipoPorClasse = {
+    NumberSchema: 'number',
+    StringSchema: 'text',
+    ArraySchema: 'array',
+    DateSchema: 'date',
+    ObjectSchema: 'object',
+  };
+
+  const nomeDaClasse = campoSchema.constructor.name;
+  return tipoPorClasse[nomeDaClasse] || 'text';
+}
+
+function obterConfiguracaoCampo(nomeCampo) {
+  const campoSchema = schemaObras.fields[nomeCampo];
+  if (!campoSchema) return null;
+
+  const meta = campoSchema.meta?.() || {};
+  const tipo = detectarTipoCampo(campoSchema, meta);
+
+  return {
+    schema: campoSchema,
+    tipo,
+    label: campoSchema.spec?.label || nomeCampo,
+    meta,
+  };
+}
+
+function campoConfig(idx) {
+  const prop = values.edicoes?.[idx]?.propriedade;
+  return prop ? obterConfiguracaoCampo(prop) : null;
+}
+
+const onSubmit = handleSubmit(async (valores) => {
+  if (!modoRevisao.value) {
+    modoRevisao.value = true;
+    return;
+  }
+
+  const payload = {
+    tipo: route.meta.tipoDeAcoesEmLote,
+    ids: edicoesEmLoteStore.idsSelecionados,
+    ops: valores.edicoes.map((edicao) => ({
+      col: edicao.propriedade,
+      set: String(edicao.valor),
+    })),
+  };
+
+  try {
+    if (await edicoesEmLoteStore.salvarItem(payload)) {
+      alertStore.success('Edição realizada com sucesso.');
+
+      const rotaDeEscape = route.meta?.rotaDeEscape;
+
+      if (rotaDeEscape) {
+        router.push(typeof rotaDeEscape === 'string' ? { name: rotaDeEscape } : rotaDeEscape);
+      }
+    }
+  } catch (error) {
+    alertStore.error(error);
+  }
+});
+
+async function fetchOptionsIfNeeded(rowIndex, fieldConfig) {
+  const { meta } = fieldConfig;
+  if (!meta?.storeKey || !meta.fetchAction || (!meta.listState && !meta.getterKey)) {
+    console.warn('Configuração de metadados incompleta:', fieldConfig);
+    return;
+  }
+
+  const store = storeInstances[meta.storeKey];
+  if (!store) {
+    console.error(`Store não encontrada para a chave: ${meta.storeKey}`);
+    return;
+  }
+
+  const dataSourceKey = meta.getterKey || meta.listState;
+
+  if (
+    store[dataSourceKey]
+      && Array.isArray(store[dataSourceKey]) && store[dataSourceKey].length > 0) {
+    return;
+  }
+
+  const loadingKey = `${rowIndex}-${meta.storeKey}`;
+  if (loadingOptions.value[loadingKey]) {
+    return;
+  }
+
+  loadingOptions.value = { ...loadingOptions.value, [loadingKey]: true };
+
+  try {
+    if (typeof store[meta.fetchAction] !== 'function') {
+      throw new Error(`Ação '${meta.fetchAction}' não encontrada na store ${meta.storeKey}`);
+    }
+    await store[meta.fetchAction]();
+  } catch (error) {
+    console.error(`Erro ao buscar dados para ${meta.storeKey}:`, error);
+  } finally {
+    loadingOptions.value = { ...loadingOptions.value, [loadingKey]: false };
+  }
+}
+
+function handlePropertyChange(event, idx) {
+  const propriedadeSelecionada = event.target.value;
+  setFieldValue(`edicoes[${idx}].valor`, null);
+
+  if (propriedadeSelecionada) {
+    const fieldConfig = obterConfiguracaoCampo(propriedadeSelecionada);
+    if (fieldConfig?.tipo === 'select-dinamico' && fieldConfig.meta?.storeKey) {
+      fetchOptionsIfNeeded(idx, fieldConfig);
+    }
+  }
+}
 </script>
 
 <template>
   <CabecalhoDePagina />
-
-  <p class="debug">
-    Aqui montamos a edição.
+  <p v-if="modoRevisao">
+    Essa alteração será aplicada nas
+    {{ edicoesEmLoteStore.idsSelecionados.length }} obras selecionadas. Confirma a edição?
   </p>
+  <p v-else>
+    Selecione o item que deseja editar nas
+    {{ edicoesEmLoteStore.idsSelecionados.length }} obras selecionadas.
+  </p>
+  <form @submit.prevent="onSubmit">
+    <div class="mb2">
+      <FieldArray
+        v-slot="{ fields, push, remove }"
+        name="edicoes"
+      >
+        <div
+          v-for="(field, idx) in fields"
+          :key="field.key"
+          class="flex g2 mb1 items-start"
+        >
+          <div class="f1">
+            <LabelFromYup
+              :name="`edicoes[${idx}].propriedade`"
+              label="Editar"
+              class="tc300"
+            >
+              Editar
+            </LabelFromYup>
+            <Field
+              :name="`edicoes[${idx}].propriedade`"
+              as="select"
+              class="inputtext light mb1"
+              :class="{ error: errors?.[`edicoes[${idx}].propriedade`] }"
+              :aria-disabled="modoRevisao"
+              @mousedown="modoRevisao && $event.preventDefault()"
+              @keydown="modoRevisao && $event.preventDefault()"
+              @focus="modoRevisao && $event.target.blur()"
+              @change="(event) => handlePropertyChange(event, idx)"
+            >
+              <option
+                value=""
+                disabled
+              >
+                Selecione...
+              </option>
+              <option
+                v-for="opcao in getOpcoesDisponiveis(idx)"
+                :key="opcao.value"
+                :value="opcao.value"
+              >
+                {{ opcao.label }}
+              </option>
+            </Field>
+            <ErrorMessage
+              class="error-msg"
+              :name="`edicoes[${idx}].propriedade`"
+            />
+          </div>
+
+          <div class="f1">
+            <template v-if="values.edicoes?.[idx]?.propriedade">
+              <LabelFromYup
+                :name="`edicoes[${idx}].valor`"
+                class="tc300"
+              >
+                {{ campoConfig(idx).label || 'Valor' }}
+              </LabelFromYup>
+              <CampoDinamico
+                v-model="values.edicoes[idx].valor"
+                :idx="idx"
+                :config="campoConfig(idx)"
+                :errors="errors"
+                :loading-options="loadingOptions"
+                :store-instances="storeInstances"
+                :fontes-estaticas="fontesEstaticas"
+                :readonly="modoRevisao"
+              />
+            </template>
+            <template v-else>
+              <div class="label tc300">
+                Selecione
+              </div>
+              <div class="inputtext light mb1 disabled-placeholder">
+                Selecione um campo à esquerda
+              </div>
+              <ErrorMessage
+                class="error-msg"
+                :name="`edicoes[${idx}].valor`"
+              />
+            </template>
+          </div>
+
+          <button
+            v-if="!modoRevisao"
+            type="button"
+            class="like-a__text addlink self-end mb-1"
+            aria-label="Remover Edição"
+            title="Remover Edição"
+            @click="remove(idx)"
+          >
+            <svg
+              width="20"
+              height="20"
+            ><use xlink:href="#i_remove" /></svg>
+          </button>
+        </div>
+
+        <button
+          v-if="!modoRevisao"
+          class="like-a__text addlink"
+          type="button"
+          @click="push({ propriedade: '', valor: null })"
+        >
+          <svg
+            width="20"
+            height="20"
+          ><use xlink:href="#i_+" /></svg>Adicionar Edição
+        </button>
+      </FieldArray>
+      <ErrorMessage
+        name="edicoes"
+        class="error-msg mt1"
+      />
+    </div>
+
+    <SmaeFieldsetSubmit>
+      <template v-if="!modoRevisao">
+        <button
+          type="submit"
+          class="btn big"
+          :disabled="Object.keys(errors).length > 0 || values.edicoes?.length === 0"
+        >
+          Aplicar Edição
+        </button>
+      </template>
+
+      <template v-else>
+        <button
path: frontend/src/views/edicoesEmLote/obras/EdicoesEmLoteObrasConstruir.vue
user: login: Eduruiz
body: essa so ativa/desativa o modo de edicao, nao volta pra lugar nenhum
created_at: 2025-04-11T16:14:18Z
author_association: COLLABORATOR
diff_hunk: @@ -1,11 +1,384 @@
 <script setup>
-// Aqui montamos a edição
+import * as yup from 'yup';
+import { useEdicoesEmLoteStore } from '@/stores/edicoesEmLote.store';
+import {
+  Field, FieldArray, ErrorMessage, useForm,
+} from 'vee-validate';
+import { computed, ref } from 'vue';
+import { useAlertStore } from '@/stores/alert.store';
+import { useEquipamentosStore } from '@/stores/equipamentos.store';
+import { useGruposTematicosStore } from '@/stores/gruposTematicos.store';
+import { useOrgansStore } from '@/stores/organs.store';
+import { useTiposDeIntervencaoStore } from '@/stores/tiposDeIntervencao.store';
+import rawStatusObras from '@/consts/statusObras';
+import { obras as schemaObras } from '@/consts/formSchemas';
+import LabelFromYup from '@/components/camposDeFormulario/LabelFromYup.vue';
+import CabecalhoDePagina from '@/components/CabecalhoDePagina.vue';
+import CampoDinamico from '@/components/alteracaoEmLotes.componentes/CampoDinamico.vue';
+import SmaeFieldsetSubmit from '@/components/SmaeFieldsetSubmit.vue';
+import { useRoute, useRouter } from 'vue-router';
+
+const router = useRouter();
+const route = useRoute();
+const alertStore = useAlertStore();
+const edicoesEmLoteStore = useEdicoesEmLoteStore(route.meta.tipoDeAcoesEmLote);
+
+const modoRevisao = ref(false);
+
+const storeInstances = {
+  equipamentos: useEquipamentosStore(),
+  grupos_tematicos: useGruposTematicosStore(),
+  órgãos: useOrgansStore(),
+  tipos_de_intervencao: useTiposDeIntervencaoStore(),
+};
+
+const fontesEstaticas = {
+  statusObras: Object.values(rawStatusObras).map((item) => ({
+    value: item.valor,
+    label: item.nome,
+  })),
+};
+
+const loadingOptions = ref({});
+
+const schema = yup.object({
+  edicoes: yup.array().of(
+    yup.object({
+      propriedade: yup.string().required('Selecione o campo'),
+      valor: yup.lazy((value, { parent }) => {
+        const { propriedade } = parent;
+        if (!propriedade) {
+          return yup.mixed().nullable();
+        }
+        const campoSchemaOriginal = schemaObras.fields[propriedade];
+
+        if (!campoSchemaOriginal) {
+          return yup.mixed().required('Configuração inválida.');
+        }
+        return campoSchemaOriginal.required('Informe o novo valor');
+      }),
+    }),
+  ).min(1, 'Adicione pelo menos uma edição'),
+});
+
+const {
+  values, errors, handleSubmit, setFieldValue,
+} = useForm({
+  validationSchema: schema,
+  initialValues: {
+    edicoes: [],
+  },
+});
+
+const camposDisponiveisParaEdicao = computed(() => Object.entries(schemaObras.fields)
+  .filter(([_, fieldSchema]) => fieldSchema.meta()?.permite_edicao_em_massa)
+  .map(([fieldName, fieldSchema]) => ({
+    value: fieldName,
+    label: fieldSchema.spec.label || fieldName,
+  })));
+
+function getOpcoesDisponiveis(rowIndex) {
+  const propriedadesSelecionadas = values.edicoes
+    ?.map((edicao, index) => (index !== rowIndex ? edicao.propriedade : null))
+    .filter((prop) => prop != null && prop !== '');
+
+  return camposDisponiveisParaEdicao.value.filter(
+    (opcao) => !propriedadesSelecionadas.includes(opcao.value),
+  );
+}
+
+function detectarTipoCampo(campoSchema, meta) {
+  if (meta.optionSource && fontesEstaticas[meta.optionSource]) {
+    return 'select-estatico';
+  }
+
+  if (meta.storeKey) {
+    return 'select-dinamico';
+  }
+
+  const tipoPorClasse = {
+    NumberSchema: 'number',
+    StringSchema: 'text',
+    ArraySchema: 'array',
+    DateSchema: 'date',
+    ObjectSchema: 'object',
+  };
+
+  const nomeDaClasse = campoSchema.constructor.name;
+  return tipoPorClasse[nomeDaClasse] || 'text';
+}
+
+function obterConfiguracaoCampo(nomeCampo) {
+  const campoSchema = schemaObras.fields[nomeCampo];
+  if (!campoSchema) return null;
+
+  const meta = campoSchema.meta?.() || {};
+  const tipo = detectarTipoCampo(campoSchema, meta);
+
+  return {
+    schema: campoSchema,
+    tipo,
+    label: campoSchema.spec?.label || nomeCampo,
+    meta,
+  };
+}
+
+function campoConfig(idx) {
+  const prop = values.edicoes?.[idx]?.propriedade;
+  return prop ? obterConfiguracaoCampo(prop) : null;
+}
+
+const onSubmit = handleSubmit(async (valores) => {
+  if (!modoRevisao.value) {
+    modoRevisao.value = true;
+    return;
+  }
+
+  const payload = {
+    tipo: route.meta.tipoDeAcoesEmLote,
+    ids: edicoesEmLoteStore.idsSelecionados,
+    ops: valores.edicoes.map((edicao) => ({
+      col: edicao.propriedade,
+      set: String(edicao.valor),
+    })),
+  };
+
+  try {
+    if (await edicoesEmLoteStore.salvarItem(payload)) {
+      alertStore.success('Edição realizada com sucesso.');
+
+      const rotaDeEscape = route.meta?.rotaDeEscape;
+
+      if (rotaDeEscape) {
+        router.push(typeof rotaDeEscape === 'string' ? { name: rotaDeEscape } : rotaDeEscape);
+      }
+    }
+  } catch (error) {
+    alertStore.error(error);
+  }
+});
+
+async function fetchOptionsIfNeeded(rowIndex, fieldConfig) {
+  const { meta } = fieldConfig;
+  if (!meta?.storeKey || !meta.fetchAction || (!meta.listState && !meta.getterKey)) {
+    console.warn('Configuração de metadados incompleta:', fieldConfig);
+    return;
+  }
+
+  const store = storeInstances[meta.storeKey];
+  if (!store) {
+    console.error(`Store não encontrada para a chave: ${meta.storeKey}`);
+    return;
+  }
+
+  const dataSourceKey = meta.getterKey || meta.listState;
+
+  if (
+    store[dataSourceKey]
+      && Array.isArray(store[dataSourceKey]) && store[dataSourceKey].length > 0) {
+    return;
+  }
+
+  const loadingKey = `${rowIndex}-${meta.storeKey}`;
+  if (loadingOptions.value[loadingKey]) {
+    return;
+  }
+
+  loadingOptions.value = { ...loadingOptions.value, [loadingKey]: true };
path: frontend/src/views/edicoesEmLote/obras/EdicoesEmLoteObrasConstruir.vue
user: login: Eduruiz
body: na real... clonar esse objeto aqui nem faz sentido so pra trocar a flag, acho que posso trocar so o loading direto.
created_at: 2025-04-11T16:28:32Z
author_association: COLLABORATOR
diff_hunk: @@ -1,11 +1,384 @@
 <script setup>
-// Aqui montamos a edição
+import * as yup from 'yup';
+import { useEdicoesEmLoteStore } from '@/stores/edicoesEmLote.store';
+import {
+  Field, FieldArray, ErrorMessage, useForm,
+} from 'vee-validate';
+import { computed, ref } from 'vue';
+import { useAlertStore } from '@/stores/alert.store';
+import { useEquipamentosStore } from '@/stores/equipamentos.store';
+import { useGruposTematicosStore } from '@/stores/gruposTematicos.store';
+import { useOrgansStore } from '@/stores/organs.store';
+import { useTiposDeIntervencaoStore } from '@/stores/tiposDeIntervencao.store';
+import rawStatusObras from '@/consts/statusObras';
+import { obras as schemaObras } from '@/consts/formSchemas';
+import LabelFromYup from '@/components/camposDeFormulario/LabelFromYup.vue';
+import CabecalhoDePagina from '@/components/CabecalhoDePagina.vue';
+import CampoDinamico from '@/components/alteracaoEmLotes.componentes/CampoDinamico.vue';
+import SmaeFieldsetSubmit from '@/components/SmaeFieldsetSubmit.vue';
+import { useRoute, useRouter } from 'vue-router';
+
+const router = useRouter();
+const route = useRoute();
+const alertStore = useAlertStore();
+const edicoesEmLoteStore = useEdicoesEmLoteStore(route.meta.tipoDeAcoesEmLote);
+
+const modoRevisao = ref(false);
+
+const storeInstances = {
+  equipamentos: useEquipamentosStore(),
+  grupos_tematicos: useGruposTematicosStore(),
+  órgãos: useOrgansStore(),
+  tipos_de_intervencao: useTiposDeIntervencaoStore(),
+};
+
+const fontesEstaticas = {
+  statusObras: Object.values(rawStatusObras).map((item) => ({
+    value: item.valor,
+    label: item.nome,
+  })),
+};
+
+const loadingOptions = ref({});
+
+const schema = yup.object({
+  edicoes: yup.array().of(
+    yup.object({
+      propriedade: yup.string().required('Selecione o campo'),
+      valor: yup.lazy((value, { parent }) => {
+        const { propriedade } = parent;
+        if (!propriedade) {
+          return yup.mixed().nullable();
+        }
+        const campoSchemaOriginal = schemaObras.fields[propriedade];
+
+        if (!campoSchemaOriginal) {
+          return yup.mixed().required('Configuração inválida.');
+        }
+        return campoSchemaOriginal.required('Informe o novo valor');
+      }),
+    }),
+  ).min(1, 'Adicione pelo menos uma edição'),
+});
+
+const {
+  values, errors, handleSubmit, setFieldValue,
+} = useForm({
+  validationSchema: schema,
+  initialValues: {
+    edicoes: [],
+  },
+});
+
+const camposDisponiveisParaEdicao = computed(() => Object.entries(schemaObras.fields)
+  .filter(([_, fieldSchema]) => fieldSchema.meta()?.permite_edicao_em_massa)
+  .map(([fieldName, fieldSchema]) => ({
+    value: fieldName,
+    label: fieldSchema.spec.label || fieldName,
+  })));
+
+function getOpcoesDisponiveis(rowIndex) {
+  const propriedadesSelecionadas = values.edicoes
+    ?.map((edicao, index) => (index !== rowIndex ? edicao.propriedade : null))
+    .filter((prop) => prop != null && prop !== '');
+
+  return camposDisponiveisParaEdicao.value.filter(
+    (opcao) => !propriedadesSelecionadas.includes(opcao.value),
+  );
+}
+
+function detectarTipoCampo(campoSchema, meta) {
+  if (meta.optionSource && fontesEstaticas[meta.optionSource]) {
+    return 'select-estatico';
+  }
+
+  if (meta.storeKey) {
+    return 'select-dinamico';
+  }
+
+  const tipoPorClasse = {
+    NumberSchema: 'number',
+    StringSchema: 'text',
+    ArraySchema: 'array',
+    DateSchema: 'date',
+    ObjectSchema: 'object',
+  };
+
+  const nomeDaClasse = campoSchema.constructor.name;
+  return tipoPorClasse[nomeDaClasse] || 'text';
+}
+
+function obterConfiguracaoCampo(nomeCampo) {
+  const campoSchema = schemaObras.fields[nomeCampo];
+  if (!campoSchema) return null;
+
+  const meta = campoSchema.meta?.() || {};
+  const tipo = detectarTipoCampo(campoSchema, meta);
+
+  return {
+    schema: campoSchema,
+    tipo,
+    label: campoSchema.spec?.label || nomeCampo,
+    meta,
+  };
+}
+
+function campoConfig(idx) {
+  const prop = values.edicoes?.[idx]?.propriedade;
+  return prop ? obterConfiguracaoCampo(prop) : null;
+}
+
+const onSubmit = handleSubmit(async (valores) => {
+  if (!modoRevisao.value) {
+    modoRevisao.value = true;
+    return;
+  }
+
+  const payload = {
+    tipo: route.meta.tipoDeAcoesEmLote,
+    ids: edicoesEmLoteStore.idsSelecionados,
+    ops: valores.edicoes.map((edicao) => ({
+      col: edicao.propriedade,
+      set: String(edicao.valor),
+    })),
+  };
+
+  try {
+    if (await edicoesEmLoteStore.salvarItem(payload)) {
+      alertStore.success('Edição realizada com sucesso.');
+
+      const rotaDeEscape = route.meta?.rotaDeEscape;
+
+      if (rotaDeEscape) {
+        router.push(typeof rotaDeEscape === 'string' ? { name: rotaDeEscape } : rotaDeEscape);
+      }
+    }
+  } catch (error) {
+    alertStore.error(error);
+  }
+});
+
+async function fetchOptionsIfNeeded(rowIndex, fieldConfig) {
+  const { meta } = fieldConfig;
+  if (!meta?.storeKey || !meta.fetchAction || (!meta.listState && !meta.getterKey)) {
+    console.warn('Configuração de metadados incompleta:', fieldConfig);
+    return;
+  }
+
+  const store = storeInstances[meta.storeKey];
+  if (!store) {
+    console.error(`Store não encontrada para a chave: ${meta.storeKey}`);
+    return;
+  }
+
+  const dataSourceKey = meta.getterKey || meta.listState;
+
+  if (
+    store[dataSourceKey]
+      && Array.isArray(store[dataSourceKey]) && store[dataSourceKey].length > 0) {
+    return;
+  }
+
+  const loadingKey = `${rowIndex}-${meta.storeKey}`;
+  if (loadingOptions.value[loadingKey]) {
+    return;
+  }
+
+  loadingOptions.value = { ...loadingOptions.value, [loadingKey]: true };
path: frontend/src/views/edicoesEmLote/obras/EdicoesEmLoteObrasConstruir.vue
user: login: Eduruiz
body: bem melhor: efdb042d6f4facd0c62243fd197b157aee6cdcc9
created_at: 2025-04-11T16:36:28Z
author_association: COLLABORATOR
diff_hunk: @@ -1,11 +1,384 @@
 <script setup>
-// Aqui montamos a edição
+import * as yup from 'yup';
path: frontend/src/views/edicoesEmLote/obras/EdicoesEmLoteObrasConstruir.vue
user: login: Eduruiz
body: boa, tava no desespero hahaha 531051a0c1be560b772c2fe6b77cda907fcb95e5
created_at: 2025-04-11T16:37:21Z
author_association: COLLABORATOR
diff_hunk: @@ -1,11 +1,384 @@
 <script setup>
-// Aqui montamos a edição
+import * as yup from 'yup';
+import { useEdicoesEmLoteStore } from '@/stores/edicoesEmLote.store';
+import {
+  Field, FieldArray, ErrorMessage, useForm,
+} from 'vee-validate';
+import { computed, ref } from 'vue';
+import { useAlertStore } from '@/stores/alert.store';
+import { useEquipamentosStore } from '@/stores/equipamentos.store';
+import { useGruposTematicosStore } from '@/stores/gruposTematicos.store';
+import { useOrgansStore } from '@/stores/organs.store';
+import { useTiposDeIntervencaoStore } from '@/stores/tiposDeIntervencao.store';
+import rawStatusObras from '@/consts/statusObras';
+import { obras as schemaObras } from '@/consts/formSchemas';
+import LabelFromYup from '@/components/camposDeFormulario/LabelFromYup.vue';
+import CabecalhoDePagina from '@/components/CabecalhoDePagina.vue';
+import CampoDinamico from '@/components/alteracaoEmLotes.componentes/CampoDinamico.vue';
+import SmaeFieldsetSubmit from '@/components/SmaeFieldsetSubmit.vue';
+import { useRoute, useRouter } from 'vue-router';
+
+const router = useRouter();
+const route = useRoute();
+const alertStore = useAlertStore();
+const edicoesEmLoteStore = useEdicoesEmLoteStore(route.meta.tipoDeAcoesEmLote);
+
+const modoRevisao = ref(false);
+
+const storeInstances = {
+  equipamentos: useEquipamentosStore(),
+  grupos_tematicos: useGruposTematicosStore(),
+  órgãos: useOrgansStore(),
+  tipos_de_intervencao: useTiposDeIntervencaoStore(),
+};
+
+const fontesEstaticas = {
+  statusObras: Object.values(rawStatusObras).map((item) => ({
+    value: item.valor,
+    label: item.nome,
+  })),
+};
+
+const loadingOptions = ref({});
+
+const schema = yup.object({
+  edicoes: yup.array().of(
+    yup.object({
+      propriedade: yup.string().required('Selecione o campo'),
+      valor: yup.lazy((value, { parent }) => {
+        const { propriedade } = parent;
+        if (!propriedade) {
+          return yup.mixed().nullable();
+        }
+        const campoSchemaOriginal = schemaObras.fields[propriedade];
+
+        if (!campoSchemaOriginal) {
+          return yup.mixed().required('Configuração inválida.');
+        }
+        return campoSchemaOriginal.required('Informe o novo valor');
+      }),
+    }),
+  ).min(1, 'Adicione pelo menos uma edição'),
+});
+
+const {
+  values, errors, handleSubmit, setFieldValue,
+} = useForm({
+  validationSchema: schema,
+  initialValues: {
+    edicoes: [],
+  },
+});
+
+const camposDisponiveisParaEdicao = computed(() => Object.entries(schemaObras.fields)
+  .filter(([_, fieldSchema]) => fieldSchema.meta()?.permite_edicao_em_massa)
+  .map(([fieldName, fieldSchema]) => ({
+    value: fieldName,
+    label: fieldSchema.spec.label || fieldName,
+  })));
+
+function getOpcoesDisponiveis(rowIndex) {
+  const propriedadesSelecionadas = values.edicoes
path: frontend/src/views/edicoesEmLote/obras/EdicoesEmLoteObrasConstruir.vue
user: login: Eduruiz
body: deus não existe
created_at: 2025-04-11T16:37:59Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,78 @@
+<script setup>
+import { useField } from 'vee-validate';
+import {
+  computed,
+  toRef,
+} from 'vue';
+import { parseISO, isValid, format } from 'date-fns';
+
+const props = defineProps({
+  modelValue: {
+    type: [Date, String, null],
+    default: null,
+  },
+  name: {
+    type: String,
+    default: '',
+  },
+  converterPara: {
+    default: 'date',
+    type: String,
+    validator(valor) {
+      return ['date', 'string', 'text'].includes(valor);
+    },
+  },
+});
+
+const emit = defineEmits(['update:modelValue']);
+const nome = toRef(props, 'name');
+
+const { handleChange } = useField(nome, undefined, {
path: frontend/src/components/camposDeFormulario/SmaeDateInput.vue
user: login: robsonsobral
body: Meu ponto aqui é o tempo de implementação.
created_at: 2025-04-11T16:52:36Z
author_association: COLLABORATOR
diff_hunk: @@ -1,11 +1,384 @@
 <script setup>
-// Aqui montamos a edição
+import * as yup from 'yup';
+import { useEdicoesEmLoteStore } from '@/stores/edicoesEmLote.store';
+import {
+  Field, FieldArray, ErrorMessage, useForm,
+} from 'vee-validate';
+import { computed, ref } from 'vue';
+import { useAlertStore } from '@/stores/alert.store';
+import { useEquipamentosStore } from '@/stores/equipamentos.store';
+import { useGruposTematicosStore } from '@/stores/gruposTematicos.store';
+import { useOrgansStore } from '@/stores/organs.store';
+import { useTiposDeIntervencaoStore } from '@/stores/tiposDeIntervencao.store';
+import rawStatusObras from '@/consts/statusObras';
+import { obras as schemaObras } from '@/consts/formSchemas';
+import LabelFromYup from '@/components/camposDeFormulario/LabelFromYup.vue';
+import CabecalhoDePagina from '@/components/CabecalhoDePagina.vue';
+import CampoDinamico from '@/components/alteracaoEmLotes.componentes/CampoDinamico.vue';
+import SmaeFieldsetSubmit from '@/components/SmaeFieldsetSubmit.vue';
+import { useRoute, useRouter } from 'vue-router';
+
+const router = useRouter();
+const route = useRoute();
+const alertStore = useAlertStore();
+const edicoesEmLoteStore = useEdicoesEmLoteStore(route.meta.tipoDeAcoesEmLote);
+
+const modoRevisao = ref(false);
+
+const storeInstances = {
+  equipamentos: useEquipamentosStore(),
+  grupos_tematicos: useGruposTematicosStore(),
+  órgãos: useOrgansStore(),
+  tipos_de_intervencao: useTiposDeIntervencaoStore(),
+};
+
+const fontesEstaticas = {
+  statusObras: Object.values(rawStatusObras).map((item) => ({
+    value: item.valor,
+    label: item.nome,
+  })),
+};
+
+const loadingOptions = ref({});
+
+const schema = yup.object({
+  edicoes: yup.array().of(
+    yup.object({
+      propriedade: yup.string().required('Selecione o campo'),
+      valor: yup.lazy((value, { parent }) => {
+        const { propriedade } = parent;
+        if (!propriedade) {
+          return yup.mixed().nullable();
+        }
+        const campoSchemaOriginal = schemaObras.fields[propriedade];
+
+        if (!campoSchemaOriginal) {
+          return yup.mixed().required('Configuração inválida.');
+        }
+        return campoSchemaOriginal.required('Informe o novo valor');
+      }),
+    }),
+  ).min(1, 'Adicione pelo menos uma edição'),
+});
+
+const {
+  values, errors, handleSubmit, setFieldValue,
+} = useForm({
+  validationSchema: schema,
+  initialValues: {
+    edicoes: [],
+  },
+});
+
+const camposDisponiveisParaEdicao = computed(() => Object.entries(schemaObras.fields)
+  .filter(([_, fieldSchema]) => fieldSchema.meta()?.permite_edicao_em_massa)
path: frontend/src/views/edicoesEmLote/obras/EdicoesEmLoteObrasConstruir.vue
user: login: robsonsobral
body: Deus que se dane. Eu fico triste é com esses loops consecutivos. Viram um monstro. É só botar um IF ali dentro e pronto!
created_at: 2025-04-11T16:55:05Z
author_association: COLLABORATOR
diff_hunk: @@ -1,11 +1,384 @@
 <script setup>
-// Aqui montamos a edição
+import * as yup from 'yup';
+import { useEdicoesEmLoteStore } from '@/stores/edicoesEmLote.store';
+import {
+  Field, FieldArray, ErrorMessage, useForm,
+} from 'vee-validate';
+import { computed, ref } from 'vue';
+import { useAlertStore } from '@/stores/alert.store';
+import { useEquipamentosStore } from '@/stores/equipamentos.store';
+import { useGruposTematicosStore } from '@/stores/gruposTematicos.store';
+import { useOrgansStore } from '@/stores/organs.store';
+import { useTiposDeIntervencaoStore } from '@/stores/tiposDeIntervencao.store';
+import rawStatusObras from '@/consts/statusObras';
+import { obras as schemaObras } from '@/consts/formSchemas';
+import LabelFromYup from '@/components/camposDeFormulario/LabelFromYup.vue';
+import CabecalhoDePagina from '@/components/CabecalhoDePagina.vue';
+import CampoDinamico from '@/components/alteracaoEmLotes.componentes/CampoDinamico.vue';
+import SmaeFieldsetSubmit from '@/components/SmaeFieldsetSubmit.vue';
+import { useRoute, useRouter } from 'vue-router';
+
+const router = useRouter();
+const route = useRoute();
+const alertStore = useAlertStore();
+const edicoesEmLoteStore = useEdicoesEmLoteStore(route.meta.tipoDeAcoesEmLote);
+
+const modoRevisao = ref(false);
+
+const storeInstances = {
+  equipamentos: useEquipamentosStore(),
+  grupos_tematicos: useGruposTematicosStore(),
+  órgãos: useOrgansStore(),
+  tipos_de_intervencao: useTiposDeIntervencaoStore(),
+};
+
+const fontesEstaticas = {
+  statusObras: Object.values(rawStatusObras).map((item) => ({
+    value: item.valor,
+    label: item.nome,
+  })),
+};
+
+const loadingOptions = ref({});
+
+const schema = yup.object({
+  edicoes: yup.array().of(
+    yup.object({
+      propriedade: yup.string().required('Selecione o campo'),
+      valor: yup.lazy((value, { parent }) => {
+        const { propriedade } = parent;
+        if (!propriedade) {
+          return yup.mixed().nullable();
+        }
+        const campoSchemaOriginal = schemaObras.fields[propriedade];
+
+        if (!campoSchemaOriginal) {
+          return yup.mixed().required('Configuração inválida.');
+        }
+        return campoSchemaOriginal.required('Informe o novo valor');
+      }),
+    }),
+  ).min(1, 'Adicione pelo menos uma edição'),
+});
+
+const {
+  values, errors, handleSubmit, setFieldValue,
+} = useForm({
+  validationSchema: schema,
+  initialValues: {
+    edicoes: [],
+  },
+});
+
+const camposDisponiveisParaEdicao = computed(() => Object.entries(schemaObras.fields)
+  .filter(([_, fieldSchema]) => fieldSchema.meta()?.permite_edicao_em_massa)
+  .map(([fieldName, fieldSchema]) => ({
+    value: fieldName,
+    label: fieldSchema.spec.label || fieldName,
+  })));
+
+function getOpcoesDisponiveis(rowIndex) {
+  const propriedadesSelecionadas = values.edicoes
path: frontend/src/views/edicoesEmLote/obras/EdicoesEmLoteObrasConstruir.vue
user: login: robsonsobral
body: Outro caso em que você pode fazer um código mais eficiente com um IF!
created_at: 2025-04-11T16:56:00Z
author_association: COLLABORATOR

PR comments:
user: login: cloudflare-workers-and-pages[bot]
created_at: 2025-04-11T15:39:42Z
body: ## Deploying smae with &nbsp;<a href="https://pages.dev"><img alt="Cloudflare Pages" src="https://user-images.githubusercontent.com/23264/106598434-9e719e00-654f-11eb-9e59-6167043cfa01.png" width="16"></a> &nbsp;Cloudflare Pages

<table><tr><td><strong>Latest commit:</strong> </td><td>
<code>e650cf0</code>
</td></tr>
<tr><td><strong>Status:</strong></td><td>&nbsp;✅&nbsp; Deploy successful!</td></tr>
<tr><td><strong>Preview URL:</strong></td><td>
<a href='https://0ab8522e.smae.pages.dev'>https://0ab8522e.smae.pages.dev</a>
</td></tr>
<tr><td><strong>Branch Preview URL:</strong></td><td>
<a href='https://feature-sa5-mo004-alteracao.smae.pages.dev'>https://feature-sa5-mo004-alteracao.smae.pages.dev</a>
</td></tr>
</table>

[View logs](https://dash.cloudflare.com/32164cbf7c98d775eb838d1a08ee2a8e/pages/view/smae/0ab8522e-b51a-4ee0-aa21-b08058544249)

author_association: NONE
user: login: sonarqubecloud[bot]
created_at: 2025-04-11T17:15:39Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=AppCivico_smae&pullRequest=243) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [2 New issues](https://sonarcloud.io/project/issues?id=AppCivico_smae&pullRequest=243&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=AppCivico_smae&pullRequest=243&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=AppCivico_smae&pullRequest=243&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=AppCivico_smae&pullRequest=243&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=AppCivico_smae&pullRequest=243&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=AppCivico_smae&pullRequest=243)


author_association: NONE