Compare commits
No commits in common. "api" and "main" have entirely different histories.
@ -1,237 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Controllers\Dashboard;
|
|
||||||
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Routing\Controller as BaseController;
|
|
||||||
use Illuminate\Support\Facades\Cache;
|
|
||||||
use Illuminate\Support\Facades\Http;
|
|
||||||
use Illuminate\Support\Facades\Log;
|
|
||||||
|
|
||||||
class AtencionController extends BaseController
|
|
||||||
{
|
|
||||||
public function Atencion(Request $request)
|
|
||||||
{
|
|
||||||
$query = $request->only(['start', 'end', 'type', 'action', 'period', 'charts_only']);
|
|
||||||
$input = array_merge(['action' => 'index', 'type' => 'api'], $query);
|
|
||||||
|
|
||||||
$startDate = $input['start'] ?? date('Y-01-01');
|
|
||||||
$endDate = $input['end'] ?? date('Y-12-31');
|
|
||||||
$period = $input['period'] ?? null;
|
|
||||||
$chartsOnly = $input['charts_only'] ?? false;
|
|
||||||
|
|
||||||
// Cache key para diferentes tipos de peticiones
|
|
||||||
$cacheKey = 'atencion_' . md5(serialize([
|
|
||||||
'start' => $startDate,
|
|
||||||
'end' => $endDate,
|
|
||||||
'period' => $period,
|
|
||||||
'charts_only' => $chartsOnly
|
|
||||||
]));
|
|
||||||
|
|
||||||
// Intentar obtener desde cache (2 minutos)
|
|
||||||
if (Cache::has($cacheKey)) {
|
|
||||||
return response()->json(Cache::get($cacheKey), 200)
|
|
||||||
->header('Content-Type', 'application/json');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Si solo necesitamos gráficas, hacer menos peticiones
|
|
||||||
if ($chartsOnly) {
|
|
||||||
$result = $this->getChartsData($startDate, $endDate);
|
|
||||||
} else {
|
|
||||||
$result = $this->getFullData($startDate, $endDate, $period);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cachear resultado por 2 minutos
|
|
||||||
Cache::put($cacheKey, $result, 120);
|
|
||||||
|
|
||||||
return response()->json($result, 200)
|
|
||||||
->header('Content-Type', 'application/json');
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
Log::error('Error en la consulta de atención', ['exception' => $e->getMessage()]);
|
|
||||||
return response()->json(['error' => 'Error en la consulta de atención'], 500);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function getChartsData($startDate, $endDate)
|
|
||||||
{
|
|
||||||
$baseParams = [
|
|
||||||
'start_date' => $startDate,
|
|
||||||
'end_date' => $endDate,
|
|
||||||
'type' => 'api',
|
|
||||||
'action' => 'index',
|
|
||||||
];
|
|
||||||
|
|
||||||
$baseUrl = 'https://apoyos.comalcalco.gob.mx/beneficiaries/stats-by-date-range';
|
|
||||||
|
|
||||||
// Solo hacer la petición principal para gráficas
|
|
||||||
$response = Http::timeout(15)->get($baseUrl, $baseParams);
|
|
||||||
|
|
||||||
if (!$response->successful()) {
|
|
||||||
throw new \Exception('Error del servicio principal');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $response->json();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function getFullData($startDate, $endDate, $period)
|
|
||||||
{
|
|
||||||
$baseParams = [
|
|
||||||
'start_date' => $startDate,
|
|
||||||
'end_date' => $endDate,
|
|
||||||
'type' => 'api',
|
|
||||||
'action' => 'index',
|
|
||||||
];
|
|
||||||
|
|
||||||
$urls = [
|
|
||||||
'main' => 'https://apoyos.comalcalco.gob.mx/beneficiaries/stats-by-date-range',
|
|
||||||
'counts' => 'https://apoyos.comalcalco.gob.mx/beneficiaries/counts-by-date',
|
|
||||||
'dashboard' => 'https://apoyos.comalcalco.gob.mx/beneficiaries/dashboard/api',
|
|
||||||
'dashboard_servicio' => 'https://apoyos.comalcalco.gob.mx/beneficiaries/dashboard/api?type=servicio'
|
|
||||||
];
|
|
||||||
|
|
||||||
// Hacer peticiones en paralelo usando HTTP Pool
|
|
||||||
$responses = Http::pool(fn($pool) => [
|
|
||||||
$pool->timeout(15)->get($urls['main'], $baseParams),
|
|
||||||
$pool->timeout(15)->get($urls['counts'], [
|
|
||||||
'start_date' => $startDate,
|
|
||||||
'end_date' => $endDate,
|
|
||||||
]),
|
|
||||||
$pool->timeout(15)->get($urls['dashboard']),
|
|
||||||
$pool->timeout(15)->get($urls['dashboard_servicio'])
|
|
||||||
]);
|
|
||||||
|
|
||||||
$mainData = [];
|
|
||||||
$countsAc = [];
|
|
||||||
$dashboardData = [];
|
|
||||||
$dashboardServicioData = [];
|
|
||||||
|
|
||||||
// Procesar respuestas
|
|
||||||
if ($responses[0]->successful()) {
|
|
||||||
$mainData = $responses[0]->json();
|
|
||||||
} else {
|
|
||||||
Log::error('Error en petición principal', ['status' => $responses[0]->status()]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($responses[1]->successful()) {
|
|
||||||
$countsAc = $responses[1]->json();
|
|
||||||
} else {
|
|
||||||
Log::error('Error en petición counts', ['status' => $responses[1]->status()]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($responses[2]->successful()) {
|
|
||||||
$dashboardData = $responses[2]->json();
|
|
||||||
} else {
|
|
||||||
Log::error('Error en petición dashboard', ['status' => $responses[2]->status()]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($responses[3]->successful()) {
|
|
||||||
$dashboardServicioData = $responses[3]->json();
|
|
||||||
} else {
|
|
||||||
Log::error('Error en petición dashboard servicio', ['status' => $responses[3]->status()]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Combinar todos los resultados
|
|
||||||
return array_merge(
|
|
||||||
is_array($mainData) ? $mainData : [],
|
|
||||||
[
|
|
||||||
'counts' => $countsAc,
|
|
||||||
'dashboard' => $dashboardData,
|
|
||||||
'dashboard_servicio' => $dashboardServicioData,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
public function exportExcel(Request $request)
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
// Obtener parámetros necesarios para la API
|
|
||||||
$params = $request->only([
|
|
||||||
'start_date',
|
|
||||||
'end_date',
|
|
||||||
'department'
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Validaciones básicas
|
|
||||||
if (!isset($params['start_date']) || !isset($params['end_date'])) {
|
|
||||||
return response()->json([
|
|
||||||
'error' => 'Las fechas son requeridas'
|
|
||||||
], 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isset($params['department']) || $params['department'] === '') {
|
|
||||||
return response()->json([
|
|
||||||
'error' => 'El departamento es requerido'
|
|
||||||
], 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validar que department sea 1 o 3
|
|
||||||
if (!in_array((int)$params['department'], [1, 3])) {
|
|
||||||
return response()->json([
|
|
||||||
'error' => 'El departamento debe ser Atención Ciudadana o DIF'
|
|
||||||
], 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
$url = 'https://apoyos.comalcalco.gob.mx/api/beneficiaries/export-excel';
|
|
||||||
|
|
||||||
// Hacer la petición a la API externa
|
|
||||||
$response = Http::withoutVerifying()
|
|
||||||
->timeout(60)
|
|
||||||
->get($url, $params);
|
|
||||||
|
|
||||||
if (!$response->successful()) {
|
|
||||||
Log::error('Error al exportar Excel', [
|
|
||||||
'status' => $response->status(),
|
|
||||||
'body' => $response->body(),
|
|
||||||
'params' => $params
|
|
||||||
]);
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'error' => 'Error al generar el archivo Excel: ' . $response->body()
|
|
||||||
], $response->status());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verificar que la respuesta sea realmente un Excel
|
|
||||||
$contentType = $response->header('Content-Type');
|
|
||||||
|
|
||||||
if (!str_contains($contentType, 'spreadsheet') && !str_contains($contentType, 'excel') && !str_contains($contentType, 'octet-stream')) {
|
|
||||||
Log::error('Respuesta no es un archivo Excel', [
|
|
||||||
'url' => $url,
|
|
||||||
'params' => $params,
|
|
||||||
'status' => $response->status(),
|
|
||||||
'content_type' => $contentType,
|
|
||||||
'body_preview' => substr($response->body(), 0, 500)
|
|
||||||
]);
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'error' => 'La API externa no devolvió un archivo Excel válido. URL: ' . $url . ' | Status: ' . $response->status()
|
|
||||||
], 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Nombre del departamento para el archivo
|
|
||||||
$departmentName = $params['department'] == 1 ? 'AtencionCiudadana' : 'DIF';
|
|
||||||
|
|
||||||
// Retornar el archivo Excel directamente con headers correctos
|
|
||||||
return response($response->body(), 200, [
|
|
||||||
'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
||||||
'Content-Disposition' => 'attachment; filename="beneficiarios_' . $departmentName . '_' .
|
|
||||||
$params['start_date'] . '_' . $params['end_date'] . '.xlsx"',
|
|
||||||
'Content-Length' => strlen($response->body()),
|
|
||||||
'Cache-Control' => 'no-cache, no-store, must-revalidate',
|
|
||||||
'Pragma' => 'no-cache',
|
|
||||||
'Expires' => '0'
|
|
||||||
]);
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
Log::error('Error en exportExcel', [
|
|
||||||
'exception' => $e->getMessage(),
|
|
||||||
'trace' => $e->getTraceAsString(),
|
|
||||||
'params' => $request->all()
|
|
||||||
]);
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'error' => 'Error interno del servidor: ' . $e->getMessage()
|
|
||||||
], 500);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,70 +0,0 @@
|
|||||||
<?php namespace App\Http\Controllers\Dashboard;
|
|
||||||
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Routing\Controller as BaseController;
|
|
||||||
use Illuminate\Support\Facades\Http;
|
|
||||||
use Illuminate\Support\Facades\Log;
|
|
||||||
use Illuminate\Support\Facades\Cache;
|
|
||||||
|
|
||||||
class ObrasController extends BaseController
|
|
||||||
{
|
|
||||||
public function Obras(Request $request)
|
|
||||||
{
|
|
||||||
$query = $request->only(['start', 'end', 'type', 'action']);
|
|
||||||
$query = array_merge($query, ['action' => 'index', 'type' => 'api'], $query);
|
|
||||||
|
|
||||||
// Caché por 2 minutos (datos más dinámicos)
|
|
||||||
$cacheKey = "obras_data_" . md5(serialize($query));
|
|
||||||
|
|
||||||
if ($cachedData = Cache::get($cacheKey)) {
|
|
||||||
return response()->json($cachedData, 200)
|
|
||||||
->header('Content-Type', 'application/json');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$response = Http::timeout(5)->get('https://obras-information.comalcalco.gob.mx/api/controller.php', $query);
|
|
||||||
|
|
||||||
if (! $response->successful()) {
|
|
||||||
Log::warning('Obras service error', [
|
|
||||||
'status' => $response->status(),
|
|
||||||
'body' => $response->body(),
|
|
||||||
'query' => $query
|
|
||||||
]);
|
|
||||||
return response()->json(['error' => 'External service error'], $response->status());
|
|
||||||
}
|
|
||||||
|
|
||||||
$mainData = $response->json();
|
|
||||||
|
|
||||||
$counterQuery = array_merge($query, ['action' => 'getCounters']);
|
|
||||||
$countersResponse = Http::timeout(5)->get('https://obras-information.comalcalco.gob.mx/api/controller.php', $counterQuery);
|
|
||||||
|
|
||||||
$countersData = [];
|
|
||||||
if ($countersResponse->successful()) {
|
|
||||||
$countersData = $countersResponse->json();
|
|
||||||
}
|
|
||||||
|
|
||||||
$usersQuery = array_merge($query, ['action' => 'actionsOfSupervisors']);
|
|
||||||
$userResponse = Http::timeout(5)->get('https://obras-information.comalcalco.gob.mx/api/controller.php', $usersQuery);
|
|
||||||
|
|
||||||
$usersData = [];
|
|
||||||
if($userResponse->successful()) {
|
|
||||||
$usersData = $userResponse->json();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Combinar ambas respuestas
|
|
||||||
$combinedData = array_merge($mainData, ['counters' => $countersData, 'users' => $usersData]);
|
|
||||||
|
|
||||||
// Cachear por 2 minutos
|
|
||||||
Cache::put($cacheKey, $combinedData, now()->addMinutes(2));
|
|
||||||
|
|
||||||
return response()->json($combinedData, $response->status())
|
|
||||||
->header('Content-Type', $response->header('Content-Type', 'application/json'));
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
Log::error('Proxy error: '.$e->getMessage(), [
|
|
||||||
'query' => $query,
|
|
||||||
'trace' => $e->getTraceAsString()
|
|
||||||
]);
|
|
||||||
return response()->json(['error' => 'Unable to contact external service'], 502);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,54 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Controllers\Dashboard;
|
|
||||||
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Routing\Controller as BaseController;
|
|
||||||
use Illuminate\Support\Facades\Http;
|
|
||||||
use Illuminate\Support\Facades\Log;
|
|
||||||
use Illuminate\Support\Facades\Cache;
|
|
||||||
|
|
||||||
class TramiteController extends BaseController
|
|
||||||
{
|
|
||||||
public function tramiteEspecial(Request $request)
|
|
||||||
{
|
|
||||||
$query = $request->only(['start', 'end', 'type']);
|
|
||||||
$query = array_merge(['type' => 'api'], $query);
|
|
||||||
|
|
||||||
// Caché por 2 minutos (datos más dinámicos)
|
|
||||||
$cacheKey = "tramites_data_" . md5(serialize($query));
|
|
||||||
|
|
||||||
if ($cachedData = Cache::get($cacheKey)) {
|
|
||||||
return response()->json($cachedData, 200)
|
|
||||||
->header('Content-Type', 'application/json');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$response = Http::timeout(20)->get('https://tramites.comalcalco.gob.mx/reporte-especial', $query);
|
|
||||||
|
|
||||||
if (!$response->successful()) {
|
|
||||||
Log::warning('Tramites service error', [
|
|
||||||
'status' => $response->status(),
|
|
||||||
'body' => $response->body(),
|
|
||||||
'query' => $query
|
|
||||||
]);
|
|
||||||
return response()->json(['error' => 'External service error'], $response->status());
|
|
||||||
}
|
|
||||||
|
|
||||||
$data = $response->json();
|
|
||||||
|
|
||||||
// Cachear por 2 minutos
|
|
||||||
Cache::put($cacheKey, $data, now()->addMinutes(2));
|
|
||||||
|
|
||||||
return response()->json($data, $response->status())
|
|
||||||
->header('Content-Type', $response->header('Content-Type', 'application/json'));
|
|
||||||
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
Log::error('Proxy error: ' . $e->getMessage(), [
|
|
||||||
'query' => $query,
|
|
||||||
'trace' => $e->getTraceAsString()
|
|
||||||
]);
|
|
||||||
return response()->json(['error' => 'Unable to contact external service'], 502);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -17,7 +17,7 @@ class RouteServiceProvider extends ServiceProvider
|
|||||||
*
|
*
|
||||||
* @var string
|
* @var string
|
||||||
*/
|
*/
|
||||||
public const HOME = '/tramites';
|
public const HOME = '/dashboard/welcome';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Define your route model bindings, pattern filters, and other route configuration.
|
* Define your route model bindings, pattern filters, and other route configuration.
|
||||||
|
|||||||
@ -7,12 +7,12 @@
|
|||||||
"main-on":"#000",
|
"main-on":"#000",
|
||||||
"main-dark":"#1E1F1C",
|
"main-dark":"#1E1F1C",
|
||||||
"main-dark-on":"#fff",
|
"main-dark-on":"#fff",
|
||||||
"primary":"#621132",
|
"primary":"#111827",
|
||||||
"primary-on":"#fff",
|
"primary-on":"#fff",
|
||||||
"primary-dark":"#621132",
|
"primary-dark":"#000",
|
||||||
"primary-dark-on":"#fff",
|
"primary-dark-on":"#fff",
|
||||||
"secondary":"#7d1a42",
|
"secondary":"#374151",
|
||||||
"secondary-dark":"#7d1a42",
|
"secondary-dark":"#989A9C",
|
||||||
"white":"#FFFFFF",
|
"white":"#FFFFFF",
|
||||||
"white-dark":"#FFFFFF",
|
"white-dark":"#FFFFFF",
|
||||||
"success":"#22C55E",
|
"success":"#22C55E",
|
||||||
|
|||||||
@ -35,11 +35,11 @@ public function run()
|
|||||||
[
|
[
|
||||||
$developerEmail,
|
$developerEmail,
|
||||||
$developerPass
|
$developerPass
|
||||||
] = UserSecureSupport::new('developer@golsystems.com');
|
] = UserSecureSupport::new('developer@notsoweb.com');
|
||||||
|
|
||||||
User::create([
|
User::create([
|
||||||
'name' => 'Developer',
|
'name' => 'Developer',
|
||||||
'paternal' => 'golsystems',
|
'paternal' => 'Notsoweb',
|
||||||
'email' => $developerEmail,
|
'email' => $developerEmail,
|
||||||
'phone' => '5631809090',
|
'phone' => '5631809090',
|
||||||
'password' => $developerPass,
|
'password' => $developerPass,
|
||||||
@ -49,14 +49,22 @@ public function run()
|
|||||||
[
|
[
|
||||||
$adminEmail,
|
$adminEmail,
|
||||||
$adminPass
|
$adminPass
|
||||||
] = UserSecureSupport::new('admin@comalcalco.com');
|
] = UserSecureSupport::new('admin@notsoweb.com');
|
||||||
|
|
||||||
User::create([
|
User::create([
|
||||||
'name' => 'Administrador',
|
'name' => 'Administrador',
|
||||||
'paternal' => 'comalcalco',
|
'paternal' => 'Notsoweb',
|
||||||
'email' => $adminEmail,
|
'email' => $adminEmail,
|
||||||
'password' => $adminPass
|
'password' => $adminPass
|
||||||
])->assignRole('admin');
|
])->assignRole('admin');
|
||||||
|
|
||||||
|
// Usuario de prueba
|
||||||
|
User::create([
|
||||||
|
'name' => 'Demo',
|
||||||
|
'paternal' => 'Notsoweb',
|
||||||
|
'email' => 'demo@notsoweb.com',
|
||||||
|
'password' => Hash::make('Demo')
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
39
package-lock.json
generated
39
package-lock.json
generated
@ -1,22 +1,19 @@
|
|||||||
{
|
{
|
||||||
"name": "maquetador-graficas",
|
"name": "template-laravel-vuejs",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@googlemaps/js-api-loader": "^1.16.10",
|
|
||||||
"@inertiajs/vue3": "^1.0.0-beta.2",
|
"@inertiajs/vue3": "^1.0.0-beta.2",
|
||||||
"@soketi/soketi": "^1.6.0",
|
"@soketi/soketi": "^1.6.0",
|
||||||
"@vueuse/core": "^9.6.0",
|
"@vueuse/core": "^9.6.0",
|
||||||
"chart.js": "^4.5.0",
|
|
||||||
"dotenv": "^16.3.1",
|
"dotenv": "^16.3.1",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"laravel-echo": "^1.14.2",
|
"laravel-echo": "^1.14.2",
|
||||||
"pusher-js": "^7.5.0",
|
"pusher-js": "^7.5.0",
|
||||||
"sweetalert2": "^11.4.8",
|
"sweetalert2": "^11.4.8",
|
||||||
"toastr": "^2.1.4",
|
"toastr": "^2.1.4",
|
||||||
"vue-chartjs": "^5.3.2",
|
|
||||||
"vue-i18n": "^9.2.2",
|
"vue-i18n": "^9.2.2",
|
||||||
"vue-multiselect": "^3.0.0-alpha.2"
|
"vue-multiselect": "^3.0.0-alpha.2"
|
||||||
},
|
},
|
||||||
@ -409,12 +406,6 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@googlemaps/js-api-loader": {
|
|
||||||
"version": "1.16.10",
|
|
||||||
"resolved": "https://registry.npmjs.org/@googlemaps/js-api-loader/-/js-api-loader-1.16.10.tgz",
|
|
||||||
"integrity": "sha512-c2erv2k7P2ilYzMmtYcMgAR21AULosQuUHJbStnrvRk2dG93k5cqptDrh9A8p+ZNlyhiqEOgHW7N9PAizdUM7Q==",
|
|
||||||
"license": "Apache-2.0"
|
|
||||||
},
|
|
||||||
"node_modules/@inertiajs/core": {
|
"node_modules/@inertiajs/core": {
|
||||||
"version": "1.0.14",
|
"version": "1.0.14",
|
||||||
"resolved": "https://registry.npmjs.org/@inertiajs/core/-/core-1.0.14.tgz",
|
"resolved": "https://registry.npmjs.org/@inertiajs/core/-/core-1.0.14.tgz",
|
||||||
@ -532,12 +523,6 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@kurkle/color": {
|
|
||||||
"version": "0.3.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
|
|
||||||
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
|
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
|
||||||
"version": "3.0.2",
|
"version": "3.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.2.tgz",
|
||||||
@ -1693,18 +1678,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/charm/-/charm-0.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/charm/-/charm-0.1.2.tgz",
|
||||||
"integrity": "sha512-syedaZ9cPe7r3hoQA9twWYKu5AIyCswN5+szkmPBe9ccdLrj4bYaCnLVPTLd2kgVRc7+zoX4tyPgRnFKCj5YjQ=="
|
"integrity": "sha512-syedaZ9cPe7r3hoQA9twWYKu5AIyCswN5+szkmPBe9ccdLrj4bYaCnLVPTLd2kgVRc7+zoX4tyPgRnFKCj5YjQ=="
|
||||||
},
|
},
|
||||||
"node_modules/chart.js": {
|
|
||||||
"version": "4.5.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.0.tgz",
|
|
||||||
"integrity": "sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@kurkle/color": "^0.3.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"pnpm": ">=8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/chokidar": {
|
"node_modules/chokidar": {
|
||||||
"version": "3.5.3",
|
"version": "3.5.3",
|
||||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz",
|
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz",
|
||||||
@ -5478,16 +5451,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vue-chartjs": {
|
|
||||||
"version": "5.3.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/vue-chartjs/-/vue-chartjs-5.3.2.tgz",
|
|
||||||
"integrity": "sha512-NrkbRRoYshbXbWqJkTN6InoDVwVb90C0R7eAVgMWcB9dPikbruaOoTFjFYHE/+tNPdIe6qdLCDjfjPHQ0fw4jw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
|
||||||
"chart.js": "^4.1.1",
|
|
||||||
"vue": "^3.0.0-0 || ^2.7.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vue-i18n": {
|
"node_modules/vue-i18n": {
|
||||||
"version": "9.7.1",
|
"version": "9.7.1",
|
||||||
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-9.7.1.tgz",
|
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-9.7.1.tgz",
|
||||||
|
|||||||
@ -19,18 +19,15 @@
|
|||||||
"vue": "^3.2.31"
|
"vue": "^3.2.31"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@googlemaps/js-api-loader": "^1.16.10",
|
|
||||||
"@inertiajs/vue3": "^1.0.0-beta.2",
|
"@inertiajs/vue3": "^1.0.0-beta.2",
|
||||||
"@soketi/soketi": "^1.6.0",
|
"@soketi/soketi": "^1.6.0",
|
||||||
"@vueuse/core": "^9.6.0",
|
"@vueuse/core": "^9.6.0",
|
||||||
"chart.js": "^4.5.0",
|
|
||||||
"dotenv": "^16.3.1",
|
"dotenv": "^16.3.1",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"laravel-echo": "^1.14.2",
|
"laravel-echo": "^1.14.2",
|
||||||
"pusher-js": "^7.5.0",
|
"pusher-js": "^7.5.0",
|
||||||
"sweetalert2": "^11.4.8",
|
"sweetalert2": "^11.4.8",
|
||||||
"toastr": "^2.1.4",
|
"toastr": "^2.1.4",
|
||||||
"vue-chartjs": "^5.3.2",
|
|
||||||
"vue-i18n": "^9.2.2",
|
"vue-i18n": "^9.2.2",
|
||||||
"vue-multiselect": "^3.0.0-alpha.2"
|
"vue-multiselect": "^3.0.0-alpha.2"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -61,7 +61,7 @@ .btn-icon-secondary {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.header-icon {
|
.header-icon {
|
||||||
@apply hover:text-primary focus:text-primary hover:scale-105 hover:transition hover:duration-300;
|
@apply hover:text-yellow-500 focus:text-yellow-600 hover:scale-105 hover:transition hover:duration-300;
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-primary {
|
.input-primary {
|
||||||
|
|||||||
@ -1,34 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { Bar } from 'vue-chartjs'
|
|
||||||
import {
|
|
||||||
Chart as ChartJS,
|
|
||||||
Title,
|
|
||||||
Tooltip,
|
|
||||||
Legend,
|
|
||||||
BarElement,
|
|
||||||
CategoryScale,
|
|
||||||
LinearScale,
|
|
||||||
PointElement,
|
|
||||||
LineElement
|
|
||||||
} from 'chart.js'
|
|
||||||
|
|
||||||
ChartJS.register(
|
|
||||||
Title,
|
|
||||||
Tooltip,
|
|
||||||
Legend,
|
|
||||||
BarElement,
|
|
||||||
CategoryScale,
|
|
||||||
LinearScale,
|
|
||||||
PointElement,
|
|
||||||
LineElement
|
|
||||||
)
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
chartData: Object,
|
|
||||||
chartOptions: Object
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Bar :data="chartData" :options="chartOptions" />
|
|
||||||
</template>
|
|
||||||
@ -1,32 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { Line } from 'vue-chartjs'
|
|
||||||
import {
|
|
||||||
Chart as ChartJS,
|
|
||||||
Title,
|
|
||||||
Tooltip,
|
|
||||||
Legend,
|
|
||||||
LineElement,
|
|
||||||
PointElement,
|
|
||||||
CategoryScale,
|
|
||||||
LinearScale,
|
|
||||||
} from 'chart.js'
|
|
||||||
|
|
||||||
ChartJS.register(
|
|
||||||
Title,
|
|
||||||
Tooltip,
|
|
||||||
Legend,
|
|
||||||
LineElement,
|
|
||||||
PointElement,
|
|
||||||
CategoryScale,
|
|
||||||
LinearScale
|
|
||||||
)
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
chartData: Object,
|
|
||||||
chartOptions: Object
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Line :data="chartData" :options="chartOptions" />
|
|
||||||
</template>
|
|
||||||
@ -1,29 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { Pie } from 'vue-chartjs'
|
|
||||||
import {
|
|
||||||
Chart as ChartJS,
|
|
||||||
Title,
|
|
||||||
Tooltip,
|
|
||||||
Legend,
|
|
||||||
ArcElement
|
|
||||||
} from 'chart.js'
|
|
||||||
|
|
||||||
ChartJS.register(Title, Tooltip, Legend, ArcElement)
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
chartData: {
|
|
||||||
type: Object,
|
|
||||||
required: true
|
|
||||||
},
|
|
||||||
chartOptions: {
|
|
||||||
type: Object,
|
|
||||||
default: () => ({ responsive: true, maintainAspectRatio: false })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="pie-chart-wrapper" style="position: relative; width:100%; height:300px;">
|
|
||||||
<Pie :data="chartData" :options="chartOptions" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@ -1,42 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { Bar } from "vue-chartjs";
|
|
||||||
import {
|
|
||||||
Chart as ChartJS,
|
|
||||||
Title,
|
|
||||||
Tooltip,
|
|
||||||
Legend,
|
|
||||||
BarElement,
|
|
||||||
CategoryScale,
|
|
||||||
LinearScale,
|
|
||||||
} from "chart.js";
|
|
||||||
|
|
||||||
ChartJS.register(
|
|
||||||
Title,
|
|
||||||
Tooltip,
|
|
||||||
Legend,
|
|
||||||
BarElement,
|
|
||||||
CategoryScale,
|
|
||||||
LinearScale
|
|
||||||
);
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
chartData: {
|
|
||||||
type: Object,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
chartOptions: {
|
|
||||||
type: Object,
|
|
||||||
default: () => ({
|
|
||||||
responsive: true,
|
|
||||||
scales: {
|
|
||||||
x: { stacked: true },
|
|
||||||
y: { stacked: true },
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Bar :data="chartData" :options="chartOptions" />
|
|
||||||
</template>
|
|
||||||
@ -1,125 +0,0 @@
|
|||||||
<template>
|
|
||||||
<footer class="bg-[#58595b] text-white py-8">
|
|
||||||
<div class="container mx-auto px-4 sm:px-6 lg:px-8">
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
|
|
||||||
<!-- Logo y información de contacto -->
|
|
||||||
<div class="flex flex-col md:flex-row gap-6">
|
|
||||||
<!-- Logo -->
|
|
||||||
<div class="flex-shrink-0">
|
|
||||||
<img
|
|
||||||
src="https://apoyos.comalcalco.gob.mx/images/logo_blanco.png"
|
|
||||||
alt="Comalcalco Logo"
|
|
||||||
class="h-20 w-auto object-contain"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Información de contacto -->
|
|
||||||
<div class="space-y-3">
|
|
||||||
<!-- Dirección -->
|
|
||||||
<div class="flex items-start space-x-3">
|
|
||||||
<div class="bg-yellow-600 rounded-full p-2 flex-shrink-0">
|
|
||||||
<svg class="w-4 h-4 text-white" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path fill-rule="evenodd" d="M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" clip-rule="evenodd"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<p class="text-sm text-gray-300">
|
|
||||||
Plaza Juárez S/N, Centro, C.P. 86300, Comalcalco,<br>
|
|
||||||
Tabasco, México.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Teléfono -->
|
|
||||||
<div class="flex items-center space-x-3">
|
|
||||||
<div class="bg-yellow-600 rounded-full p-2 flex-shrink-0">
|
|
||||||
<svg class="w-4 h-4 text-white" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path d="M2 3a1 1 0 011-1h2.153a1 1 0 01.986.836l.74 4.435a1 1 0 01-.54 1.06l-1.548.773a11.037 11.037 0 006.105 6.105l.774-1.548a1 1 0 011.059-.54l4.435.74a1 1 0 01.836.986V17a1 1 0 01-1 1h-2C7.82 18 2 12.18 2 5V3z"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<p class="text-sm text-gray-300">9331140000</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Horarios -->
|
|
||||||
<div class="flex items-center space-x-3">
|
|
||||||
<div class="bg-yellow-600 rounded-full p-2 flex-shrink-0">
|
|
||||||
<svg class="w-4 h-4 text-white" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clip-rule="evenodd"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<p class="text-sm text-gray-300">Lunes a Viernes de 8:00 a 16:00 horas</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Email -->
|
|
||||||
<div class="flex items-center space-x-3">
|
|
||||||
<div class="bg-yellow-600 rounded-full p-2 flex-shrink-0">
|
|
||||||
<svg class="w-4 h-4 text-white" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path d="M2.003 5.884L10 9.882l7.997-3.998A2 2 0 0016 4H4a2 2 0 00-1.997 1.884z"/>
|
|
||||||
<path d="M18 8.118l-8 4-8-4V14a2 2 0 002 2h12a2 2 0 002-2V8.118z"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<p class="text-sm text-gray-300">
|
|
||||||
<a href="mailto:mejoraregulatoria@comalcalco.gob.mx" class="hover:text-yellow-400 transition-colors">
|
|
||||||
mejoraregulatoria@comalcalco.gob.mx
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Servicios de emergencia -->
|
|
||||||
<div class="space-y-4">
|
|
||||||
<!-- Emergencias -->
|
|
||||||
<div class="flex items-center space-x-3">
|
|
||||||
<div class="bg-yellow-600 rounded-full p-2 flex-shrink-0">
|
|
||||||
<svg class="w-4 h-4 text-white" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p class="text-yellow-400 font-semibold text-sm">Emergencias</p>
|
|
||||||
<p class="text-white text-lg font-bold">911</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Bomberos -->
|
|
||||||
<div class="flex items-center space-x-3">
|
|
||||||
<div class="bg-yellow-600 rounded-full p-2 flex-shrink-0">
|
|
||||||
<svg class="w-4 h-4 text-white" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path fill-rule="evenodd" d="M12.395 2.553a1 1 0 00-1.45-.385c-.345.23-.614.558-.822.88-.214.33-.403.713-.57 1.116-.334.804-.614 1.768-.84 2.734a31.365 31.365 0 00-.613 3.58 2.64 2.64 0 01-.945-1.067c-.328-.68-.398-1.534-.398-2.654A1 1 0 005.05 6.05 6.981 6.981 0 003 11a7 7 0 1011.95-4.95c-.592-.591-.98-.985-1.348-1.467-.363-.476-.724-1.063-1.207-2.03zM12.12 15.12A3 3 0 017 13s.879.5 2.5.5c0-1 .5-4 1.25-4.5.5 1 .786 1.293 1.371 1.879A2.99 2.99 0 0113 13a2.99 2.99 0 01-.879 2.121z" clip-rule="evenodd"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p class="text-yellow-400 font-semibold text-sm">Bomberos</p>
|
|
||||||
<p class="text-white text-lg font-bold">993 315 5670</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Cruz Roja -->
|
|
||||||
<div class="flex items-center space-x-3">
|
|
||||||
<div class="bg-yellow-600 rounded-full p-2 flex-shrink-0">
|
|
||||||
<svg class="w-4 h-4 text-white" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path fill-rule="evenodd" d="M6 2a2 2 0 00-2 2v12a2 2 0 002 2h8a2 2 0 002-2V4a2 2 0 00-2-2H6zm1 2a1 1 0 000 2h6a1 1 0 100-2H7zm6 7a1 1 0 011 1v3a1 1 0 11-2 0v-3a1 1 0 011-1zm-3 3a1 1 0 100 2h.01a1 1 0 100-2H10zm-4 1a1 1 0 011-1h.01a1 1 0 110 2H7a1 1 0 01-1-1zm1-4a1 1 0 100 2h.01a1 1 0 100-2H7zm2 1a1 1 0 011-1h.01a1 1 0 110 2H10a1 1 0 01-1-1zm4-4a1 1 0 100 2h.01a1 1 0 100-2H13zM9 9a1 1 0 011-1h.01a1 1 0 110 2H10a1 1 0 01-1-1zM7 8a1 1 0 000 2h.01a1 1 0 000-2H7z" clip-rule="evenodd"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p class="text-yellow-400 font-semibold text-sm">Cruz Roja Mexicana</p>
|
|
||||||
<p class="text-white text-lg font-bold">993 334 3004</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Copyright -->
|
|
||||||
<div class="mt-8 pt-6 border-t border-gray-600 text-center">
|
|
||||||
<div class="flex flex-col md:flex-row justify-center items-center space-y-2 md:space-y-0 md:space-x-6">
|
|
||||||
<p class="text-sm text-gray-300">
|
|
||||||
© Ayuntamiento de Comalcalco 2024-2027
|
|
||||||
</p>
|
|
||||||
<span class="hidden md:block text-gray-500">|</span>
|
|
||||||
<a href="#" class="text-sm text-gray-300 hover:text-yellow-400 transition-colors">
|
|
||||||
Aviso de Privacidad
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
</template>
|
|
||||||
@ -1,118 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { ref, watch, computed, onBeforeUnmount } from 'vue';
|
|
||||||
import Input from '@/Components/Dashboard/Form/Input.vue';
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
modelValue: {
|
|
||||||
type: Object,
|
|
||||||
default: () => ({ start: '', end: '' })
|
|
||||||
},
|
|
||||||
required: Boolean,
|
|
||||||
onError: String,
|
|
||||||
idStart: { type: String, default: 'startDate' },
|
|
||||||
idEnd: { type: String, default: 'endDate' },
|
|
||||||
titleStart: { type: String, default: 'Fecha inicio' },
|
|
||||||
titleEnd: { type: String, default: 'Fecha fin' },
|
|
||||||
presets: {
|
|
||||||
type: Boolean,
|
|
||||||
default: true
|
|
||||||
},
|
|
||||||
debounceMs: {
|
|
||||||
type: Number,
|
|
||||||
default: 350
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue', 'invalid']);
|
|
||||||
|
|
||||||
const local = ref({ start: props.modelValue.start, end: props.modelValue.end });
|
|
||||||
let debounceId = null;
|
|
||||||
|
|
||||||
// --- utilidades fechas ---
|
|
||||||
const fmt = (d) => new Date(d).toISOString().slice(0,10);
|
|
||||||
const today = fmt(new Date());
|
|
||||||
const firstDayOfMonth = fmt(new Date(new Date().getFullYear(), new Date().getMonth(), 1));
|
|
||||||
const daysAgo = (n) => {
|
|
||||||
const d = new Date(); d.setDate(d.getDate()-n);
|
|
||||||
return fmt(d);
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- validación ---
|
|
||||||
const isValid = computed(() => {
|
|
||||||
const { start, end } = local.value;
|
|
||||||
if (!start || !end) return !props.required; // si no son obligatorias
|
|
||||||
return new Date(start) <= new Date(end);
|
|
||||||
});
|
|
||||||
|
|
||||||
// sincroniza si el padre cambia externamente
|
|
||||||
watch(() => props.modelValue, (nv) => {
|
|
||||||
if (nv?.start !== local.value.start || nv?.end !== local.value.end) {
|
|
||||||
local.value = { start: nv?.start || '', end: nv?.end || '' };
|
|
||||||
}
|
|
||||||
}, { deep: true });
|
|
||||||
|
|
||||||
// emite con debounce solo si es válido
|
|
||||||
const emitChange = () => {
|
|
||||||
clearTimeout(debounceId);
|
|
||||||
debounceId = setTimeout(() => {
|
|
||||||
if (isValid.value) {
|
|
||||||
emit('update:modelValue', { ...local.value });
|
|
||||||
} else {
|
|
||||||
emit('invalid', { ...local.value });
|
|
||||||
}
|
|
||||||
}, props.debounceMs);
|
|
||||||
};
|
|
||||||
|
|
||||||
watch(() => local.value.start, emitChange);
|
|
||||||
watch(() => local.value.end, emitChange);
|
|
||||||
|
|
||||||
// presets
|
|
||||||
const applyPreset = (type) => {
|
|
||||||
if (type === 'today') {
|
|
||||||
local.value = { start: today, end: today };
|
|
||||||
} else if (type === 'last7') {
|
|
||||||
local.value = { start: daysAgo(6), end: today }; // 7 días incluyendo hoy
|
|
||||||
} else if (type === 'month') {
|
|
||||||
local.value = { start: firstDayOfMonth, end: today };
|
|
||||||
}
|
|
||||||
emitChange();
|
|
||||||
};
|
|
||||||
|
|
||||||
onBeforeUnmount(() => clearTimeout(debounceId));
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="flex flex-wrap items-end gap-3">
|
|
||||||
<div class="flex space-x-4">
|
|
||||||
<Input
|
|
||||||
:id="idStart"
|
|
||||||
type="date"
|
|
||||||
:title="titleStart"
|
|
||||||
v-model="local.start"
|
|
||||||
:required="required"
|
|
||||||
:onError="onError"
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
:id="idEnd"
|
|
||||||
type="date"
|
|
||||||
:title="titleEnd"
|
|
||||||
v-model="local.end"
|
|
||||||
:required="required"
|
|
||||||
:onError="onError"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="presets" class="flex gap-2">
|
|
||||||
<button type="button" class="px-3 py-2 rounded bg-gray-200 hover:bg-gray-300"
|
|
||||||
@click="applyPreset('today')">Hoy</button>
|
|
||||||
<button type="button" class="px-3 py-2 rounded bg-gray-200 hover:bg-gray-300"
|
|
||||||
@click="applyPreset('last7')">Últimos 7 días</button>
|
|
||||||
<button type="button" class="px-3 py-2 rounded bg-gray-200 hover:bg-gray-300"
|
|
||||||
@click="applyPreset('month')">Mes actual</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p v-if="!isValid" class="text-sm text-red-600 w-full">
|
|
||||||
La fecha fin debe ser mayor o igual a la fecha inicio.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@ -1,694 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { ref, reactive, onMounted, onUnmounted, computed, watch } from "vue";
|
|
||||||
import { Loader } from "@googlemaps/js-api-loader";
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
counters: { type: Object, required: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
const mapContainer = ref(null);
|
|
||||||
const loading = ref(true);
|
|
||||||
const error = ref(null);
|
|
||||||
const map = ref(null);
|
|
||||||
const markers = ref([]);
|
|
||||||
const geocoder = ref(null);
|
|
||||||
const isMounted = ref(false);
|
|
||||||
|
|
||||||
const filters = reactive({
|
|
||||||
sin_avances: true,
|
|
||||||
abiertas: true,
|
|
||||||
finalizadas: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const searchQuery = ref("");
|
|
||||||
const searchResults = ref([]);
|
|
||||||
const showSearchResults = ref(false);
|
|
||||||
const searchLoading = ref(false);
|
|
||||||
|
|
||||||
const geocodingCache = new Map();
|
|
||||||
const failedGeocodings = ref([]);
|
|
||||||
|
|
||||||
const markerColors = {
|
|
||||||
sin_avances: "#EF4444",
|
|
||||||
abiertas: "#F59E0B",
|
|
||||||
finalizadas: "#10B981",
|
|
||||||
};
|
|
||||||
const COMALCALCO_CENTER = { lat: 18.26, lng: -93.25 };
|
|
||||||
|
|
||||||
const COMALCALCO_BOUNDS = {
|
|
||||||
south: 18.1,
|
|
||||||
west: -93.4,
|
|
||||||
north: 18.4,
|
|
||||||
east: -93.0,
|
|
||||||
};
|
|
||||||
|
|
||||||
const visibleMarkersCount = computed(
|
|
||||||
() => markers.value.filter((m) => m.visible).length
|
|
||||||
);
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
isMounted.value = true;
|
|
||||||
initMap();
|
|
||||||
});
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
isMounted.value = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.counters,
|
|
||||||
() => {
|
|
||||||
if (map.value && geocoder.value) {
|
|
||||||
loadMarkers();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ deep: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
const initMap = async () => {
|
|
||||||
loading.value = true;
|
|
||||||
error.value = null;
|
|
||||||
try {
|
|
||||||
const loader = new Loader({
|
|
||||||
apiKey: import.meta.env.VITE_GOOGLE_MAPS_API_KEY,
|
|
||||||
version: "weekly",
|
|
||||||
// Usar solo librerías básicas por compatibilidad
|
|
||||||
libraries: ["places", "geometry"],
|
|
||||||
});
|
|
||||||
window.google = await loader.load();
|
|
||||||
|
|
||||||
if (!isMounted.value) return;
|
|
||||||
|
|
||||||
map.value = new google.maps.Map(mapContainer.value, {
|
|
||||||
center: COMALCALCO_CENTER,
|
|
||||||
zoom: 11,
|
|
||||||
// Mantener mapId pero para funcionalidad futura
|
|
||||||
mapId: "OBRAS_MAP_COMALCALCO",
|
|
||||||
mapTypeControl: true,
|
|
||||||
streetViewControl: true,
|
|
||||||
fullscreenControl: true,
|
|
||||||
zoomControl: true,
|
|
||||||
// Estilos mejorados
|
|
||||||
styles: [
|
|
||||||
{
|
|
||||||
featureType: "poi",
|
|
||||||
elementType: "labels.icon",
|
|
||||||
stylers: [{ visibility: "off" }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
featureType: "transit",
|
|
||||||
elementType: "labels.icon",
|
|
||||||
stylers: [{ visibility: "off" }],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
geocoder.value = new google.maps.Geocoder();
|
|
||||||
await loadMarkers();
|
|
||||||
} catch (err) {
|
|
||||||
if (!isMounted.value) return;
|
|
||||||
console.error("Error loading map:", err);
|
|
||||||
error.value = err.message || "Error al cargar Google Maps";
|
|
||||||
} finally {
|
|
||||||
if (isMounted.value) loading.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadMarkers = async () => {
|
|
||||||
if (!map.value || !geocoder.value || !isMounted.value) return;
|
|
||||||
loading.value = true;
|
|
||||||
clearMarkers();
|
|
||||||
failedGeocodings.value = [];
|
|
||||||
|
|
||||||
const allObras = getAllObras();
|
|
||||||
console.log("Total obras a procesar:", allObras.length);
|
|
||||||
|
|
||||||
if (allObras.length === 0) {
|
|
||||||
if (isMounted.value) loading.value = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const obrasPorGrupo = groupObrasByAddressAndStatus(allObras);
|
|
||||||
const uniqueAddresses = [
|
|
||||||
...new Set(Array.from(obrasPorGrupo.values()).map((g) => g.direccion)),
|
|
||||||
].filter(Boolean);
|
|
||||||
|
|
||||||
console.log("Direcciones únicas a geocodificar:", uniqueAddresses.length);
|
|
||||||
|
|
||||||
await geocodeUniqueAddresses(uniqueAddresses);
|
|
||||||
|
|
||||||
if (!isMounted.value) return;
|
|
||||||
|
|
||||||
createMarkersFromGroups(obrasPorGrupo);
|
|
||||||
loading.value = false;
|
|
||||||
updateMarkers();
|
|
||||||
|
|
||||||
console.log("Marcadores creados exitosamente:", markers.value.length);
|
|
||||||
};
|
|
||||||
|
|
||||||
const normalizeAddressForGeocoding = (address) => {
|
|
||||||
if (!address) return "";
|
|
||||||
return address
|
|
||||||
.replace(/^RA\.\s*/, "RANCHERÍA ")
|
|
||||||
.replace(/(\d+)\s*DA\s*SECCION/i, "$1 SECCION")
|
|
||||||
.replace(/\s+/g, " ")
|
|
||||||
.trim();
|
|
||||||
};
|
|
||||||
|
|
||||||
const geocodeAndCacheAddress = (address) => {
|
|
||||||
if (!address || geocodingCache.has(address)) {
|
|
||||||
return Promise.resolve();
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const normalizedAddress = normalizeAddressForGeocoding(address);
|
|
||||||
|
|
||||||
const request = {
|
|
||||||
address: normalizedAddress,
|
|
||||||
componentRestrictions: {
|
|
||||||
country: "MX",
|
|
||||||
administrativeArea: "Tabasco",
|
|
||||||
},
|
|
||||||
bounds: new google.maps.LatLngBounds(
|
|
||||||
{ lat: COMALCALCO_BOUNDS.south, lng: COMALCALCO_BOUNDS.west },
|
|
||||||
{ lat: COMALCALCO_BOUNDS.north, lng: COMALCALCO_BOUNDS.east }
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
geocoder.value.geocode(request, (results, status) => {
|
|
||||||
if (status === "OK" && results?.length) {
|
|
||||||
// Con la búsqueda sesgada, el primer resultado es ahora mucho más confiable
|
|
||||||
const bestResult = results[0];
|
|
||||||
geocodingCache.set(address, {
|
|
||||||
success: true,
|
|
||||||
position: {
|
|
||||||
lat: bestResult.geometry.location.lat(),
|
|
||||||
lng: bestResult.geometry.location.lng(),
|
|
||||||
},
|
|
||||||
formatted_address: bestResult.formatted_address,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
geocodingCache.set(address, { success: false, status });
|
|
||||||
}
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const groupObrasByAddressAndStatus = (obras) => {
|
|
||||||
const grouped = new Map();
|
|
||||||
obras.forEach((obra) => {
|
|
||||||
const key = `${obra.direccion_obra}|${obra.estado}`;
|
|
||||||
if (!grouped.has(key)) {
|
|
||||||
grouped.set(key, {
|
|
||||||
direccion: obra.direccion_obra,
|
|
||||||
estado: obra.estado,
|
|
||||||
obras: [],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
grouped.get(key).obras.push(obra);
|
|
||||||
});
|
|
||||||
return grouped;
|
|
||||||
};
|
|
||||||
|
|
||||||
const geocodeUniqueAddresses = async (addresses) => {
|
|
||||||
const geocodingPromises = addresses.map((addr) =>
|
|
||||||
geocodeAndCacheAddress(addr)
|
|
||||||
);
|
|
||||||
await Promise.all(geocodingPromises);
|
|
||||||
};
|
|
||||||
|
|
||||||
const createMarkersFromGroups = (obrasPorGrupo) => {
|
|
||||||
const statusOffsets = {
|
|
||||||
sin_avances: { lat: 0.00008, lng: -0.00005 },
|
|
||||||
abiertas: { lat: 0, lng: 0.0001 },
|
|
||||||
finalizadas: { lat: -0.00004, lng: -0.00005 },
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const grupo of obrasPorGrupo.values()) {
|
|
||||||
const geocodedResult = geocodingCache.get(grupo.direccion);
|
|
||||||
if (geocodedResult?.success) {
|
|
||||||
const offset = statusOffsets[grupo.estado] || { lat: 0, lng: 0 };
|
|
||||||
const position = {
|
|
||||||
lat: geocodedResult.position.lat + offset.lat,
|
|
||||||
lng: geocodedResult.position.lng + offset.lng,
|
|
||||||
};
|
|
||||||
createMarker(
|
|
||||||
position,
|
|
||||||
grupo.estado,
|
|
||||||
grupo.obras,
|
|
||||||
geocodedResult.formatted_address
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
failedGeocodings.value.push({
|
|
||||||
direccion: grupo.direccion,
|
|
||||||
motivo: geocodedResult?.status || "NOT_FOUND",
|
|
||||||
obras: grupo.obras,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const createMarker = (position, estado, obras, formatted_address) => {
|
|
||||||
try {
|
|
||||||
const svgIcon = createSVGIcon(estado, obras.length);
|
|
||||||
|
|
||||||
const marker = new google.maps.Marker({
|
|
||||||
position: new google.maps.LatLng(position.lat, position.lng),
|
|
||||||
map: map.value,
|
|
||||||
title: `${obras.length} obra(s) en ${obras[0].direccion_obra}`,
|
|
||||||
icon: {
|
|
||||||
url: svgIcon,
|
|
||||||
scaledSize: new google.maps.Size(32, 40),
|
|
||||||
anchor: new google.maps.Point(16, 40),
|
|
||||||
},
|
|
||||||
animation: google.maps.Animation.DROP,
|
|
||||||
optimized: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const infoWindow = new google.maps.InfoWindow({
|
|
||||||
content: createMultipleInfoWindowContent(obras, formatted_address),
|
|
||||||
maxWidth: 400,
|
|
||||||
});
|
|
||||||
|
|
||||||
marker.addListener("click", () => {
|
|
||||||
markers.value.forEach((m) => m.infoWindow?.close());
|
|
||||||
infoWindow.open(map.value, marker);
|
|
||||||
});
|
|
||||||
|
|
||||||
markers.value.push({
|
|
||||||
marker,
|
|
||||||
infoWindow,
|
|
||||||
estado,
|
|
||||||
obras,
|
|
||||||
visible: filters[estado],
|
|
||||||
position,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error creando marcador:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Crear icono SVG
|
|
||||||
const createSVGIcon = (estado, count) => {
|
|
||||||
const color = markerColors[estado];
|
|
||||||
const svg = `
|
|
||||||
<svg width="32" height="40" viewBox="0 0 32 40" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<defs>
|
|
||||||
<filter id="shadow" x="-50%" y="-50%" width="200%" height="200%">
|
|
||||||
<feDropShadow dx="0" dy="2" stdDeviation="2" flood-opacity="0.3"/>
|
|
||||||
</filter>
|
|
||||||
</defs>
|
|
||||||
<path d="M16 0C7.163 0 0 7.163 0 16c0 12 16 24 16 24s16-12 16-24c0-8.837-7.163-16-16-16z"
|
|
||||||
fill="${color}"
|
|
||||||
stroke="white"
|
|
||||||
stroke-width="2"
|
|
||||||
filter="url(#shadow)"/>
|
|
||||||
<circle cx="16" cy="16" r="10" fill="white"/>
|
|
||||||
<circle cx="16" cy="16" r="7" fill="${color}"/>
|
|
||||||
<text x="16" y="21" text-anchor="middle" fill="white" font-size="10" font-weight="bold" font-family="Arial">${count}</text>
|
|
||||||
</svg>
|
|
||||||
`;
|
|
||||||
return `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(svg)}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateMarkers = () => {
|
|
||||||
markers.value.forEach((m) => {
|
|
||||||
const shouldShow = filters[m.estado];
|
|
||||||
m.marker.setVisible(shouldShow);
|
|
||||||
m.visible = shouldShow;
|
|
||||||
});
|
|
||||||
console.log("Marcadores actualizados. Visibles:", visibleMarkersCount.value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearMarkers = () => {
|
|
||||||
markers.value.forEach(({ marker, infoWindow }) => {
|
|
||||||
try {
|
|
||||||
infoWindow?.close();
|
|
||||||
marker.setMap(null);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error limpiando marcador:", error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
markers.value = [];
|
|
||||||
// No limpiar cache para mejorar rendimiento
|
|
||||||
};
|
|
||||||
|
|
||||||
const fitMapToMarkers = () => {
|
|
||||||
if (!map.value || markers.value.length === 0) return;
|
|
||||||
|
|
||||||
const bounds = new google.maps.LatLngBounds();
|
|
||||||
const visibleMarkers = markers.value.filter((m) => m.visible);
|
|
||||||
|
|
||||||
if (visibleMarkers.length === 0) return;
|
|
||||||
|
|
||||||
visibleMarkers.forEach(({ position }) => {
|
|
||||||
bounds.extend(new google.maps.LatLng(position.lat, position.lng));
|
|
||||||
});
|
|
||||||
|
|
||||||
map.value.fitBounds(bounds);
|
|
||||||
|
|
||||||
const listener = google.maps.event.addListener(map.value, "idle", () => {
|
|
||||||
if (map.value.getZoom() > 15) map.value.setZoom(15);
|
|
||||||
google.maps.event.removeListener(listener);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const getAllObras = () => {
|
|
||||||
try {
|
|
||||||
return [
|
|
||||||
...props.counters.sin_avances.detalles.map((o) => ({
|
|
||||||
...o,
|
|
||||||
estado: "sin_avances",
|
|
||||||
})),
|
|
||||||
...props.counters.abiertas.detalles.map((o) => ({
|
|
||||||
...o,
|
|
||||||
estado: "abiertas",
|
|
||||||
})),
|
|
||||||
...props.counters.finalizadas.detalles.map((o) => ({
|
|
||||||
...o,
|
|
||||||
estado: "finalizadas",
|
|
||||||
})),
|
|
||||||
];
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error obteniendo obras:", error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const searchAddresses = () => {
|
|
||||||
if (!searchQuery.value.trim()) {
|
|
||||||
searchResults.value = [];
|
|
||||||
showSearchResults.value = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const query = searchQuery.value.toLowerCase();
|
|
||||||
const allObras = getAllObras();
|
|
||||||
searchResults.value = allObras
|
|
||||||
.filter(
|
|
||||||
(obra) =>
|
|
||||||
obra.direccion_obra?.toLowerCase().includes(query) ||
|
|
||||||
obra.num_proyecto?.toLowerCase().includes(query)
|
|
||||||
)
|
|
||||||
.slice(0, 10);
|
|
||||||
showSearchResults.value = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const goToObra = (obra) => {
|
|
||||||
searchQuery.value = "";
|
|
||||||
searchResults.value = [];
|
|
||||||
showSearchResults.value = false;
|
|
||||||
|
|
||||||
const markerData = markers.value.find((m) =>
|
|
||||||
m.obras.some((o) => o.num_proyecto === obra.num_proyecto)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (markerData) {
|
|
||||||
map.value.setCenter(markerData.position);
|
|
||||||
map.value.setZoom(16);
|
|
||||||
markers.value.forEach((m) => m.infoWindow?.close());
|
|
||||||
markerData.infoWindow.open(map.value, markerData.marker);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearSearch = () => {
|
|
||||||
searchQuery.value = "";
|
|
||||||
searchResults.value = [];
|
|
||||||
showSearchResults.value = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const createMultipleInfoWindowContent = (obras, formatted_address) => {
|
|
||||||
const direccion = obras[0].direccion_obra;
|
|
||||||
let obrasHtml = obras
|
|
||||||
.map((obra) => {
|
|
||||||
const estadoConfig = {
|
|
||||||
sin_avances: { color: "bg-red-100 text-red-800", label: "Sin avances" },
|
|
||||||
abiertas: {
|
|
||||||
color: "bg-yellow-100 text-yellow-800",
|
|
||||||
label: "En proceso",
|
|
||||||
},
|
|
||||||
finalizadas: {
|
|
||||||
color: "bg-green-100 text-green-800",
|
|
||||||
label: "Finalizada",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const config = estadoConfig[obra.estado];
|
|
||||||
const cumplimiento = obra.cumplimiento_total
|
|
||||||
? `${Number(obra.cumplimiento_total).toFixed(1)}%`
|
|
||||||
: "N/A";
|
|
||||||
return `<div class="border-b border-gray-200 pb-3 mb-3 last:border-b-0 last:pb-0 last:mb-0"><div class="flex items-center justify-between mb-2"><h4 class="font-semibold text-gray-900">${
|
|
||||||
obra.num_proyecto || "N/A"
|
|
||||||
}</h4><span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
|
|
||||||
config.color
|
|
||||||
}">${
|
|
||||||
config.label
|
|
||||||
}</span></div><div class="flex items-center space-x-2"><div class="flex-1 bg-gray-200 rounded-full h-1.5"><div class="h-1.5 rounded-full" style="width: ${
|
|
||||||
Number(obra.cumplimiento_total) || 0
|
|
||||||
}%; background-color: ${
|
|
||||||
markerColors[obra.estado]
|
|
||||||
};"></div></div><span class="text-xs font-medium text-gray-700 min-w-[35px]">${cumplimiento}</span></div></div>`;
|
|
||||||
})
|
|
||||||
.join("");
|
|
||||||
return `<div class="p-4 max-w-sm"><div class="mb-3"><h3 class="font-bold text-gray-900 text-lg mb-1">${
|
|
||||||
obras.length
|
|
||||||
} Obra(s) en esta ubicación</h3><p class="text-xs text-gray-600">${direccion}</p>${
|
|
||||||
formatted_address
|
|
||||||
? `<p class="text-xs text-gray-500 mt-1">${formatted_address}</p>`
|
|
||||||
: ""
|
|
||||||
}</div><div class="space-y-3 text-sm max-h-60 overflow-y-auto">${obrasHtml}</div></div>`;
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
<template>
|
|
||||||
<div class="bg-white rounded-lg shadow-lg overflow-hidden">
|
|
||||||
<div
|
|
||||||
class="px-6 py-4 bg-gradient-to-r from-gray-50 to-gray-100 border-b border-gray-200"
|
|
||||||
>
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h3 class="text-lg font-medium text-gray-900">Mapa de Obras</h3>
|
|
||||||
<p class="text-sm text-gray-600 mt-1">
|
|
||||||
Ubicación geográfica de proyectos en Comalcalco, Tabasco
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center space-x-4">
|
|
||||||
<div class="relative">
|
|
||||||
<div class="relative">
|
|
||||||
<input
|
|
||||||
v-model="searchQuery"
|
|
||||||
@input="searchAddresses"
|
|
||||||
@focus="showSearchResults = true"
|
|
||||||
type="text"
|
|
||||||
placeholder="Buscar obra o dirección..."
|
|
||||||
class="w-64 px-3 py-1 text-sm border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
class="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
v-if="searchLoading"
|
|
||||||
class="animate-spin h-4 w-4 text-gray-400"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<circle
|
|
||||||
class="opacity-25"
|
|
||||||
cx="12"
|
|
||||||
cy="12"
|
|
||||||
r="10"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="4"
|
|
||||||
></circle>
|
|
||||||
<path
|
|
||||||
class="opacity-75"
|
|
||||||
fill="currentColor"
|
|
||||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
<svg
|
|
||||||
v-else-if="searchQuery"
|
|
||||||
@click="clearSearch"
|
|
||||||
class="h-4 w-4 text-gray-400 cursor-pointer hover:text-gray-600 pointer-events-auto"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M6 18L18 6M6 6l12 12"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<svg
|
|
||||||
v-else
|
|
||||||
class="h-4 w-4 text-gray-400"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-if="showSearchResults && searchResults.length > 0"
|
|
||||||
class="absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-md shadow-lg max-h-60 overflow-y-auto"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
v-for="obra in searchResults"
|
|
||||||
:key="obra.num_proyecto"
|
|
||||||
@click="goToObra(obra)"
|
|
||||||
class="px-3 py-2 hover:bg-gray-50 cursor-pointer"
|
|
||||||
>
|
|
||||||
<p class="text-sm font-medium text-gray-900 truncate">
|
|
||||||
{{ obra.num_proyecto || "N/A" }}
|
|
||||||
</p>
|
|
||||||
<p class="text-xs text-gray-500 truncate">
|
|
||||||
{{ obra.direccion_obra }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-else-if="
|
|
||||||
showSearchResults &&
|
|
||||||
searchResults.length === 0 &&
|
|
||||||
searchQuery.trim()
|
|
||||||
"
|
|
||||||
class="absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-md shadow-lg"
|
|
||||||
>
|
|
||||||
<div class="px-3 py-2 text-sm text-gray-500 text-center">
|
|
||||||
No se encontraron resultados
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<input
|
|
||||||
id="sin-avances"
|
|
||||||
v-model="filters.sin_avances"
|
|
||||||
@change="updateMarkers"
|
|
||||||
type="checkbox"
|
|
||||||
class="rounded border-gray-300 text-red-600 focus:ring-red-500"
|
|
||||||
/>
|
|
||||||
<label
|
|
||||||
for="sin-avances"
|
|
||||||
class="flex items-center text-sm text-gray-700"
|
|
||||||
>
|
|
||||||
<div class="w-3 h-3 bg-red-500 rounded-full mr-2"></div>
|
|
||||||
Sin avances ({{ counters.sin_avances.count }})
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<input
|
|
||||||
id="abiertas"
|
|
||||||
v-model="filters.abiertas"
|
|
||||||
@change="updateMarkers"
|
|
||||||
type="checkbox"
|
|
||||||
class="rounded border-gray-300 text-yellow-600 focus:ring-yellow-500"
|
|
||||||
/>
|
|
||||||
<label
|
|
||||||
for="abiertas"
|
|
||||||
class="flex items-center text-sm text-gray-700"
|
|
||||||
>
|
|
||||||
<div class="w-3 h-3 bg-yellow-500 rounded-full mr-2"></div>
|
|
||||||
Abiertas ({{ counters.abiertas.count }})
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<input
|
|
||||||
id="finalizadas"
|
|
||||||
v-model="filters.finalizadas"
|
|
||||||
@change="updateMarkers"
|
|
||||||
type="checkbox"
|
|
||||||
class="rounded border-gray-300 text-green-600 focus:ring-green-500"
|
|
||||||
/>
|
|
||||||
<label
|
|
||||||
for="finalizadas"
|
|
||||||
class="flex items-center text-sm text-gray-700"
|
|
||||||
>
|
|
||||||
<div class="w-3 h-3 bg-green-500 rounded-full mr-2"></div>
|
|
||||||
Finalizadas ({{ counters.finalizadas.count }})
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center justify-between mt-3">
|
|
||||||
<div class="text-sm text-gray-600">
|
|
||||||
Marcadores visibles: {{ visibleMarkersCount }} de {{ markers.length }}
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<button
|
|
||||||
@click="fitMapToMarkers"
|
|
||||||
class="px-3 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
|
|
||||||
>
|
|
||||||
Ajustar Vista
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
@click="loadMarkers"
|
|
||||||
class="px-3 py-1 text-xs bg-gray-600 text-white rounded hover:bg-gray-700 transition-colors"
|
|
||||||
>
|
|
||||||
Recargar
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div ref="mapContainer" class="w-full h-[600px] relative">
|
|
||||||
<div
|
|
||||||
v-if="loading"
|
|
||||||
class="absolute inset-0 bg-gray-100/80 flex items-center justify-center z-10 backdrop-blur-sm"
|
|
||||||
>
|
|
||||||
<div class="text-center">
|
|
||||||
<div
|
|
||||||
class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"
|
|
||||||
></div>
|
|
||||||
<p class="mt-2 text-sm text-gray-600">
|
|
||||||
Cargando mapa y geocodificando direcciones...
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-if="error"
|
|
||||||
class="absolute inset-0 bg-red-50 flex items-center justify-center z-10"
|
|
||||||
>
|
|
||||||
<div class="text-center">
|
|
||||||
<h3 class="text-sm font-medium text-red-800">
|
|
||||||
Error al cargar el mapa
|
|
||||||
</h3>
|
|
||||||
<p class="mt-1 text-sm text-red-600">{{ error }}</p>
|
|
||||||
<button
|
|
||||||
@click="initMap"
|
|
||||||
class="mt-4 bg-red-600 text-white px-4 py-2 rounded-md hover:bg-red-700"
|
|
||||||
>
|
|
||||||
Reintentar
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="!loading && failedGeocodings.length > 0"
|
|
||||||
class="px-6 py-4 bg-yellow-50 border-t border-yellow-200"
|
|
||||||
>
|
|
||||||
<h4 class="text-sm font-semibold text-yellow-800">
|
|
||||||
{{ failedGeocodings.flatMap((f) => f.obras).length }} obra(s) no
|
|
||||||
pudieron ser ubicadas
|
|
||||||
</h4>
|
|
||||||
<p class="text-xs text-yellow-700 mb-2">
|
|
||||||
Las siguientes direcciones no fueron encontradas o la coincidencia era
|
|
||||||
de baja calidad. Considera revisarlas en la base de datos.
|
|
||||||
</p>
|
|
||||||
<ul class="text-xs text-gray-700 list-disc pl-5 max-h-32 overflow-y-auto">
|
|
||||||
<li v-for="item in failedGeocodings" :key="item.direccion">
|
|
||||||
<strong>{{ item.direccion }}</strong> ({{ item.obras.length }} obra/s)
|
|
||||||
- Motivo:
|
|
||||||
<span class="font-mono text-red-600">{{ item.motivo }}</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@ -1,223 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { ref, watch } from "vue";
|
|
||||||
import axios from "axios";
|
|
||||||
import DateRange from "@/Components/Dashboard/Form/DateRange.vue";
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
show: Boolean
|
|
||||||
});
|
|
||||||
|
|
||||||
const emit = defineEmits(['close']);
|
|
||||||
|
|
||||||
const loading = ref(false);
|
|
||||||
const form = ref({
|
|
||||||
startDate: new Date().toISOString().slice(0, 10),
|
|
||||||
endDate: new Date().toISOString().slice(0, 10),
|
|
||||||
department: '' // 1 para Atención Ciudadana, 3 para DIF
|
|
||||||
});
|
|
||||||
|
|
||||||
const dateRange = ref({
|
|
||||||
start: new Date().toISOString().slice(0, 10),
|
|
||||||
end: new Date().toISOString().slice(0, 10)
|
|
||||||
});
|
|
||||||
|
|
||||||
const departments = [
|
|
||||||
{ id: '', name: 'Seleccionar departamento' },
|
|
||||||
{ id: 1, name: 'Atención Ciudadana' },
|
|
||||||
{ id: 3, name: 'DIF' }
|
|
||||||
];
|
|
||||||
|
|
||||||
// Watch para actualizar fechas desde DateRange
|
|
||||||
watch(dateRange, (newRange) => {
|
|
||||||
form.value.startDate = newRange.start;
|
|
||||||
form.value.endDate = newRange.end;
|
|
||||||
}, { deep: true });
|
|
||||||
|
|
||||||
const exportToExcel = async () => {
|
|
||||||
if (!form.value.department) {
|
|
||||||
alert('Por favor selecciona el departamento');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
loading.value = true;
|
|
||||||
try {
|
|
||||||
const params = {
|
|
||||||
start_date: form.value.startDate,
|
|
||||||
end_date: form.value.endDate,
|
|
||||||
department: form.value.department
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log('Enviando parámetros:', params);
|
|
||||||
|
|
||||||
const response = await axios({
|
|
||||||
method: 'GET',
|
|
||||||
url: '/api/export-excel',
|
|
||||||
params: params,
|
|
||||||
responseType: 'blob',
|
|
||||||
headers: {
|
|
||||||
'X-Requested-With': 'XMLHttpRequest',
|
|
||||||
'Accept': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
|
||||||
},
|
|
||||||
timeout: 60000
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.data || response.data.size === 0) {
|
|
||||||
throw new Error('El archivo descargado está vacío');
|
|
||||||
}
|
|
||||||
|
|
||||||
const contentType = response.headers['content-type'] || '';
|
|
||||||
if (!contentType.includes('spreadsheet') && !contentType.includes('excel') && !contentType.includes('octet-stream')) {
|
|
||||||
const text = await response.data.text();
|
|
||||||
console.error('Respuesta inesperada:', text);
|
|
||||||
throw new Error('El servidor no devolvió un archivo Excel válido');
|
|
||||||
}
|
|
||||||
|
|
||||||
const blob = new Blob([response.data], {
|
|
||||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
|
||||||
});
|
|
||||||
|
|
||||||
const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
|
|
||||||
const departmentName = departments.find(d => d.id === form.value.department)?.name.replace(/\s+/g, '_') || 'departamento';
|
|
||||||
const filename = `beneficiarios_${departmentName}_${form.value.startDate}_${form.value.endDate}_${timestamp}.xlsx`;
|
|
||||||
|
|
||||||
if (window.navigator && window.navigator.msSaveOrOpenBlob) {
|
|
||||||
window.navigator.msSaveOrOpenBlob(blob, filename);
|
|
||||||
} else {
|
|
||||||
const url = window.URL.createObjectURL(blob);
|
|
||||||
const link = document.createElement('a');
|
|
||||||
link.href = url;
|
|
||||||
link.download = filename;
|
|
||||||
link.style.display = 'none';
|
|
||||||
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
|
|
||||||
document.body.removeChild(link);
|
|
||||||
window.URL.revokeObjectURL(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
closeModal();
|
|
||||||
alert('Archivo Excel descargado exitosamente');
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error exportando Excel:', error);
|
|
||||||
|
|
||||||
let errorMessage = 'Error al exportar el archivo Excel';
|
|
||||||
|
|
||||||
if (error.response?.status === 400 && error.response?.data?.error) {
|
|
||||||
errorMessage = error.response.data.error;
|
|
||||||
} else if (error.response?.status === 500) {
|
|
||||||
errorMessage = 'Error interno del servidor. Intente nuevamente.';
|
|
||||||
} else if (error.message) {
|
|
||||||
errorMessage = error.message;
|
|
||||||
} else if (error.code === 'ECONNABORTED') {
|
|
||||||
errorMessage = 'La descarga tardó demasiado. Intente con un rango de fechas menor.';
|
|
||||||
}
|
|
||||||
|
|
||||||
alert(errorMessage);
|
|
||||||
} finally {
|
|
||||||
loading.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeModal = () => {
|
|
||||||
form.value = {
|
|
||||||
startDate: new Date().toISOString().slice(0, 10),
|
|
||||||
endDate: new Date().toISOString().slice(0, 10),
|
|
||||||
department: ''
|
|
||||||
};
|
|
||||||
|
|
||||||
dateRange.value = {
|
|
||||||
start: new Date().toISOString().slice(0, 10),
|
|
||||||
end: new Date().toISOString().slice(0, 10)
|
|
||||||
};
|
|
||||||
|
|
||||||
emit('close');
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<!-- Modal Backdrop -->
|
|
||||||
<div
|
|
||||||
v-if="show"
|
|
||||||
class="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4"
|
|
||||||
@click.self="closeModal"
|
|
||||||
>
|
|
||||||
<div class="bg-white rounded-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
|
||||||
<!-- Header -->
|
|
||||||
<div class="p-6 border-b border-gray-200">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<h3 class="text-lg font-semibold text-gray-900">
|
|
||||||
Exportar a Excel
|
|
||||||
</h3>
|
|
||||||
<button
|
|
||||||
@click="closeModal"
|
|
||||||
class="text-gray-400 hover:text-gray-600 transition-colors"
|
|
||||||
>
|
|
||||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p class="text-sm text-gray-600 mt-2">
|
|
||||||
Configure los filtros para exportar los datos de beneficiarios
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Body -->
|
|
||||||
<div class="p-6 space-y-6">
|
|
||||||
<!-- Rango de Fechas -->
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Rango de Fechas
|
|
||||||
</label>
|
|
||||||
<DateRange
|
|
||||||
v-model="dateRange"
|
|
||||||
:presets="true"
|
|
||||||
title-start="Fecha inicio"
|
|
||||||
title-end="Fecha fin"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Departamento -->
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Departamento <span class="text-red-500">*</span>
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
v-model="form.department"
|
|
||||||
class="w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
|
||||||
>
|
|
||||||
<option v-for="dept in departments" :key="dept.id" :value="dept.id">
|
|
||||||
{{ dept.name }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
<p class="text-xs text-gray-500 mt-2">
|
|
||||||
Se exportarán todos los registros del departamento seleccionado
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Footer -->
|
|
||||||
<div class="p-6 border-t border-gray-200 flex justify-end space-x-3">
|
|
||||||
<button
|
|
||||||
@click="closeModal"
|
|
||||||
class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-md hover:bg-gray-200 transition-colors"
|
|
||||||
>
|
|
||||||
Cancelar
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
@click="exportToExcel"
|
|
||||||
:disabled="loading || !form.department"
|
|
||||||
class="px-4 py-2 text-sm font-medium text-white bg-green-600 rounded-md hover:bg-green-700 disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors flex items-center"
|
|
||||||
>
|
|
||||||
<svg v-if="loading" class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
|
|
||||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
|
|
||||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"/>
|
|
||||||
</svg>
|
|
||||||
{{ loading ? 'Exportando...' : 'Exportar Excel' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@ -149,9 +149,9 @@ onMounted(()=>{
|
|||||||
<div class="text-center block px-4 py-2 text-sm text-gray-800 border-b truncate">
|
<div class="text-center block px-4 py-2 text-sm text-gray-800 border-b truncate">
|
||||||
{{ $page.props.user.name }}
|
{{ $page.props.user.name }}
|
||||||
</div>
|
</div>
|
||||||
<!-- <DropdownLink :href="route('profile.show')">
|
<DropdownLink :href="route('profile.show')">
|
||||||
{{$t('profile')}}
|
{{$t('profile')}}
|
||||||
</DropdownLink> -->
|
</DropdownLink>
|
||||||
<DropdownLink v-if="$page.props.jetstream.hasApiFeatures" :href="route('api-tokens.index')">
|
<DropdownLink v-if="$page.props.jetstream.hasApiFeatures" :href="route('api-tokens.index')">
|
||||||
API Tokens
|
API Tokens
|
||||||
</DropdownLink>
|
</DropdownLink>
|
||||||
|
|||||||
@ -1,88 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { onMounted, ref } from 'vue';
|
|
||||||
import { Head } from '@inertiajs/vue3';
|
|
||||||
import { sidebarSwitch, sidebar } from '@/sidebar'
|
|
||||||
|
|
||||||
import Header from '@/Components/Dashboard/Skeleton/Header.vue';
|
|
||||||
import Sidebar from '@/Components/Dashboard/Skeleton/Sidebar.vue';
|
|
||||||
import Link from '@/Components/Dashboard/Skeleton/Sidebar/Link.vue';
|
|
||||||
import Section from '@/Components/Dashboard/Skeleton/Sidebar/Section.vue';
|
|
||||||
|
|
||||||
defineProps({
|
|
||||||
title: String,
|
|
||||||
titlePage: {
|
|
||||||
default: true,
|
|
||||||
type: Boolean
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const sidebarStatus = ref(sidebar);
|
|
||||||
|
|
||||||
onMounted(()=> {
|
|
||||||
if (!sessionFresh.isLayoutInitialized()) {
|
|
||||||
sessionFresh.startLayout();
|
|
||||||
}
|
|
||||||
|
|
||||||
if(TwScreen.isDevice('phone')) {
|
|
||||||
sidebarSwitch(true);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Head
|
|
||||||
:title="title"
|
|
||||||
/>
|
|
||||||
<div class="flex w-full h-screen">
|
|
||||||
<div
|
|
||||||
id="sidebar"
|
|
||||||
class="fixed w-fit h-screen transition-all duration-300 z-10"
|
|
||||||
:class="{'-translate-x-[16.5rem] md:-translate-x-0':sidebarStatus, '-translate-x-0 md:-translate-x-64':!sidebarStatus}"
|
|
||||||
>
|
|
||||||
<Sidebar
|
|
||||||
:sidebar="sidebarStatus"
|
|
||||||
@open="sidebarSwitch()"
|
|
||||||
>
|
|
||||||
<Section name="Reportes">
|
|
||||||
<Link
|
|
||||||
icon="assignment"
|
|
||||||
name="Trámites"
|
|
||||||
to="app.tramites"
|
|
||||||
/>
|
|
||||||
<Link
|
|
||||||
icon="support_agent"
|
|
||||||
name="Entrega de Apoyos"
|
|
||||||
to="app.atencion"
|
|
||||||
/>
|
|
||||||
<Link
|
|
||||||
icon="construction"
|
|
||||||
name="Obras"
|
|
||||||
to="app.obras"
|
|
||||||
/>
|
|
||||||
</Section>
|
|
||||||
</Sidebar>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="flex flex-col w-full transition-all duration-300"
|
|
||||||
:class="{'md:w-[calc(100vw-rem)] md:ml-64':sidebarStatus, 'md:w-screen md:ml-0':!sidebarStatus}"
|
|
||||||
>
|
|
||||||
<div class="h-2 md:h-14">
|
|
||||||
<Header
|
|
||||||
:sidebar="sidebarStatus"
|
|
||||||
@open="sidebarSwitch()"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<main id="page" class="flex h-full justify-center md:p-2">
|
|
||||||
<div class="mt-14 md:mt-0 w-full shadow-lg h-fit md:h-[calc(100vh-4.5rem)] px-2 md:px-8 pb-4 sm:px-6 lg:px-8 md:rounded-lg bg-main text-main-on dark:bg-main-dark dark:text-main-dark-on lg:py-4 md:overflow-y-auto md:overflow-x-auto transition-colors duration-300">
|
|
||||||
<div v-if="titlePage" class="flex w-full justify-center pt-4 lg:pt-0">
|
|
||||||
<h2
|
|
||||||
class="font-bold text-xl uppercase text-primary dark:text-primary-dark"
|
|
||||||
v-text="title"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<slot />
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@ -1,145 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { router } from "@inertiajs/vue3";
|
|
||||||
|
|
||||||
import GoogleIcon from "@/Components/Shared/GoogleIcon.vue";
|
|
||||||
import NotificationController from "@/Controllers/NotificationController";
|
|
||||||
import AppLayout from "@/Layouts/AppLayout.vue";
|
|
||||||
|
|
||||||
const notificationCtl = NotificationController;
|
|
||||||
|
|
||||||
const logout = () => {
|
|
||||||
router.post(
|
|
||||||
route("logout"),
|
|
||||||
{},
|
|
||||||
{
|
|
||||||
onBefore: () => {
|
|
||||||
notificationCtl.stop();
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<AppLayout>
|
|
||||||
<div
|
|
||||||
class="min-h-screen font-sans bg-gradient-to-br from-[#621132] via-[#621132] to-[#621132] text-[#621132] antialiased"
|
|
||||||
>
|
|
||||||
<main class="grid min-h-screen place-items-center p-4">
|
|
||||||
<!-- Tarjeta -->
|
|
||||||
<section
|
|
||||||
class="w-full max-w-2xl rounded-3xl border border-white/10 bg-white/5 p-6 sm:p-8 shadow-2xl backdrop-blur supports-[backdrop-filter]:bg-white/10"
|
|
||||||
>
|
|
||||||
<!-- Encabezado -->
|
|
||||||
<header class="mb-6 sm:mb-8">
|
|
||||||
<h1
|
|
||||||
class="text-2xl sm:text-3xl font-bold tracking-tight text-white"
|
|
||||||
>
|
|
||||||
Accesos rápidos
|
|
||||||
</h1>
|
|
||||||
<p class="mt-2 text-sm sm:text-base text-white">
|
|
||||||
Selecciona un destino.
|
|
||||||
</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<!-- Grid de botones -->
|
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-1 gap-3 sm:gap-4">
|
|
||||||
<!-- Botón 1 -->
|
|
||||||
<a
|
|
||||||
:href="route('app.tramites')"
|
|
||||||
target="_blank"
|
|
||||||
class="group relative overflow-hidden rounded-2xl border border-white/10 bg-white/10 p-4 sm:p-5 transition hover:bg-white/15 focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-400 active:scale-[0.99]"
|
|
||||||
aria-label="Ir al sitio 1"
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<!-- Ícono externo simple -->
|
|
||||||
<span class="rounded-xl bg-white/10 p-2">
|
|
||||||
<GoogleIcon name="open_in_new" class="text-white" />
|
|
||||||
</span>
|
|
||||||
<div class="min-w-0">
|
|
||||||
<h2 class="font-semibold text-white">
|
|
||||||
Reporte Especial de Trámites
|
|
||||||
</h2>
|
|
||||||
<p class="text-sm text-slate-300">
|
|
||||||
Resumen de trámites por unidad administrativa
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Efecto -->
|
|
||||||
<span
|
|
||||||
class="pointer-events-none absolute inset-0 opacity-0 transition-opacity duration-500 group-hover:opacity-100"
|
|
||||||
style="
|
|
||||||
background: radial-gradient(
|
|
||||||
600px circle at var(--x, 50%) var(--y, 50%),
|
|
||||||
rgba(199, 12, 12, 0.12),
|
|
||||||
transparent 40%
|
|
||||||
);
|
|
||||||
"
|
|
||||||
></span>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<!-- Botón 2 -->
|
|
||||||
<a
|
|
||||||
:href="route('app.atencion')"
|
|
||||||
target="_blank"
|
|
||||||
class="group relative overflow-hidden rounded-2xl border border-white/10 bg-white/10 p-4 sm:p-5 transition hover:bg-white/15 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400 active:scale-[0.99]"
|
|
||||||
aria-label="Ir al sitio 2"
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<span class="rounded-xl bg-white/10 p-2">
|
|
||||||
<GoogleIcon name="open_in_new" class="text-white" />
|
|
||||||
</span>
|
|
||||||
<div class="min-w-0">
|
|
||||||
<h2 class="font-semibold text-white">
|
|
||||||
Apoyo a Beneficiarios y DIF
|
|
||||||
</h2>
|
|
||||||
<p class="text-sm text-slate-300">
|
|
||||||
Gráficas de apoyo a Beneficiarios y DIF
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
class="pointer-events-none absolute inset-0 opacity-0 transition-opacity duration-500 group-hover:opacity-100"
|
|
||||||
style="
|
|
||||||
background: radial-gradient(
|
|
||||||
600px circle at var(--x, 50%) var(--y, 50%),
|
|
||||||
rgba(98, 17, 50, 0.12),
|
|
||||||
transparent 40%
|
|
||||||
);
|
|
||||||
"
|
|
||||||
></span>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<!-- Botón 3 -->
|
|
||||||
<a
|
|
||||||
:href="route('app.obras')"
|
|
||||||
target="_blank"
|
|
||||||
class="group relative overflow-hidden rounded-2xl border border-white/10 bg-white/10 p-4 sm:p-5 transition hover:bg-white/15 focus:outline-none focus-visible:ring-2 focus-visible:ring-fuchsia-400 active:scale-[0.99]"
|
|
||||||
aria-label="Ir al sitio 3"
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<span class="rounded-xl bg-white/10 p-2">
|
|
||||||
<GoogleIcon name="open_in_new" class="text-white" />
|
|
||||||
</span>
|
|
||||||
<div class="min-w-0">
|
|
||||||
<h2 class="font-semibold text-white">Información de Obras</h2>
|
|
||||||
<p class="text-sm text-slate-300">Proyectos en seguimiento</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
class="pointer-events-none absolute inset-0 opacity-0 transition-opacity duration-500 group-hover:opacity-100"
|
|
||||||
style="
|
|
||||||
background: radial-gradient(
|
|
||||||
600px circle at var(--x, 50%) var(--y, 50%),
|
|
||||||
rgba(255, 255, 255, 0.12),
|
|
||||||
transparent 40%
|
|
||||||
);
|
|
||||||
"
|
|
||||||
></span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</AppLayout>
|
|
||||||
</template>
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -1,581 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { ref, computed, watch, onMounted } from "vue";
|
|
||||||
|
|
||||||
import axios from "axios";
|
|
||||||
import Bars from "@/Components/Dashboard/Charts/Bars.vue";
|
|
||||||
import Pie from "@/Components/Dashboard/Charts/Pie.vue";
|
|
||||||
import DateRange from "@/Components/Dashboard/Form/DateRange.vue";
|
|
||||||
import Footer from "@/Components/Dashboard/Footer.vue";
|
|
||||||
import AppLayout from "@/Layouts/AppLayout.vue";
|
|
||||||
|
|
||||||
const data = ref({
|
|
||||||
procedures_opened_today: 0,
|
|
||||||
procedures_with_movements_today: 0,
|
|
||||||
procedures_by_administration: [],
|
|
||||||
movements_by_procedure: [],
|
|
||||||
});
|
|
||||||
const error = ref(null);
|
|
||||||
|
|
||||||
// Rango inicial: Hoy→Hoy (coincide con preset del DateRange)
|
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
|
||||||
const dateRange = ref({ start: today, end: today });
|
|
||||||
|
|
||||||
// datasets
|
|
||||||
const barChartData = ref({ labels: [], datasets: [] });
|
|
||||||
const pieChartData = ref({ labels: [], datasets: [] });
|
|
||||||
|
|
||||||
// opciones reutilizables
|
|
||||||
const barChartOptions = {
|
|
||||||
responsive: true,
|
|
||||||
makeDataset: (label, values) => ({
|
|
||||||
label,
|
|
||||||
data: values,
|
|
||||||
backgroundColor: "rgba(75,192,192,0.2)",
|
|
||||||
borderColor: "rgba(75,192,192,1)",
|
|
||||||
borderWidth: 1,
|
|
||||||
}),
|
|
||||||
scales: {
|
|
||||||
x: {
|
|
||||||
ticks: {
|
|
||||||
autoSkip: false,
|
|
||||||
maxRotation: 0,
|
|
||||||
minRotation: 0,
|
|
||||||
callback: (_value, index) => {
|
|
||||||
const lab = barChartData.value.labels[index] || "";
|
|
||||||
return lab.split(" ").map((w) => w.toUpperCase());
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const pieChartOptions = { responsive: true, maintainAspectRatio: false };
|
|
||||||
|
|
||||||
// helpers
|
|
||||||
const diffDays = (a, b) => {
|
|
||||||
if (!a || !b) return 0;
|
|
||||||
const A = new Date(a + "T00:00:00");
|
|
||||||
const B = new Date(b + "T00:00:00");
|
|
||||||
return Math.max(0, Math.round((B - A) / (1000 * 60 * 60 * 24)));
|
|
||||||
};
|
|
||||||
|
|
||||||
const mode = computed(() => {
|
|
||||||
const d = diffDays(dateRange.value.start, dateRange.value.end);
|
|
||||||
if (d === 0) return "día";
|
|
||||||
if (d <= 7) return "semana";
|
|
||||||
return "rango";
|
|
||||||
});
|
|
||||||
|
|
||||||
let cancelTokenSource = null;
|
|
||||||
let debounceId = null;
|
|
||||||
|
|
||||||
const fetchReport = async ({ start, end }) => {
|
|
||||||
// cancelar petición previa
|
|
||||||
if (cancelTokenSource) {
|
|
||||||
try {
|
|
||||||
cancelTokenSource.cancel("cancel");
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
|
||||||
cancelTokenSource = axios.CancelToken.source();
|
|
||||||
|
|
||||||
const params = {};
|
|
||||||
if (start) params.start = start;
|
|
||||||
if (end) params.end = end;
|
|
||||||
|
|
||||||
const res = await axios.get("/api/reporte-especial", {
|
|
||||||
params,
|
|
||||||
cancelToken: cancelTokenSource.token,
|
|
||||||
headers: { "X-Requested-With": "XMLHttpRequest" },
|
|
||||||
});
|
|
||||||
|
|
||||||
return res.data;
|
|
||||||
};
|
|
||||||
|
|
||||||
const load = async () => {
|
|
||||||
error.value = null;
|
|
||||||
try {
|
|
||||||
const payload = await fetchReport({
|
|
||||||
start: dateRange.value.start,
|
|
||||||
end: dateRange.value.end,
|
|
||||||
});
|
|
||||||
data.value = {
|
|
||||||
procedures_opened_today: payload?.procedures_opened_today || 0,
|
|
||||||
procedures_with_movements_today: payload?.procedures_with_movements_today || 0,
|
|
||||||
procedures_by_administration: Array.isArray(payload?.procedures_by_administration)
|
|
||||||
? payload.procedures_by_administration : [],
|
|
||||||
movements_by_procedure: Array.isArray(payload?.movements_by_procedure)
|
|
||||||
? payload.movements_by_procedure : [],
|
|
||||||
};
|
|
||||||
mapToCharts(data.value, mode.value);
|
|
||||||
} catch (e) {
|
|
||||||
if (!axios.isCancel(e)) {
|
|
||||||
console.error(e);
|
|
||||||
error.value = e.message || "Error desconocido";
|
|
||||||
mapToCharts(data.value, mode.value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
watch(
|
|
||||||
dateRange,
|
|
||||||
() => {
|
|
||||||
clearTimeout(debounceId);
|
|
||||||
debounceId = setTimeout(load, 350);
|
|
||||||
},
|
|
||||||
{ deep: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
mapToCharts(data.value, mode.value);
|
|
||||||
load();
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- mapping charts: usa SIEMPRE el rango ---
|
|
||||||
function mapToCharts(api, currentMode) {
|
|
||||||
const proceduresList = Array.isArray(api?.movements_by_procedure)
|
|
||||||
? api.movements_by_procedure
|
|
||||||
: [];
|
|
||||||
|
|
||||||
// Filtrar procedimientos con datos significativos y ordenar por total descendente
|
|
||||||
const filteredProcedures = proceduresList
|
|
||||||
.filter((proc) => (proc.opened || 0) + (proc.closed || 0) > 0)
|
|
||||||
.sort((a, b) => {
|
|
||||||
const totalA = (a.opened || 0) + (a.closed || 0);
|
|
||||||
const totalB = (b.opened || 0) + (b.closed || 0);
|
|
||||||
return totalB - totalA;
|
|
||||||
});
|
|
||||||
|
|
||||||
const labels = filteredProcedures.map((proc) =>
|
|
||||||
(proc.name || "").toUpperCase().substring(0, 30) +
|
|
||||||
((proc.name || "").length > 30 ? "..." : "")
|
|
||||||
);
|
|
||||||
|
|
||||||
// Pie: Distribución de trámites por procedimiento
|
|
||||||
const pieValues = filteredProcedures.map((proc) => (proc.opened || 0) + (proc.closed || 0));
|
|
||||||
pieChartData.value = {
|
|
||||||
labels: labels.length > 0 ? labels : ["Sin datos"],
|
|
||||||
datasets: [
|
|
||||||
{
|
|
||||||
label: "Distribución de Trámites por Procedimiento",
|
|
||||||
data: pieValues.length > 0 ? pieValues : [0],
|
|
||||||
backgroundColor: labels.length > 0
|
|
||||||
? labels.map((_, i) => `hsl(${(i * 360) / Math.max(1, labels.length)}, 70%, 50%)`)
|
|
||||||
: ["#e5e7eb"],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
// Bars: Trámites más solicitados según el modo
|
|
||||||
let barLabel = "";
|
|
||||||
let barValues = [];
|
|
||||||
|
|
||||||
if (currentMode === "día") {
|
|
||||||
barLabel = "Trámites Abiertos Hoy";
|
|
||||||
barValues = filteredProcedures.map((proc) => proc.opened_today || 0);
|
|
||||||
} else if (currentMode === "semana") {
|
|
||||||
barLabel = "Trámites de la Semana";
|
|
||||||
barValues = filteredProcedures.map((proc) => proc.week_total || (proc.opened || 0));
|
|
||||||
} else {
|
|
||||||
barLabel = "Trámites en el Rango Seleccionado";
|
|
||||||
barValues = filteredProcedures.map((proc) => {
|
|
||||||
// Priorizar datos específicos del rango si están disponibles
|
|
||||||
if (typeof proc.range_total === "number") return proc.range_total;
|
|
||||||
return (proc.opened || 0) + (proc.closed || 0);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
barChartData.value = {
|
|
||||||
labels: labels.length > 0 ? labels : ["Sin datos"],
|
|
||||||
datasets: [
|
|
||||||
{
|
|
||||||
...barChartOptions.makeDataset(barLabel, barValues.length > 0 ? barValues : [0]),
|
|
||||||
backgroundColor: "rgba(99, 102, 241, 0.2)",
|
|
||||||
borderColor: "rgba(99, 102, 241, 1)",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<AppLayout>
|
|
||||||
<div>
|
|
||||||
<header class="relative">
|
|
||||||
<div class="bg-gradient-to-r from-gray-100 to-gray-50 py-3 shadow-sm">
|
|
||||||
<div class="container mx-auto px-4 sm:px-6 lg:px-8">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<span class="text-sm font-bold text-gray-800 tracking-wide"
|
|
||||||
>COMALCALCO.GOB.MX</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
style="
|
|
||||||
background: linear-gradient(
|
|
||||||
135deg,
|
|
||||||
#621132 0%,
|
|
||||||
#7d1a42 50%,
|
|
||||||
#621132 100%
|
|
||||||
);
|
|
||||||
"
|
|
||||||
class="shadow-xl relative overflow-hidden"
|
|
||||||
>
|
|
||||||
<!-- Patrón decorativo de fondo -->
|
|
||||||
<div class="absolute inset-0 opacity-10">
|
|
||||||
<div
|
|
||||||
class="absolute top-0 left-0 w-full h-full"
|
|
||||||
style="
|
|
||||||
background-image: repeating-linear-gradient(
|
|
||||||
45deg,
|
|
||||||
transparent,
|
|
||||||
transparent 35px,
|
|
||||||
rgba(255, 255, 255, 0.1) 35px,
|
|
||||||
rgba(255, 255, 255, 0.1) 70px
|
|
||||||
);
|
|
||||||
"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="container mx-auto px-4 sm:px-6 lg:px-8 relative">
|
|
||||||
<div class="flex items-center justify-between h-32">
|
|
||||||
<div class="flex items-center space-x-4">
|
|
||||||
<img
|
|
||||||
src="https://apoyos.comalcalco.gob.mx/images/logo_blanco.png"
|
|
||||||
alt="Logo Comalcalco"
|
|
||||||
class="h-20 w-auto object-contain filter drop-shadow-lg transition-transform hover:scale-105"
|
|
||||||
/>
|
|
||||||
<div class="hidden md:block">
|
|
||||||
<h1 class="text-2xl font-bold text-white">
|
|
||||||
Reporte Especial de Trámites
|
|
||||||
</h1>
|
|
||||||
<p class="text-blue-100 text-sm">
|
|
||||||
Sistema de seguimiento de trámites
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<!-- Loading Overlay -->
|
|
||||||
<div
|
|
||||||
v-if="loading"
|
|
||||||
class="fixed inset-0 bg-white/95 backdrop-blur-sm flex items-center justify-center z-50 transition-all duration-300"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div v-else>
|
|
||||||
<div class="min-h-screen bg-gray-50 py-8">
|
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
||||||
<!-- Header -->
|
|
||||||
<div class="mb-8">
|
|
||||||
<div class="flex px-4 sm:px-6 lg:px-8 mb-4 items-center gap-4">
|
|
||||||
<div>
|
|
||||||
<h1 class="text-3xl font-bold text-gray-900 mb-2">
|
|
||||||
Reporte Especial de Trámites
|
|
||||||
</h1>
|
|
||||||
<p class="text-gray-600">Resumen por unidad administrativa</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Estadísticas del día -->
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
|
|
||||||
<div
|
|
||||||
class="bg-white rounded-lg shadow-sm border border-gray-200 p-6"
|
|
||||||
>
|
|
||||||
<div class="flex items-center">
|
|
||||||
<div class="flex-shrink-0">
|
|
||||||
<div
|
|
||||||
class="w-8 h-8 bg-blue-100 rounded-lg flex items-center justify-center"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
<div class="ml-4">
|
|
||||||
<p class="text-sm font-medium text-gray-500">
|
|
||||||
Trámites Abiertos Hoy
|
|
||||||
</p>
|
|
||||||
<p class="text-2xl font-bold text-gray-900">
|
|
||||||
{{
|
|
||||||
new Intl.NumberFormat().format(
|
|
||||||
data.procedures_opened_today
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="bg-white rounded-lg shadow-sm border border-gray-200 p-6"
|
|
||||||
>
|
|
||||||
<div class="flex items-center">
|
|
||||||
<div class="flex-shrink-0">
|
|
||||||
<div
|
|
||||||
class="w-8 h-8 bg-green-100 rounded-lg flex items-center justify-center"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
<div class="ml-4">
|
|
||||||
<p class="text-sm font-medium text-gray-500">
|
|
||||||
Con Movimientos Hoy
|
|
||||||
</p>
|
|
||||||
<p class="text-2xl font-bold text-gray-900">
|
|
||||||
{{
|
|
||||||
new Intl.NumberFormat().format(
|
|
||||||
data.procedures_with_movements_today
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Tabla de trámites por administración -->
|
|
||||||
<div
|
|
||||||
class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden w-full"
|
|
||||||
>
|
|
||||||
<div class="px-6 py-4 border-b border-gray-200">
|
|
||||||
<h2 class="text-lg font-semibold text-gray-900">
|
|
||||||
Trámites por Unidad Administrativa
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<div class="overflow-x-auto w-full">
|
|
||||||
<table class="w-full divide-y divide-gray-200">
|
|
||||||
<thead class="bg-gray-50">
|
|
||||||
<tr>
|
|
||||||
<th
|
|
||||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
|
||||||
>
|
|
||||||
Unidad Administrativa
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
|
||||||
>
|
|
||||||
Abiertos
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
|
||||||
>
|
|
||||||
Movimientos Hoy
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
|
||||||
>
|
|
||||||
Cerrados
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
|
||||||
>
|
|
||||||
Total
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody class="bg-white divide-y divide-gray-200">
|
|
||||||
<tr
|
|
||||||
v-for="administration in data.procedures_by_administration ||
|
|
||||||
[]"
|
|
||||||
:key="administration.id"
|
|
||||||
class="hover:bg-gray-50 transition-colors duration-200"
|
|
||||||
>
|
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
|
||||||
<div class="text-sm font-medium text-gray-900">
|
|
||||||
{{ administration.name }}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
|
||||||
<span
|
|
||||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800"
|
|
||||||
>
|
|
||||||
{{
|
|
||||||
new Intl.NumberFormat().format(
|
|
||||||
administration.opened
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
|
||||||
<span
|
|
||||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800"
|
|
||||||
>
|
|
||||||
{{
|
|
||||||
new Intl.NumberFormat().format(
|
|
||||||
administration.movement_today
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
|
||||||
<span
|
|
||||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800"
|
|
||||||
>
|
|
||||||
{{
|
|
||||||
new Intl.NumberFormat().format(
|
|
||||||
administration.closed
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
|
||||||
<div class="text-sm font-semibold text-gray-900">
|
|
||||||
{{
|
|
||||||
new Intl.NumberFormat().format(
|
|
||||||
administration.opened + administration.closed
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr
|
|
||||||
v-if="
|
|
||||||
!(
|
|
||||||
data.procedures_by_administration &&
|
|
||||||
data.procedures_by_administration.length
|
|
||||||
)
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<td colspan="5" class="px-6 py-8 text-center">
|
|
||||||
<div class="text-gray-500">
|
|
||||||
No hay datos disponibles
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Tabla de trámites por procedimiento -->
|
|
||||||
<div
|
|
||||||
class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden w-full mt-8"
|
|
||||||
>
|
|
||||||
<div class="px-6 py-4 border-b border-gray-200">
|
|
||||||
<h2 class="text-lg font-semibold text-gray-900">
|
|
||||||
Trámites por Procedimiento
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<div class="overflow-x-auto w-full">
|
|
||||||
<table class="w-full divide-y divide-gray-200">
|
|
||||||
<thead class="bg-gray-50">
|
|
||||||
<tr>
|
|
||||||
<th
|
|
||||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
|
||||||
>
|
|
||||||
Procedimiento
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
|
||||||
>
|
|
||||||
Abiertos
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
|
||||||
>
|
|
||||||
Movimientos Hoy
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
|
||||||
>
|
|
||||||
Cerrados
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
|
||||||
>
|
|
||||||
Total
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody class="bg-white divide-y divide-gray-200 uppercase">
|
|
||||||
<tr
|
|
||||||
v-for="procedure in data.movements_by_procedure || []"
|
|
||||||
:key="procedure.id"
|
|
||||||
class="hover:bg-gray-50 transition-colors duration-200"
|
|
||||||
>
|
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
|
||||||
<div class="text-sm font-medium text-gray-900">
|
|
||||||
{{ procedure.name }}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
|
||||||
<span
|
|
||||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800"
|
|
||||||
>
|
|
||||||
{{ new Intl.NumberFormat().format(procedure.opened) }}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
|
||||||
<span
|
|
||||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800"
|
|
||||||
>
|
|
||||||
{{
|
|
||||||
new Intl.NumberFormat().format(
|
|
||||||
procedure.movement_today
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
|
||||||
<span
|
|
||||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800"
|
|
||||||
>
|
|
||||||
{{ new Intl.NumberFormat().format(procedure.closed) }}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
|
||||||
<div class="text-sm font-semibold text-gray-900">
|
|
||||||
{{
|
|
||||||
new Intl.NumberFormat().format(
|
|
||||||
procedure.opened + procedure.closed
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr
|
|
||||||
v-if="
|
|
||||||
!(
|
|
||||||
data.movements_by_procedure &&
|
|
||||||
data.movements_by_procedure.length
|
|
||||||
)
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<td colspan="5" class="px-6 py-8 text-center">
|
|
||||||
<div class="text-gray-500">
|
|
||||||
No hay datos disponibles
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Bars -->
|
|
||||||
<div
|
|
||||||
class="max-w-7xl mx-auto mt-12 px-4 sm:px-6 lg:px-8 mb-8 bg-white rounded-lg shadow p-6"
|
|
||||||
>
|
|
||||||
<!-- DateRange mejorado -->
|
|
||||||
<DateRange v-model="dateRange" :presets="true" class="mb-12" />
|
|
||||||
<Bars :chartData="barChartData" :chartOptions="barChartOptions" />
|
|
||||||
<p class="text-sm text-gray-500 mt-2">Modo actual: {{ mode }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Pie -->
|
|
||||||
<div
|
|
||||||
class="max-w-7xl mx-auto mt-8 px-4 sm:px-6 lg:px-8 bg-white rounded-lg shadow p-6"
|
|
||||||
>
|
|
||||||
<Pie :chartData="pieChartData" :chartOptions="pieChartOptions" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-8 text-center">
|
|
||||||
<p class="text-sm text-gray-500">
|
|
||||||
Reporte generado el {{ new Date().toLocaleString("es-MX") }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Footer />
|
|
||||||
</div>
|
|
||||||
</AppLayout>
|
|
||||||
</template>
|
|
||||||
@ -35,7 +35,7 @@ const submit = () => {
|
|||||||
<AuthenticationCard>
|
<AuthenticationCard>
|
||||||
<template #logo>
|
<template #logo>
|
||||||
<AppLogo
|
<AppLogo
|
||||||
class="text-2xl text-[#621132]"
|
class="text-2xl"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -75,12 +75,12 @@ const submit = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col items-center justify-end space-y-2 mt-4">
|
<div class="flex flex-col items-center justify-end space-y-2 mt-4">
|
||||||
<PrimaryButton
|
<PrimaryButton
|
||||||
class="w-full bg-[#621132]"
|
class="w-full"
|
||||||
:class="{ 'opacity-25': form.processing }"
|
:class="{ 'opacity-25': form.processing }"
|
||||||
:disabled="form.processing"
|
:disabled="form.processing"
|
||||||
v-text="$t('auth.login')"
|
v-text="$t('auth.login')"
|
||||||
/>
|
/>
|
||||||
<!-- <Link
|
<Link
|
||||||
class="w-full"
|
class="w-full"
|
||||||
:href="route('register')"
|
:href="route('register')"
|
||||||
>
|
>
|
||||||
@ -90,7 +90,7 @@ const submit = () => {
|
|||||||
:disabled="form.processing"
|
:disabled="form.processing"
|
||||||
v-text="$t('register.signUp')"
|
v-text="$t('register.signUp')"
|
||||||
/>
|
/>
|
||||||
</Link>-->
|
</Link>
|
||||||
<Link v-if="canResetPassword"
|
<Link v-if="canResetPassword"
|
||||||
class="underline text-sm"
|
class="underline text-sm"
|
||||||
:href="route('password.request')"
|
:href="route('password.request')"
|
||||||
|
|||||||
@ -22,7 +22,9 @@ window.Notify = new Notify();
|
|||||||
window.sessionFresh = new SessionFresh();
|
window.sessionFresh = new SessionFresh();
|
||||||
window.Swal = Swal;
|
window.Swal = Swal;
|
||||||
window.TwScreen = new TailwindScreen();
|
window.TwScreen = new TailwindScreen();
|
||||||
|
window.darkMode = darkMode;
|
||||||
|
|
||||||
|
bootTheme();
|
||||||
bootSidebar();
|
bootSidebar();
|
||||||
|
|
||||||
createInertiaApp({
|
createInertiaApp({
|
||||||
|
|||||||
@ -1,8 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Http\Controllers\Dashboard\AtencionController;
|
|
||||||
use App\Http\Controllers\Dashboard\ObrasController;
|
|
||||||
use App\Http\Controllers\Dashboard\TramiteController;
|
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
@ -20,9 +17,3 @@
|
|||||||
Route::middleware('auth:sanctum')->get('/user', function (Request $request) {
|
Route::middleware('auth:sanctum')->get('/user', function (Request $request) {
|
||||||
return $request->user();
|
return $request->user();
|
||||||
});
|
});
|
||||||
|
|
||||||
Route::middleware('api')->group(function () {
|
|
||||||
Route::get('/reporte-obras', [ObrasController::class, 'Obras'])->middleware(['throttle:60,1']);
|
|
||||||
Route::get('/reporte-especial', [TramiteController::class, 'tramiteEspecial'])->middleware(['throttle:60,1']);
|
|
||||||
Route::get('/reporte-atencion', [AtencionController::class, 'Atencion'])->middleware(['throttle:60,1']);
|
|
||||||
});
|
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Http\Controllers\Admin\UserController;
|
use App\Http\Controllers\Admin\UserController;
|
||||||
use App\Http\Controllers\Dashboard\AtencionController;
|
|
||||||
use App\Http\Controllers\Dashboard\HistoryLogController;
|
use App\Http\Controllers\Dashboard\HistoryLogController;
|
||||||
use App\Http\Controllers\Dashboard\IndexController;
|
use App\Http\Controllers\Dashboard\IndexController;
|
||||||
use App\Http\Controllers\Dashboard\NotificationController;
|
use App\Http\Controllers\Dashboard\NotificationController;
|
||||||
@ -14,21 +13,7 @@
|
|||||||
*
|
*
|
||||||
* Rutas accesibles por todos los usuarios y no usuarios
|
* Rutas accesibles por todos los usuarios y no usuarios
|
||||||
*/
|
*/
|
||||||
Route::redirect('/', '/tramites');
|
Route::redirect('/', '/login');
|
||||||
|
|
||||||
/**
|
|
||||||
* Rutas públicas de reportes
|
|
||||||
*/
|
|
||||||
Route::name('app.')->middleware([
|
|
||||||
'auth:sanctum',
|
|
||||||
'verified',
|
|
||||||
config('jetstream.auth_session')
|
|
||||||
])->group(function () {
|
|
||||||
Route::inertia('/tramites', 'App/Tramites')->name('tramites');
|
|
||||||
Route::inertia('/obras', 'App/Obras')->name('obras');
|
|
||||||
Route::inertia('/atencion', 'App/AtencionCiudadana')->name('atencion');
|
|
||||||
Route::get('/api/export-excel', [AtencionController::class, 'exportExcel']);
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rutas del Dashboard
|
* Rutas del Dashboard
|
||||||
@ -36,24 +21,25 @@
|
|||||||
* El dashboard es el panel de los usuarios de forma general
|
* El dashboard es el panel de los usuarios de forma general
|
||||||
*/
|
*/
|
||||||
Route::prefix('dashboard')->name('dashboard.')->middleware([
|
Route::prefix('dashboard')->name('dashboard.')->middleware([
|
||||||
'auth:sanctum',
|
'auth:sanctum',
|
||||||
'verified',
|
'verified',
|
||||||
config('jetstream.auth_session')
|
config('jetstream.auth_session')
|
||||||
])->group(function () {
|
])->group(function () {
|
||||||
Route::get('/welcome', [IndexController::class, 'index'])->name('index');
|
Route::get('/welcome', [IndexController::class, 'index'])->name('index');
|
||||||
Route::inertia('/changelogs', 'Dashboard/Changelogs')->name('changelogs');
|
Route::inertia('/changelogs', 'Dashboard/Changelogs')->name('changelogs');
|
||||||
Route::inertia('/help', 'Dashboard/Help')->name('help');
|
Route::inertia('/help', 'Dashboard/Help')->name('help');
|
||||||
|
|
||||||
# Log de Acciones
|
# Log de Acciones
|
||||||
Route::resource('histories', HistoryLogController::class)->only([
|
Route::resource('histories', HistoryLogController::class)->only([
|
||||||
'index',
|
'index',
|
||||||
'store'
|
'store'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
Route::resource('notifications', NotificationController::class);
|
Route::resource('notifications', NotificationController::class);
|
||||||
Route::prefix('/users')->name('users.')->group(function () {
|
Route::prefix('/users')->name('users.')->group(function()
|
||||||
Route::get('/notifications', [UserController::class, 'getNotifications'])->name('notifications');
|
{
|
||||||
});
|
Route::get('/notifications', [UserController::class, 'getNotifications'])->name('notifications');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -63,16 +49,17 @@
|
|||||||
* puede acceder a ellas.
|
* puede acceder a ellas.
|
||||||
*/
|
*/
|
||||||
Route::prefix('admin')->name('admin.')->middleware([
|
Route::prefix('admin')->name('admin.')->middleware([
|
||||||
'auth:sanctum',
|
'auth:sanctum',
|
||||||
config('jetstream.auth_session')
|
config('jetstream.auth_session')
|
||||||
])->group(function () {
|
])->group(function () {
|
||||||
Route::resource('users', UserController::class);
|
Route::resource('users', UserController::class);
|
||||||
|
|
||||||
Route::prefix('/users')->name('users.')->group(function () {
|
Route::prefix('/users')->name('users.')->group(function()
|
||||||
Route::get('{user}/settings', [UserController::class, 'settings'])->name('settings');
|
{
|
||||||
Route::post('/password', [UserController::class, 'updatePassword'])->name('password');
|
Route::get('{user}/settings', [UserController::class, 'settings'])->name('settings');
|
||||||
Route::post('/syncRoles', [UserController::class, 'syncRoles'])->name('syncRoles');
|
Route::post('/password', [UserController::class, 'updatePassword'])->name('password');
|
||||||
});
|
Route::post('/syncRoles', [UserController::class, 'syncRoles'])->name('syncRoles');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -82,10 +69,10 @@
|
|||||||
* solo el desarrollador debe de ser capaz de modificarlas o actualizarlas.
|
* solo el desarrollador debe de ser capaz de modificarlas o actualizarlas.
|
||||||
*/
|
*/
|
||||||
Route::prefix('developer')->name('developer.')->middleware([
|
Route::prefix('developer')->name('developer.')->middleware([
|
||||||
'auth:sanctum',
|
'auth:sanctum',
|
||||||
config('jetstream.auth_session')
|
config('jetstream.auth_session')
|
||||||
])->group(function () {
|
])->group(function () {
|
||||||
Route::resource('roles', RoleController::class);
|
Route::resource('roles', RoleController::class);
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -97,9 +84,9 @@
|
|||||||
* en el dashboard.
|
* en el dashboard.
|
||||||
*/
|
*/
|
||||||
Route::prefix('examples')->name('examples.')->middleware([
|
Route::prefix('examples')->name('examples.')->middleware([
|
||||||
'auth:sanctum',
|
'auth:sanctum',
|
||||||
'verified',
|
'verified',
|
||||||
config('jetstream.auth_session')
|
config('jetstream.auth_session')
|
||||||
])->group(function () {
|
])->group(function () {
|
||||||
Route::get('/', [ExampleIndexController::class, 'index'])->name('index');
|
Route::get('/', [ExampleIndexController::class, 'index'])->name('index');
|
||||||
});
|
});
|
||||||
@ -16,7 +16,7 @@ module.exports = {
|
|||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
sans: ['Inter', 'ui-sans-serif', 'system-ui', 'Segoe UI', 'Roboto'],
|
sans: ['Nunito', ...defaultTheme.fontFamily.sans],
|
||||||
'google-icon':['Material Icons'],
|
'google-icon':['Material Icons'],
|
||||||
'google-icon-outlined':['Material Symbols Outlined']
|
'google-icon-outlined':['Material Symbols Outlined']
|
||||||
},
|
},
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user