Se agregó las gráficas a Tramites

This commit is contained in:
Juan Felipe Zapata Moreno 2025-08-14 15:49:46 -06:00
parent c6c2f78d16
commit bbe825313a
10 changed files with 837 additions and 46 deletions

32
package-lock.json generated
View File

@ -1,5 +1,5 @@
{
"name": "template-laravel-vuejs",
"name": "maquetador-graficas",
"lockfileVersion": 3,
"requires": true,
"packages": {
@ -8,12 +8,14 @@
"@inertiajs/vue3": "^1.0.0-beta.2",
"@soketi/soketi": "^1.6.0",
"@vueuse/core": "^9.6.0",
"chart.js": "^4.5.0",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"laravel-echo": "^1.14.2",
"pusher-js": "^7.5.0",
"sweetalert2": "^11.4.8",
"toastr": "^2.1.4",
"vue-chartjs": "^5.3.2",
"vue-i18n": "^9.2.2",
"vue-multiselect": "^3.0.0-alpha.2"
},
@ -523,6 +525,12 @@
"@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": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.2.tgz",
@ -1678,6 +1686,18 @@
"resolved": "https://registry.npmjs.org/charm/-/charm-0.1.2.tgz",
"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": {
"version": "3.5.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz",
@ -5451,6 +5471,16 @@
}
}
},
"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": {
"version": "9.7.1",
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-9.7.1.tgz",

View File

@ -22,12 +22,14 @@
"@inertiajs/vue3": "^1.0.0-beta.2",
"@soketi/soketi": "^1.6.0",
"@vueuse/core": "^9.6.0",
"chart.js": "^4.5.0",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"laravel-echo": "^1.14.2",
"pusher-js": "^7.5.0",
"sweetalert2": "^11.4.8",
"toastr": "^2.1.4",
"vue-chartjs": "^5.3.2",
"vue-i18n": "^9.2.2",
"vue-multiselect": "^3.0.0-alpha.2"
}

View File

@ -0,0 +1,34 @@
<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>

View File

@ -0,0 +1,32 @@
<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>

View File

@ -0,0 +1,29 @@
<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>

View File

@ -0,0 +1,42 @@
<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>

View File

@ -0,0 +1,118 @@
<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>

View File

@ -55,6 +55,11 @@ onMounted(()=> {
name="help.title"
to="dashboard.help"
/>
<Link
icon="live_help"
name="Graficas"
to="dashboard.chart-test.index"
/>
</Section>
<Section name="Configuraciones">
<Link

View File

@ -0,0 +1,494 @@
<script setup>
import { ref, computed, watch, onMounted } from "vue";
import Bars from "@/Components/Dashboard/Charts/Bars.vue";
import Pie from "@/Components/Dashboard/Charts/Pie.vue";
import DateRange from "@/Components/Dashboard/Form/DateRange.vue";
const data = ref(null);
const loading = ref(true);
const error = ref(null);
// Rango inicial: HoyHoy (coincide con preset del DateRange)
const today = new Date().toISOString().slice(0, 10);
const dateRange = ref({ start: today, end: today });
// toggles
const showBars = ref(false);
const showPie = ref(false);
// 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 abortController = null;
let debounceId = null;
const fetchReport = async ({ start, end }) => {
if (abortController) abortController.abort();
abortController = new AbortController();
const qs = new URLSearchParams();
if (start) qs.set("start", start);
if (end) qs.set("end", end);
const res = await fetch(
`/dashboard/api/proxy-reporte-especial?${qs.toString()}`,
{
signal: abortController.signal,
}
);
if (!res.ok) throw new Error("Error de red");
return res.json();
};
const load = async () => {
loading.value = true;
error.value = null;
try {
const payload = await fetchReport({
start: dateRange.value.start,
end: dateRange.value.end,
});
data.value = payload;
mapToCharts(payload, mode.value);
} catch (e) {
if (e.name !== "AbortError") {
console.error(e);
error.value = e.message || "Error desconocido";
}
} finally {
loading.value = false;
}
};
watch(
dateRange,
() => {
clearTimeout(debounceId);
debounceId = setTimeout(load, 350);
},
{ deep: true }
);
onMounted(load);
// --- mapping charts: usa SIEMPRE el rango ---
function mapToCharts(api, currentMode) {
const list = Array.isArray(api?.movements_by_procedure)
? api.movements_by_procedure
: [];
const labels = list.map((it) => (it.name || "").toUpperCase());
// Pie: total (opened + closed). Si tu API devuelve valores por rango, puedes sustituir aquí.
const pieValues = list.map((it) => (it.opened || 0) + (it.closed || 0));
pieChartData.value = {
labels,
datasets: [
{
label: "Recaudados",
data: pieValues,
backgroundColor: labels.map(
(_, i) => `hsl(${(i * 360) / Math.max(1, labels.length)}, 70%, 50%)`
),
},
],
};
// Bars: dataset según modo
let barLabel = "";
let barValues = [];
if (currentMode === "día") {
barLabel = "Solicitados Hoy";
barValues = list.map(
(it) => it.procedures_opened_today ?? it.opened_today ?? 0
);
} else if (currentMode === "semana") {
barLabel = "Solicitados Semana";
barValues = list.map((it) => it.total ?? 0);
} else {
barLabel = "Solicitados en Rango";
barValues = list.map((it) => {
if (typeof it.range_total === "number") return it.range_total;
return (it.opened || 0) + (it.closed || 0);
});
}
barChartData.value = {
labels,
datasets: [barChartOptions.makeDataset(barLabel, barValues)],
};
}
// UI helpers
const toggleBars = () => (showBars.value = !showBars.value);
const togglePie = () => (showPie.value = !showPie.value);
</script>
<template>
<div>
<div v-if="loading">Cargando...</div>
<div v-else-if="error">Error: {{ error }}</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 class="ml-auto flex items-center gap-3">
<button
@click="toggleBars"
class="bg-primary text-white px-4 py-2 rounded"
>
Gráfica de Barras
</button>
<button
@click="togglePie"
class="bg-primary text-white px-4 py-2 rounded"
>
Más recaudados
</button>
</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">
<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
v-if="showBars"
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
v-if="showPie"
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>
</div>
</template>

View File

@ -7,6 +7,7 @@
use App\Http\Controllers\Developer\RoleController;
use App\Http\Controllers\Example\IndexController as ExampleIndexController;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Http;
/**
* Rutas generales/publicas
@ -29,6 +30,12 @@
Route::inertia('/changelogs', 'Dashboard/Changelogs')->name('changelogs');
Route::inertia('/help', 'Dashboard/Help')->name('help');
Route::inertia('/api-example', 'Dashboard/Tramites')->name('api-example');
Route::get('/api/proxy-reporte-especial', function () {
$response = Http::get('https://tramites.comalcalco.gob.mx/reporte-especial?type=api');
return $response->json();
});
# Log de Acciones
Route::resource('histories', HistoryLogController::class)->only([
'index',
@ -36,8 +43,7 @@
]);
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');
});
});
@ -54,8 +60,7 @@
])->group(function () {
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::post('/syncRoles', [UserController::class, 'syncRoles'])->name('syncRoles');