cambios en la interfaz de organizaciones

This commit is contained in:
2026-02-23 12:40:30 -04:00
parent e149500735
commit 0efd5a11bd
12 changed files with 2427 additions and 265 deletions

View File

@@ -45,7 +45,8 @@ import {
CardTitle,
} from '@repo/shadcn/components/ui/card';
import { SelectSearchable } from '@repo/shadcn/components/ui/select-searchable';
import React from 'react';
import React, { useEffect } from 'react';
import { toast } from 'sonner';
const OSP_TYPES = ['EPSIC', 'EPSDC', 'UPF', 'OTROS', 'COOPERATIVA'];
const STATUS_OPTIONS = ['ACTIVA', 'INACTIVA'];
@@ -160,6 +161,16 @@ export function CreateTrainingForm({
mode: 'onChange',
});
// 1. Extrae errors de formState
const { formState: { errors } } = form;
// 2. Crea un efecto para monitorearlos
useEffect(() => {
if (Object.keys(errors).length > 0) {
console.log("Campos con errores:", errors);
}
}, [errors]);
// Cascading Select Logic
const ecoSector = useWatch({ control: form.control, name: 'ecoSector' });
const productiveSector = useWatch({
@@ -253,6 +264,8 @@ export function CreateTrainingForm({
}, [defaultValues]);
const onSubmit = async (formData: TrainingSchema) => {
const data = new FormData();
// 1. Definimos las claves que NO queremos enviar en el bucle general
@@ -260,9 +273,6 @@ export function CreateTrainingForm({
// 'photo1/2/3' son strings (urls viejas) que no queremos reenviar como texto.
const excludedKeys = [
'files',
'photo1',
'photo2',
'photo3',
'coorState',
'coorMunicipality',
'coorParish',
@@ -1039,7 +1049,7 @@ export function CreateTrainingForm({
render={({ field }) => (
<FormItem className="w-full flex flex-col space-y-2">
<FormLabel className="font-semibold">
Correo Electrónico de la Comuna
Correo Electrónico de la Comuna (Opcional)
</FormLabel>
<FormControl>
<Input type="email" {...field} />
@@ -1141,7 +1151,7 @@ export function CreateTrainingForm({
render={({ field }) => (
<FormItem className="w-full flex flex-col space-y-2">
<FormLabel className="font-semibold">
Correo Electrónico del Consejo Comunal
Correo Electrónico del Consejo Comunal (Opcional)
</FormLabel>
<FormControl>
<Input type="email" {...field} />
@@ -1298,7 +1308,7 @@ export function CreateTrainingForm({
name="generalObservations"
render={({ field }) => (
<FormItem>
<FormLabel>Observaciones Generales</FormLabel>
<FormLabel>Observaciones Generales (Opcional)</FormLabel>
<FormControl>
<Textarea {...field} />
</FormControl>
@@ -1374,19 +1384,25 @@ export function CreateTrainingForm({
multiple
accept="image/*"
onChange={(e) => {
const files = Array.from(e.target.files || []);
const newFiles = Array.from(e.target.files || []);
const existingCount = [
form.watch('photo1'),
form.watch('photo2'),
form.watch('photo3'),
].filter(Boolean).length;
if (files.length + existingCount > 3) {
alert('Máximo 3 imágenes en total');
if (
newFiles.length + selectedFiles.length + existingCount >
3
) {
toast.error(`Máximo 3 imágenes en total. Ya tienes ${existingCount} subidas y ${selectedFiles.length} seleccionadas para subir.`)
e.target.value = '';
return;
}
setSelectedFiles(files);
setSelectedFiles((prev) => [...prev, ...newFiles]);
// Reset the input value so the same file can be selected again if removed
e.target.value = '';
}}
/>
</div>
@@ -1398,13 +1414,37 @@ export function CreateTrainingForm({
{selectedFiles.map((file, idx) => (
<div
key={idx}
className="relative aspect-square rounded-md overflow-hidden bg-muted"
className="relative aspect-square rounded-md overflow-hidden bg-muted group"
>
<img
src={URL.createObjectURL(file)}
alt={`Preview ${idx + 1}`}
className="object-cover w-full h-full"
/>
<button
type="button"
onClick={() => {
setSelectedFiles((prev) =>
prev.filter((_, i) => i !== idx),
);
}}
className="absolute top-1 right-1 bg-destructive text-destructive-foreground rounded-full p-1 opacity-0 group-hover:opacity-100 transition-opacity"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
))}
</div>

View File

@@ -35,6 +35,17 @@ import { Trash2 } from 'lucide-react';
import { useState } from 'react';
import { useFieldArray, useFormContext } from 'react-hook-form';
const UNIT_OPTIONS = [
'KG',
'TON',
'UNID',
'LT',
'MTS',
'QQ',
'HM2',
'SACOS',
];
// 1. Definimos la estructura de los datos para que TypeScript no se queje
interface ProductItem {
productName: string;
@@ -68,20 +79,21 @@ export function ProductActivityList() {
dailyCount: '',
weeklyCount: '',
monthlyCount: '',
internalDistributionZone: '',
// Internal dist
internalState: undefined,
internalMunicipality: undefined,
internalParish: undefined,
internalState: null,
internalMunicipality: null,
internalParish: null,
internalDescription: '',
internalQuantity: '',
internalUnit: '',
// External dist
externalCountry: '',
externalState: undefined,
externalMunicipality: undefined,
externalParish: undefined,
externalState: null,
externalMunicipality: null,
externalParish: null,
externalCity: '',
externalDescription: '',
externalQuantity: '',
@@ -117,16 +129,17 @@ export function ProductActivityList() {
dailyCount: '',
weeklyCount: '',
monthlyCount: '',
internalState: undefined,
internalMunicipality: undefined,
internalParish: undefined,
internalDistributionZone: '',
internalState: null,
internalMunicipality: null,
internalParish: null,
internalDescription: '',
internalQuantity: '',
internalUnit: '',
externalCountry: '',
externalState: undefined,
externalMunicipality: undefined,
externalParish: undefined,
externalState: null,
externalMunicipality: null,
externalParish: null,
externalCity: '',
externalDescription: '',
externalQuantity: '',
@@ -337,17 +350,37 @@ export function ProductActivityList() {
/>
</div>
<div className="space-y-2">
<Label>Cantidad Numérica (KG, TON, UNID. LT, MTS,QQ, HM2, SACOS)</Label>
<Input
type="number"
value={newItem.externalQuantity}
onChange={(e) =>
setNewItem({
...newItem,
externalQuantity: e.target.value,
})
}
/>
<Label>Cantidad Numérica</Label>
<div className="flex gap-2">
<Input
type="number"
className="flex-1"
value={newItem.externalQuantity}
onChange={(e) =>
setNewItem({
...newItem,
externalQuantity: e.target.value,
})
}
/>
<Select
value={newItem.externalUnit}
onValueChange={(val) =>
setNewItem({ ...newItem, externalUnit: val })
}
>
<SelectTrigger className="w-[120px]">
<SelectValue placeholder="Unidad" />
</SelectTrigger>
<SelectContent>
{UNIT_OPTIONS.map((unit) => (
<SelectItem key={unit} value={unit}>
{unit}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>

View File

@@ -17,10 +17,28 @@ import {
} from '@repo/shadcn/components/ui/table';
import { Input } from '@repo/shadcn/input';
import { Label } from '@repo/shadcn/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@repo/shadcn/select';
import { Trash2 } from 'lucide-react';
import { useState } from 'react';
import { useFieldArray, useFormContext } from 'react-hook-form';
const UNIT_OPTIONS = [
'KG',
'TON',
'UNID',
'LT',
'MTS',
'QQ',
'HM2',
'SACOS',
];
export function ProductionList() {
const { control, register } = useFormContext();
const { fields, append, remove } = useFieldArray({
@@ -32,12 +50,13 @@ export function ProductionList() {
rawMaterial: '',
supplyType: '',
quantity: '',
unit: '',
});
const handleAdd = () => {
if (newItem.rawMaterial && newItem.quantity) {
append({ ...newItem, quantity: Number(newItem.quantity) });
setNewItem({ rawMaterial: '', supplyType: '', quantity: '' });
setNewItem({ rawMaterial: '', supplyType: '', quantity: '', unit: '' });
setIsOpen(false);
}
};
@@ -79,15 +98,35 @@ export function ProductionList() {
/>
</div>
<div className="space-y-2">
<Label>Cantidad Mensual (Kg, TON, UNID.LT, MTS,QQ,HM2,SACO)</Label>
<Input
type="number"
value={newItem.quantity}
onChange={(e) =>
setNewItem({ ...newItem, quantity: e.target.value })
}
placeholder="0"
/>
<Label>Cantidad Mensual</Label>
<div className="flex gap-2">
<Input
type="number"
className="flex-1"
value={newItem.quantity}
onChange={(e) =>
setNewItem({ ...newItem, quantity: e.target.value })
}
placeholder="0"
/>
<Select
value={newItem.unit}
onValueChange={(val) =>
setNewItem({ ...newItem, unit: val })
}
>
<SelectTrigger className="w-[120px]">
<SelectValue placeholder="Unidad" />
</SelectTrigger>
<SelectContent>
{UNIT_OPTIONS.map((unit) => (
<SelectItem key={unit} value={unit}>
{unit}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex justify-end gap-4">
<Button
@@ -134,13 +173,17 @@ export function ProductionList() {
{/* @ts-ignore */}
{field.supplyType}
</TableCell>
<TableCell>
<TableCell>
<input
type="hidden"
{...register(`productionList.${index}.quantity`)}
/>
<input
type="hidden"
{...register(`productionList.${index}.unit`)}
/>
{/* @ts-ignore */}
{field.quantity}
{field.quantity} {field.unit}
</TableCell>
<TableCell>
<Button

View File

@@ -40,11 +40,29 @@ export const CellAction: React.FC<CellActionProps> = ({ data, apiUrl }) => {
}
};
const handleExport = (id?: number | undefined) => {
window.open(`${apiUrl}/training/export/${id}`, '_blank');
};
// Mapear roles a minúsculas para comparación segura
const userRoles = session?.user?.role?.map((r) => r.rol.toLowerCase()) || [];
const isAdminOrSuper = userRoles.some((r) =>
['superadmin', 'admin'].includes(r),
);
// Soporta tanto 'coordinator' como 'coordinador'
const isCoordinator = userRoles.some(r =>
r.includes('coordinator') || r.includes('coordinador')
);
const role = session?.user.role[0]?.rol;
const isOtherAuthorized = userRoles.some((r) =>
['autoridad', 'manager'].includes(r),
);
// El creador del registro: intentamos createdBy o created_by por si acaso
const createdBy = data.createdBy ?? (data as any).created_by;
// Comparación robusta de IDs
const isOwner = createdBy !== undefined &&
createdBy !== null &&
Number(createdBy) === Number(session?.user?.id);
return (
<>
@@ -64,10 +82,8 @@ export const CellAction: React.FC<CellActionProps> = ({ data, apiUrl }) => {
/>
<div className="flex gap-1">
{/* VER DETALLE: superadmin, admin, autoridad, manager */}
{['superadmin', 'admin', 'autoridad', 'manager'].includes(
role ?? '',
) && (
{/* VER DETALLE: superadmin, admin, autoridad, manager, or owner coordinator */}
{(isAdminOrSuper || isOtherAuthorized || (isCoordinator && isOwner)) && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
@@ -86,47 +102,46 @@ export const CellAction: React.FC<CellActionProps> = ({ data, apiUrl }) => {
</TooltipProvider>
)}
{/* EDITAR Y ELIMINAR: Solo superadmin y admin */}
{['superadmin', 'admin'].includes(role ?? '') && (
<>
{/* Editar */}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={() =>
router.push(`/dashboard/formulario/editar/${data.id}`)
}
>
<Edit className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Editar</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
{/* EDITAR: Superadmin, admin OR (coordinator if owner) */}
{(isAdminOrSuper || (isCoordinator && isOwner)) && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={() =>
router.push(`/dashboard/formulario/editar/${data.id}`)
}
>
<Edit className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Editar</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* Eliminar */}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={() => setOpen(true)}
>
<Trash className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Eliminar</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</>
{/* ELIMINAR: Solo superadmin y admin */}
{isAdminOrSuper && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={() => setOpen(true)}
>
<Trash className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Eliminar</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
</>

View File

@@ -273,7 +273,7 @@ export function TrainingViewModal({
<span className="text-xs font-bold text-muted-foreground block mb-1">
DISTRIBUCIÓN INTERNA
</span>
<p>Cant: {prod.internalQuantity}</p>
<p>Cant: {prod.internalQuantity} {prod.internalUnit}</p>
<p className="text-xs text-muted-foreground">
{prod.internalDescription}
</p>
@@ -284,7 +284,7 @@ export function TrainingViewModal({
<span className="text-xs font-bold text-muted-foreground block mb-1">
EXPORTACIÓN ({prod.externalCountry})
</span>
<p>Cant: {prod.externalQuantity}</p>
<p>Cant: {prod.externalQuantity} {prod.externalUnit}</p>
<p className="text-xs text-muted-foreground">
{prod.externalDescription}
</p>
@@ -360,7 +360,7 @@ export function TrainingViewModal({
{mat.supplyType}
</p>
</div>
<Badge variant="secondary">Cant: {mat.quantity}</Badge>
<Badge variant="secondary">Cant: {mat.quantity} {mat.unit}</Badge>
</div>
))}
{(!data.productionList ||