Skip to main content

Overview

The Reports System enables citizens to report municipal issues (potholes, broken streetlights, etc.) with photos and location data. Administrators can review, prioritize, and update report statuses in real-time.

Report Categories

Alumbrado

Street lighting issues

Baches

Potholes and road damage

Limpieza

Cleaning and waste issues

Agua

Water supply problems

Alcantarillado

Sewage and drainage issues

Parques

Parks and recreation areas

Señalización

Traffic signs and signals

Seguridad

Public safety concerns

Ruido

Noise complaints

Otro

Other issues

Data Model

Report Structure

type ReporteCategoria =
  | "alumbrado"
  | "baches"
  | "limpieza"
  | "agua"
  | "alcantarillado"
  | "parques"
  | "señalizacion"
  | "seguridad"
  | "ruido"
  | "otro";

type ReporteEstado =
  | "pendiente"      // Initial state
  | "en_revision"    // Admin is reviewing
  | "en_proceso"     // Accepted and being resolved
  | "resuelto"       // Completed successfully
  | "rechazado";     // Rejected or duplicate

type ReportePrioridad = "baja" | "media" | "alta" | "urgente";

interface Reporte {
  id: string;
  usuario_id: string;
  categoria: ReporteCategoria;
  descripcion: string;
  latitud: number;
  longitud: number;
  direccion: string;
  imagen_url?: string;                  // Supabase Storage URL
  estado: ReporteEstado;
  prioridad?: ReportePrioridad;
  notas_admin?: string;                 // Admin notes
  fecha_resolucion?: string;
  created_at: string;
  updated_at: string;
}

Reports Store (reportes.store.ts)

State Management

import { defineStore } from "pinia";
import { ref } from "vue";
import { supabase } from "../lib/supabase";
import type { Database } from "../types/database.types";

type Reporte = Database["public"]["Tables"]["reportes"]["Row"];
type InsertReporte = Database["public"]["Tables"]["reportes"]["Insert"];
type UpdateReporte = Database["public"]["Tables"]["reportes"]["Update"];

export const useReportesStore = defineStore("reportes", () => {
  const reportes = ref<Reporte[]>([]);
  const reporteActual = ref<Reporte | null>(null);
  const loading = ref(false);
  const error = ref<string | null>(null);
  
  // ... actions
});

Creating Reports

const crearReporte = async (reporte: Omit<InsertReporte, "usuario_id">) => {
  loading.value = true;
  error.value = null;

  try {
    const authStore = useAuthStore();
    console.log('🔐 Usuario autenticado:', authStore.user?.id);
    
    if (!authStore.user) throw new Error("Usuario no autenticado");

    const dataToInsert = {
      ...reporte,
      usuario_id: authStore.user.id,
    };
    
    console.log('📝 Datos a insertar:', dataToInsert);

    const { data, error: insertError } = await supabase
      .from("reportes")
      .insert(dataToInsert)
      .select()
      .single();

    console.log('📊 Respuesta de Supabase:', { data, error: insertError });

    if (insertError) {
      console.error('❌ Error detallado de Supabase:', {
        message: insertError.message,
        details: insertError.details,
        hint: insertError.hint,
        code: insertError.code
      });
      throw insertError;
    }

    reportes.value.unshift(data);
    return { success: true, data };
  } catch (err: any) {
    error.value = err.message ?? String(err);
    console.error("❌ Error creando reporte:", err);
    return { success: false, error: err.message ?? String(err) };
  } finally {
    loading.value = false;
  }
};

Image Upload

Reports can include photos uploaded to Supabase Storage:
const subirImagen = async (file: File, reporteId: string) => {
  loading.value = true;
  error.value = null;

  try {
    const fileExt = file.name.split(".").pop();
    const fileName = `${reporteId}-${Date.now()}.${fileExt}`;
    const filePath = `reportes/${fileName}`;

    const { error: uploadError } = await supabase.storage
      .from("imagenes")
      .upload(filePath, file);

    if (uploadError) throw uploadError;

    const publicUrlData = supabase.storage
      .from("imagenes")
      .getPublicUrl(filePath);
    const publicUrl = publicUrlData.data?.publicUrl ?? null;

    return { success: true, url: publicUrl };
  } catch (err: any) {
    error.value = err.message ?? String(err);
    console.error("❌ Error subiendo imagen:", err);
    return { success: false, error: err.message ?? String(err) };
  } finally {
    loading.value = false;
  }
};

Updating Report Status (Admin)

const actualizarReporte = async (
  id: string,
  updates: Omit<UpdateReporte, "id" | "usuario_id">
) => {
  loading.value = true;
  error.value = null;

  try {
    const { data, error: updateError } = await supabase
      .from("reportes")
      .update(updates)
      .eq("id", id)
      .select()
      .single();

    if (updateError) throw updateError;

    // Update in local list
    const index = reportes.value.findIndex((r) => r.id === id);
    if (index !== -1) {
      reportes.value[index] = data;
    }

    if (reporteActual.value?.id === id) {
      reporteActual.value = data;
    }

    return { success: true, data };
  } catch (err: any) {
    error.value = err.message ?? String(err);
    console.error("❌ Error actualizando reporte:", err);
    return { success: false, error: err.message ?? String(err) };
  } finally {
    loading.value = false;
  }
};

Fetching Reports with Filters

const fetchReportes = async (filtros?: {
  usuario_id?: string;
  estado?: Reporte["estado"];
  categoria?: Reporte["categoria"];
}) => {
  loading.value = true;
  error.value = null;

  try {
    let query = supabase
      .from("reportes")
      .select("*")
      .order("created_at", { ascending: false });

    if (filtros?.usuario_id) {
      query = query.eq("usuario_id", filtros.usuario_id);
    }

    if (filtros?.estado) {
      query = query.eq("estado", filtros.estado);
    }

    if (filtros?.categoria) {
      query = query.eq("categoria", filtros.categoria);
    }

    const { data, error: fetchError } = await query;

    if (fetchError) throw fetchError;
    reportes.value = data || [];

    return { success: true, data };
  } catch (err: any) {
    error.value = err.message ?? String(err);
    console.error("❌ Error obteniendo reportes:", err);
    return { success: false, error: err.message ?? String(err) };
  } finally {
    loading.value = false;
  }
};

Real-Time Updates

The system supports real-time updates using Supabase Realtime:
const subscribeToReportes = () => {
  const authStore = useAuthStore();

  return supabase
    .channel("reportes-changes")
    .on(
      "postgres_changes",
      {
        event: "*",
        schema: "public",
        table: "reportes",
        filter: authStore.isCiudadano()
          ? `usuario_id=eq.${authStore.user?.id}`
          : undefined,
      },
      (payload) => {
        console.log("📡 Cambio en reportes:", payload);

        if (payload.eventType === "INSERT") {
          reportes.value.unshift(payload.new as Reporte);
        } else if (payload.eventType === "UPDATE") {
          const index = reportes.value.findIndex(
            (r) => r.id === payload.new.id
          );
          if (index !== -1) {
            reportes.value[index] = payload.new as Reporte;
          }
        } else if (payload.eventType === "DELETE") {
          reportes.value = reportes.value.filter(
            (r) => r.id !== payload.old.id
          );
        }
      }
    )
    .subscribe();
};

Geolocation Integration

Reports include precise location data using the MapSelector component. See Geolocation System for details.

Location Data Structure

interface IUbicacion {
  latitud: number;
  longitud: number;
  direccion: string;                    // Human-readable address
  barrio?: string;
  sector?: string;
  referencias?: string;                 // Optional landmarks
}

Usage Examples

Creating a Report

<script setup lang="ts">
import { ref } from "vue";
import { useReportesStore } from "@/stores/reportes.store";
import MapSelector from "@/components/maps/MapSelector.vue";

const reportesStore = useReportesStore();

const nuevoReporte = ref({
  categoria: "baches" as const,
  descripcion: "",
  latitud: 0,
  longitud: 0,
  direccion: "",
});

const ubicacion = ref({
  latitud: -0.9536,
  longitud: -80.7217,
  direccion: "",
});

const imagenFile = ref<File | null>(null);

const enviarReporte = async () => {
  // Update coordinates from map
  nuevoReporte.value.latitud = ubicacion.value.latitud;
  nuevoReporte.value.longitud = ubicacion.value.longitud;
  nuevoReporte.value.direccion = ubicacion.value.direccion;

  // Create report
  const result = await reportesStore.crearReporte(nuevoReporte.value);
  
  if (result.success && imagenFile.value) {
    // Upload image if provided
    const uploadResult = await reportesStore.subirImagen(
      imagenFile.value,
      result.data.id
    );
    
    if (uploadResult.success) {
      // Update report with image URL
      await reportesStore.actualizarReporte(result.data.id, {
        imagen_url: uploadResult.url,
      });
    }
  }
};
</script>

<template>
  <form @submit.prevent="enviarReporte">
    <select v-model="nuevoReporte.categoria">
      <option value="alumbrado">Alumbrado</option>
      <option value="baches">Baches</option>
      <option value="limpieza">Limpieza</option>
      <!-- ... more categories ... -->
    </select>
    
    <textarea 
      v-model="nuevoReporte.descripcion" 
      placeholder="Describe el problema..."
    />
    
    <input 
      type="file" 
      accept="image/*"
      @change="imagenFile = $event.target.files[0]"
    />
    
    <MapSelector 
      v-model:ubicacion="ubicacion"
      :required="true"
    />
    
    <button type="submit" :disabled="reportesStore.loading">
      Enviar Reporte
    </button>
  </form>
</template>

Admin Report Management

<script setup lang="ts">
import { ref, onMounted } from "vue";
import { useReportesStore } from "@/stores/reportes.store";

const reportesStore = useReportesStore();
const filtroEstado = ref<string>("");

onMounted(async () => {
  await reportesStore.fetchReportes();
  reportesStore.subscribeToReportes();
});

const cambiarEstado = async (reporteId: string, nuevoEstado: string) => {
  await reportesStore.actualizarReporte(reporteId, {
    estado: nuevoEstado as any,
  });
};

const cambiarPrioridad = async (reporteId: string, nuevaPrioridad: string) => {
  await reportesStore.actualizarReporte(reporteId, {
    prioridad: nuevaPrioridad as any,
  });
};
</script>

<template>
  <div>
    <select v-model="filtroEstado" @change="fetchReportes({ estado: filtroEstado })">
      <option value="">Todos</option>
      <option value="pendiente">Pendientes</option>
      <option value="en_revision">En Revisión</option>
      <option value="en_proceso">En Proceso</option>
      <option value="resuelto">Resueltos</option>
      <option value="rechazado">Rechazados</option>
    </select>
    
    <div v-for="reporte in reportesStore.reportes" :key="reporte.id">
      <h3>{{ reporte.categoria }}</h3>
      <p>{{ reporte.descripcion }}</p>
      <img v-if="reporte.imagen_url" :src="reporte.imagen_url" />
      <p>📍 {{ reporte.direccion }}</p>
      
      <select 
        :value="reporte.estado" 
        @change="cambiarEstado(reporte.id, $event.target.value)"
      >
        <option value="pendiente">Pendiente</option>
        <option value="en_revision">En Revisión</option>
        <option value="en_proceso">En Proceso</option>
        <option value="resuelto">Resuelto</option>
        <option value="rechazado">Rechazado</option>
      </select>
      
      <select 
        :value="reporte.prioridad" 
        @change="cambiarPrioridad(reporte.id, $event.target.value)"
      >
        <option value="baja">Baja</option>
        <option value="media">Media</option>
        <option value="alta">Alta</option>
        <option value="urgente">Urgente</option>
      </select>
    </div>
  </div>
</template>

Report State Workflow

The report lifecycle follows ISO/IEC 25010 principles (maximum 5 states):
1

Pendiente (Initial)

New report submitted by citizen. Awaiting admin review.
2

En Revisión

Administrator is evaluating the report for validity and priority.
3

En Proceso

Report accepted and assigned for resolution. Work in progress.
4

Resuelto (Terminal)

Issue successfully resolved. Citizen can be notified.
5

Rechazado (Terminal)

Report rejected (duplicate, invalid, or out of scope).

Best Practices

Compress images before upload to reduce storage costs and improve load times. Recommended max size: 1200px width, 85% JPEG quality.
Always validate that latitude and longitude are within Manta’s boundaries (-1.1 to -0.8, -80.9 to -80.5) before accepting reports.
Subscribe to changes when the component mounts and unsubscribe when it unmounts to prevent memory leaks.
Log detailed errors (code, message, details) to help debug Supabase issues. Use structured logging for better observability.

Storage Configuration

Configure Supabase Storage bucket policies:
-- Allow authenticated users to upload images
CREATE POLICY "Users can upload report images"
ON storage.objects FOR INSERT
TO authenticated
WITH CHECK (bucket_id = 'imagenes' AND (storage.foldername(name))[1] = 'reportes');

-- Allow public read access
CREATE POLICY "Public read access for report images"
ON storage.objects FOR SELECT
TO public
USING (bucket_id = 'imagenes' AND (storage.foldername(name))[1] = 'reportes');

Geolocation

MapSelector component and location services

Supabase Storage

File upload and management documentation

Build docs developers (and LLMs) love