feature/catalogo-driver
This commit is contained in:
@@ -36,6 +36,33 @@ class DriverCreateDTO(DriverBaseDTO):
|
||||
pass
|
||||
|
||||
|
||||
class DriverUpdateDTO(BaseModel):
|
||||
"""All fields optional; transporter_key, line, company_id, tenant_id are not updated."""
|
||||
|
||||
driver_name: Optional[str] = None
|
||||
license_number: Optional[str] = None
|
||||
express_line_id: Optional[str] = None
|
||||
ace_id: Optional[str] = None
|
||||
birth_date: Optional[int] = None
|
||||
gender: Optional[str] = None
|
||||
birth_country: Optional[str] = None
|
||||
hazardous_material_auth: Optional[str] = None
|
||||
hazardous_material_state: Optional[str] = None
|
||||
first_name: Optional[str] = None
|
||||
last_name: Optional[str] = None
|
||||
id_key1: Optional[str] = None
|
||||
id_number1: Optional[str] = None
|
||||
id_state1: Optional[str] = None
|
||||
id_country1: Optional[str] = None
|
||||
id_key2: Optional[str] = None
|
||||
id_number2: Optional[str] = None
|
||||
id_state2: Optional[str] = None
|
||||
id_country2: Optional[str] = None
|
||||
badge_number: Optional[str] = None
|
||||
class_type: Optional[str] = None
|
||||
unique_badge_number: Optional[str] = None
|
||||
|
||||
|
||||
class DriverResponseDTO(DriverBaseDTO):
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
@@ -5,7 +5,7 @@ from core.security import get_current_user, validate_access_to_resource
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .dto import DriverCreateDTO, DriverResponseDTO
|
||||
from .dto import DriverCreateDTO, DriverResponseDTO, DriverUpdateDTO
|
||||
from .models import Driver
|
||||
from .services import DriverService
|
||||
from api.v1.modules.a76.layouts_csv.drivers.routes import router as imports_router
|
||||
@@ -66,6 +66,29 @@ async def create_driver(
|
||||
return DriverService.create_driver(db, driver_data)
|
||||
|
||||
|
||||
@router.put("/{transporter_key}/{line}", response_model=DriverResponseDTO)
|
||||
async def update_driver(
|
||||
transporter_key: str,
|
||||
line: int,
|
||||
driver_data: DriverUpdateDTO,
|
||||
company_id: int = Query(..., description="Company ID for filtering"),
|
||||
db: Session = Depends(get_core_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
tenant_id = validate_access_to_resource(db, company_id, current_user)
|
||||
driver = DriverService.update_driver(
|
||||
db,
|
||||
transporter_key,
|
||||
line,
|
||||
str(company_id),
|
||||
tenant_id,
|
||||
driver_data,
|
||||
)
|
||||
if not driver:
|
||||
raise HTTPException(status_code=404, detail="Driver not found")
|
||||
return driver
|
||||
|
||||
|
||||
@router.delete("/{transporter_key}/{line}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_driver(
|
||||
transporter_key: str,
|
||||
|
||||
@@ -40,6 +40,27 @@ class DriverService:
|
||||
db.refresh(new_driver)
|
||||
return new_driver
|
||||
|
||||
@staticmethod
|
||||
def update_driver(
|
||||
db: Session,
|
||||
transporter_key: str,
|
||||
line: int,
|
||||
company_id: str,
|
||||
tenant_id: Optional[str],
|
||||
data: dto.DriverUpdateDTO,
|
||||
) -> Optional[models.Driver]:
|
||||
driver = DriverService.get_driver_by_key_and_line(
|
||||
db, transporter_key, line, company_id, tenant_id
|
||||
)
|
||||
if not driver:
|
||||
return None
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
setattr(driver, key, value)
|
||||
db.commit()
|
||||
db.refresh(driver)
|
||||
return driver
|
||||
|
||||
@staticmethod
|
||||
def delete_driver(
|
||||
db: Session,
|
||||
|
||||
@@ -1,57 +1,105 @@
|
||||
import { api, type ApiResponse } from '$lib/api';
|
||||
|
||||
export interface Driver {
|
||||
transporter_key: string;
|
||||
line: number;
|
||||
driver_name?: string;
|
||||
license_number?: string;
|
||||
express_line_id?: string;
|
||||
ace_id?: string;
|
||||
birth_date?: number;
|
||||
gender?: string;
|
||||
birth_country?: string;
|
||||
hazardous_material_auth?: string;
|
||||
hazardous_material_state?: string;
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
badge_number?: string;
|
||||
class_type?: string;
|
||||
transporter_key: string;
|
||||
line: number;
|
||||
driver_name?: string;
|
||||
license_number?: string;
|
||||
express_line_id?: string;
|
||||
ace_id?: string;
|
||||
birth_date?: number;
|
||||
gender?: string;
|
||||
birth_country?: string;
|
||||
hazardous_material_auth?: string;
|
||||
hazardous_material_state?: string;
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
id_key1?: string;
|
||||
id_number1?: string;
|
||||
id_state1?: string;
|
||||
id_country1?: string;
|
||||
id_key2?: string;
|
||||
id_number2?: string;
|
||||
id_state2?: string;
|
||||
id_country2?: string;
|
||||
badge_number?: string;
|
||||
class_type?: string;
|
||||
unique_badge_number?: string;
|
||||
company_id?: number;
|
||||
tenant_id?: number;
|
||||
}
|
||||
|
||||
export interface DriverResponse {
|
||||
items: Driver[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
items: Driver[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}
|
||||
|
||||
class DriversApi {
|
||||
private baseUrl = '/v1/a76/drivers';
|
||||
private baseUrl = '/v1/a76/drivers';
|
||||
|
||||
async list(
|
||||
companyId: string | number,
|
||||
page: number = 1,
|
||||
pageSize: number = 50
|
||||
): Promise<ApiResponse<DriverResponse>> {
|
||||
const params = new URLSearchParams({
|
||||
company_id: companyId.toString(),
|
||||
page: page.toString(),
|
||||
page_size: pageSize.toString()
|
||||
});
|
||||
async list(
|
||||
companyId: string | number,
|
||||
params?: Record<string, string | number | undefined>
|
||||
): Promise<ApiResponse<DriverResponse>> {
|
||||
const queryParams = new URLSearchParams({
|
||||
company_id: companyId.toString(),
|
||||
...(params as Record<string, string>)
|
||||
});
|
||||
return api.get<DriverResponse>(`${this.baseUrl}?${queryParams.toString()}`);
|
||||
}
|
||||
|
||||
return api.get<DriverResponse>(`${this.baseUrl}?${params.toString()}`);
|
||||
}
|
||||
async get(
|
||||
transporterKey: string,
|
||||
line: number,
|
||||
companyId: string | number
|
||||
): Promise<ApiResponse<Driver>> {
|
||||
const params = new URLSearchParams({
|
||||
company_id: companyId.toString()
|
||||
});
|
||||
return api.get<Driver>(
|
||||
`${this.baseUrl}/${transporterKey}/${line}?${params.toString()}`
|
||||
);
|
||||
}
|
||||
|
||||
async get(
|
||||
transporterKey: string,
|
||||
line: number,
|
||||
companyId: string | number
|
||||
): Promise<ApiResponse<Driver>> {
|
||||
const params = new URLSearchParams({
|
||||
company_id: companyId.toString()
|
||||
});
|
||||
return api.get<Driver>(`${this.baseUrl}/${transporterKey}/${line}?${params.toString()}`);
|
||||
}
|
||||
async create(
|
||||
data: Omit<Driver, 'company_id' | 'tenant_id'> & { company_id: number; tenant_id: number },
|
||||
companyId: string | number
|
||||
): Promise<ApiResponse<Driver>> {
|
||||
const params = new URLSearchParams({
|
||||
company_id: companyId.toString()
|
||||
});
|
||||
return api.post<Driver>(`${this.baseUrl}?${params.toString()}`, data);
|
||||
}
|
||||
|
||||
async update(
|
||||
transporterKey: string,
|
||||
line: number,
|
||||
data: Partial<Driver>,
|
||||
companyId: string | number
|
||||
): Promise<ApiResponse<Driver>> {
|
||||
const params = new URLSearchParams({
|
||||
company_id: companyId.toString()
|
||||
});
|
||||
return api.put<Driver>(
|
||||
`${this.baseUrl}/${transporterKey}/${line}?${params.toString()}`,
|
||||
data
|
||||
);
|
||||
}
|
||||
|
||||
async delete(
|
||||
transporterKey: string,
|
||||
line: number,
|
||||
companyId: string | number
|
||||
): Promise<ApiResponse<void>> {
|
||||
const params = new URLSearchParams({
|
||||
company_id: companyId.toString()
|
||||
});
|
||||
return api.delete<void>(
|
||||
`${this.baseUrl}/${transporterKey}/${line}?${params.toString()}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const driversApi = new DriversApi();
|
||||
|
||||
@@ -34,16 +34,22 @@ export interface TransporterResponse {
|
||||
class TransportersApi {
|
||||
private baseUrl = '/v1/a76/transporters';
|
||||
|
||||
async list(
|
||||
companyId: string | number,
|
||||
params?: Record<string, any>
|
||||
): Promise<ApiResponse<TransporterResponse>> {
|
||||
const queryParams = new URLSearchParams({
|
||||
company_id: companyId.toString(),
|
||||
...params
|
||||
});
|
||||
return api.get<TransporterResponse>(`${this.baseUrl}?${queryParams.toString()}`);
|
||||
}
|
||||
async list(
|
||||
companyId: string | number,
|
||||
params?: Record<string, string | number | undefined>
|
||||
): Promise<ApiResponse<TransporterResponse>> {
|
||||
const raw: Record<string, string | number> = {
|
||||
company_id: companyId.toString(),
|
||||
...params
|
||||
};
|
||||
const queryParams = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(raw)) {
|
||||
if (v != null && v !== '') {
|
||||
queryParams.set(k, String(v));
|
||||
}
|
||||
}
|
||||
return api.get<TransporterResponse>(`${this.baseUrl}?${queryParams.toString()}`);
|
||||
}
|
||||
|
||||
async get(id: string, companyId: string | number): Promise<ApiResponse<Transporter>> {
|
||||
const queryParams = new URLSearchParams({
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Definición de columnas para la tabla de Conductores
|
||||
*/
|
||||
import type { Driver } from '$lib/api/dashboard/a76/drivers';
|
||||
import type { ColumnDef } from '@tanstack/table-core';
|
||||
import { renderComponent } from '$lib/components/ui/data-table';
|
||||
import DataTableActions from './data-table-actions.svelte';
|
||||
|
||||
export function createColumns(onSuccess?: () => void): ColumnDef<Driver>[] {
|
||||
return [
|
||||
{
|
||||
accessorKey: 'transporter_key',
|
||||
header: 'Clave Transportista',
|
||||
cell: ({ row }) => row.original.transporter_key
|
||||
},
|
||||
{
|
||||
accessorKey: 'line',
|
||||
header: 'Línea',
|
||||
cell: ({ row }) => row.original.line
|
||||
},
|
||||
{
|
||||
accessorKey: 'driver_name',
|
||||
header: 'Nombre del Conductor',
|
||||
cell: ({ row }) => row.original.driver_name || '-'
|
||||
},
|
||||
{
|
||||
accessorKey: 'license_number',
|
||||
header: 'Número de Licencia',
|
||||
cell: ({ row }) => row.original.license_number || '-'
|
||||
},
|
||||
{
|
||||
accessorKey: 'first_name',
|
||||
header: 'Nombre',
|
||||
cell: ({ row }) => row.original.first_name || '-'
|
||||
},
|
||||
{
|
||||
accessorKey: 'last_name',
|
||||
header: 'Apellido',
|
||||
cell: ({ row }) => row.original.last_name || '-'
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: 'Acciones',
|
||||
cell: ({ row }) => {
|
||||
return renderComponent(DataTableActions, {
|
||||
item: row.original,
|
||||
onSuccess
|
||||
});
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import { driversApi, type Driver } from '$lib/api/dashboard/a76/drivers';
|
||||
import { transportersApi, type Transporter } from '$lib/api/dashboard/a76/transporters';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
item = null,
|
||||
onSuccess
|
||||
}: {
|
||||
open: boolean;
|
||||
item?: Driver | null;
|
||||
onSuccess?: () => void;
|
||||
} = $props();
|
||||
|
||||
const isEdit = $derived(!!item);
|
||||
const title = $derived(isEdit ? 'Editar Conductor' : 'Nuevo Conductor');
|
||||
|
||||
let transporters = $state<Transporter[]>([]);
|
||||
let transportersLoading = $state(false);
|
||||
|
||||
let formData = $state<Driver & { lineStr?: string }>({
|
||||
transporter_key: '',
|
||||
line: 0,
|
||||
driver_name: '',
|
||||
license_number: '',
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
badge_number: '',
|
||||
express_line_id: '',
|
||||
ace_id: '',
|
||||
birth_country: '',
|
||||
hazardous_material_auth: '',
|
||||
hazardous_material_state: '',
|
||||
class_type: ''
|
||||
});
|
||||
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
// Cargar transportistas al abrir el diálogo en modo creación (backend max page_size=100)
|
||||
$effect(() => {
|
||||
if (open && !item && companyStore.activeCompany) {
|
||||
transportersLoading = true;
|
||||
transportersApi
|
||||
.list(companyStore.activeCompany.id, { page: 1, page_size: 100 })
|
||||
.then((res) => {
|
||||
if (res.data?.items) transporters = res.data.items;
|
||||
else transporters = [];
|
||||
})
|
||||
.catch(() => (transporters = []))
|
||||
.finally(() => (transportersLoading = false));
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (item) {
|
||||
formData = {
|
||||
...item,
|
||||
lineStr: String(item.line)
|
||||
};
|
||||
} else {
|
||||
formData = {
|
||||
transporter_key: '',
|
||||
line: 0,
|
||||
lineStr: '',
|
||||
driver_name: '',
|
||||
license_number: '',
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
badge_number: '',
|
||||
express_line_id: '',
|
||||
ace_id: '',
|
||||
birth_country: '',
|
||||
hazardous_material_auth: '',
|
||||
hazardous_material_state: '',
|
||||
class_type: ''
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
async function handleSubmit() {
|
||||
error = null;
|
||||
loading = true;
|
||||
|
||||
try {
|
||||
const company = companyStore.activeCompany;
|
||||
if (!company) {
|
||||
throw new Error('No hay una compañía seleccionada');
|
||||
}
|
||||
|
||||
if (!formData.transporter_key?.trim()) {
|
||||
throw new Error('Selecciona un transportista de la lista');
|
||||
}
|
||||
|
||||
const lineNum = isEdit ? item!.line : parseInt(String(formData.lineStr ?? formData.line), 10);
|
||||
if (!isEdit && (Number.isNaN(lineNum) || lineNum < 1)) {
|
||||
throw new Error('La línea debe ser un número mayor a 0');
|
||||
}
|
||||
|
||||
if (isEdit && item) {
|
||||
const response = await driversApi.update(
|
||||
item.transporter_key,
|
||||
item.line,
|
||||
{
|
||||
driver_name: formData.driver_name || undefined,
|
||||
license_number: formData.license_number || undefined,
|
||||
first_name: formData.first_name || undefined,
|
||||
last_name: formData.last_name || undefined,
|
||||
badge_number: formData.badge_number || undefined,
|
||||
express_line_id: formData.express_line_id || undefined,
|
||||
ace_id: formData.ace_id || undefined,
|
||||
birth_country: formData.birth_country || undefined,
|
||||
hazardous_material_auth: formData.hazardous_material_auth || undefined,
|
||||
hazardous_material_state: formData.hazardous_material_state || undefined,
|
||||
class_type: formData.class_type || undefined
|
||||
},
|
||||
company.id
|
||||
);
|
||||
if (response.error) throw new Error(response.error);
|
||||
} else {
|
||||
const payload = {
|
||||
transporter_key: String(formData.transporter_key).trim(),
|
||||
line: lineNum,
|
||||
driver_name: formData.driver_name || undefined,
|
||||
license_number: formData.license_number || undefined,
|
||||
first_name: formData.first_name || undefined,
|
||||
last_name: formData.last_name || undefined,
|
||||
badge_number: formData.badge_number || undefined,
|
||||
express_line_id: formData.express_line_id || undefined,
|
||||
ace_id: formData.ace_id || undefined,
|
||||
birth_country: formData.birth_country || undefined,
|
||||
hazardous_material_auth: formData.hazardous_material_auth || undefined,
|
||||
hazardous_material_state: formData.hazardous_material_state || undefined,
|
||||
class_type: formData.class_type || undefined,
|
||||
company_id: company.id,
|
||||
tenant_id: company.tenant_id
|
||||
};
|
||||
const response = await driversApi.create(payload, company.id);
|
||||
if (response.error) throw new Error(response.error);
|
||||
}
|
||||
|
||||
open = false;
|
||||
if (onSuccess) onSuccess();
|
||||
} catch (e) {
|
||||
if (e && typeof e === 'object' && 'message' in e) {
|
||||
error = (e as { message: string }).message;
|
||||
} else {
|
||||
error = 'Error al guardar el conductor';
|
||||
}
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
open = false;
|
||||
error = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{title}</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
{isEdit
|
||||
? 'Modifica los datos del conductor'
|
||||
: 'Completa los datos para crear un nuevo conductor'}
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}}
|
||||
class="space-y-6"
|
||||
>
|
||||
{#if error}
|
||||
<div class="rounded-md bg-destructive/15 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div class="grid gap-2">
|
||||
<Label for="transporter_key"
|
||||
>Transportista <span class="text-destructive">*</span></Label
|
||||
>
|
||||
{#if isEdit}
|
||||
<Input
|
||||
id="transporter_key"
|
||||
value={formData.transporter_key}
|
||||
disabled
|
||||
class="bg-muted"
|
||||
/>
|
||||
{:else}
|
||||
<Select.Root
|
||||
type="single"
|
||||
bind:value={formData.transporter_key}
|
||||
disabled={transportersLoading}
|
||||
>
|
||||
<Select.Trigger class="w-full">
|
||||
{transportersLoading
|
||||
? 'Cargando transportistas...'
|
||||
: transporters.length === 0
|
||||
? 'No hay transportistas'
|
||||
: transporters.find((t) => t.transporter_key === formData.transporter_key)
|
||||
? `${formData.transporter_key} - ${transporters.find((t) => t.transporter_key === formData.transporter_key)?.name || ''}`
|
||||
: 'Seleccionar transportista'}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each transporters as t}
|
||||
<Select.Item value={t.transporter_key} label={t.transporter_key}>
|
||||
{t.transporter_key} — {t.name || t.short_name || 'Sin nombre'}
|
||||
</Select.Item>
|
||||
{/each}
|
||||
{#if !transportersLoading && transporters.length === 0}
|
||||
<div class="px-2 py-3 text-sm text-muted-foreground">
|
||||
No hay transportistas. Crea uno en el catálogo Transportistas.
|
||||
</div>
|
||||
{/if}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="line">Línea <span class="text-destructive">*</span></Label>
|
||||
<Input
|
||||
id="line"
|
||||
type="number"
|
||||
min="1"
|
||||
bind:value={formData.lineStr}
|
||||
disabled={isEdit}
|
||||
required
|
||||
placeholder="Ej: 1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2 md:col-span-2">
|
||||
<Label for="driver_name">Nombre del Conductor</Label>
|
||||
<Input id="driver_name" bind:value={formData.driver_name} maxlength={80} />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="first_name">Nombre</Label>
|
||||
<Input id="first_name" bind:value={formData.first_name} maxlength={20} />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="last_name">Apellido</Label>
|
||||
<Input id="last_name" bind:value={formData.last_name} maxlength={20} />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="license_number">Número de Licencia</Label>
|
||||
<Input id="license_number" bind:value={formData.license_number} maxlength={29} />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="badge_number">Número de Placa/Insignia</Label>
|
||||
<Input id="badge_number" bind:value={formData.badge_number} maxlength={20} />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="express_line_id">Express Line ID</Label>
|
||||
<Input id="express_line_id" bind:value={formData.express_line_id} maxlength={17} />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="ace_id">ACE ID</Label>
|
||||
<Input id="ace_id" bind:value={formData.ace_id} maxlength={20} />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="birth_country">País de Nacimiento</Label>
|
||||
<Input id="birth_country" bind:value={formData.birth_country} maxlength={3} placeholder="MEX / USA" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="hazardous_material_auth">Auth. Material Peligroso</Label>
|
||||
<Input id="hazardous_material_auth" bind:value={formData.hazardous_material_auth} maxlength={2} />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="hazardous_material_state">Estado Material Peligroso</Label>
|
||||
<Input id="hazardous_material_state" bind:value={formData.hazardous_material_state} maxlength={30} />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="class_type">Tipo de Clase</Label>
|
||||
<Input id="class_type" bind:value={formData.class_type} maxlength={1} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button type="button" variant="outline" onclick={handleCancel} disabled={loading}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading ? 'Guardando...' : isEdit ? 'Actualizar' : 'Crear'}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -0,0 +1,116 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import { driversApi, type Driver } from '$lib/api/dashboard/a76/drivers';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
import { EllipsisVertical, Pencil, LoaderCircle, Trash2 } from 'lucide-svelte';
|
||||
import CreateEditDialog from './create-edit-dialog.svelte';
|
||||
|
||||
let {
|
||||
item,
|
||||
onSuccess
|
||||
}: {
|
||||
item: Driver;
|
||||
onSuccess?: () => void;
|
||||
} = $props();
|
||||
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let dialogOpen = $state(false);
|
||||
let selectedItem = $state<Driver | null>(null);
|
||||
|
||||
async function handleDelete() {
|
||||
if (
|
||||
!confirm(
|
||||
`¿Estás seguro de eliminar el conductor "${item.driver_name || item.transporter_key + '-' + item.line}"?\n\nNota: No se puede eliminar si tiene registros relacionados.`
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!companyStore.activeCompany) {
|
||||
alert('❌ Error: No hay una compañía seleccionada');
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const response = await driversApi.delete(
|
||||
item.transporter_key,
|
||||
item.line,
|
||||
companyStore.activeCompany.id
|
||||
);
|
||||
|
||||
if (response.error) {
|
||||
if (response.status === 401) {
|
||||
error = 'Sesión expirada. Recargando página...';
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1500);
|
||||
} else {
|
||||
error = response.error;
|
||||
alert(`❌ Error al eliminar:\n\n${response.error}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
alert(
|
||||
`✅ Conductor "${item.driver_name || item.transporter_key + '-' + item.line}" eliminado correctamente`
|
||||
);
|
||||
if (onSuccess) {
|
||||
onSuccess();
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Error al eliminar';
|
||||
alert(`❌ Error: ${error}`);
|
||||
console.error('Error deleting:', e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleEdit() {
|
||||
selectedItem = item;
|
||||
dialogOpen = true;
|
||||
}
|
||||
|
||||
function handleDialogSuccess() {
|
||||
dialogOpen = false;
|
||||
selectedItem = null;
|
||||
if (onSuccess) {
|
||||
onSuccess();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button {...props} variant="ghost" size="icon" class="relative size-8 p-0">
|
||||
<span class="sr-only">Abrir menú</span>
|
||||
<EllipsisVertical size={16} />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end" class="w-[160px]">
|
||||
<DropdownMenu.Label>Acciones</DropdownMenu.Label>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item onclick={handleEdit}>
|
||||
<Pencil size={16} class="mr-2" />
|
||||
Editar
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item onclick={handleDelete} class="text-destructive" disabled={loading}>
|
||||
{#if loading}
|
||||
<LoaderCircle class="mr-2 h-4 w-4 animate-spin" />
|
||||
{:else}
|
||||
<Trash2 size={16} class="mr-2" />
|
||||
{/if}
|
||||
Eliminar
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
|
||||
<CreateEditDialog bind:open={dialogOpen} item={selectedItem} onSuccess={handleDialogSuccess} />
|
||||
@@ -0,0 +1,99 @@
|
||||
<script lang="ts" generics="TData, TValue">
|
||||
import { type ColumnDef, getCoreRowModel } from '@tanstack/table-core';
|
||||
import { createSvelteTable, FlexRender } from '$lib/components/ui/data-table/index.js';
|
||||
import * as Table from '$lib/components/ui/table/index.js';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
|
||||
type DataTableProps<TData, TValue> = {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
pageCount: number;
|
||||
totalItems: number;
|
||||
};
|
||||
|
||||
let { data, columns, pageCount, totalItems }: DataTableProps<TData, TValue> = $props();
|
||||
|
||||
const table = createSvelteTable({
|
||||
get data() {
|
||||
return data;
|
||||
},
|
||||
get columns() {
|
||||
return columns;
|
||||
},
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
manualPagination: true,
|
||||
get pageCount() {
|
||||
return pageCount;
|
||||
}
|
||||
});
|
||||
|
||||
function handlePageChange(newPage: number) {
|
||||
const url = new URL($page.url);
|
||||
url.searchParams.set('page', newPage.toString());
|
||||
goto(url);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="rounded-md border bg-card">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
{#each table.getHeaderGroups() as headerGroup (headerGroup.id)}
|
||||
<Table.Row>
|
||||
{#each headerGroup.headers as header (header.id)}
|
||||
<Table.Head>
|
||||
{#if !header.isPlaceholder}
|
||||
<FlexRender
|
||||
content={header.column.columnDef.header}
|
||||
context={header.getContext()}
|
||||
/>
|
||||
{/if}
|
||||
</Table.Head>
|
||||
{/each}
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each table.getRowModel().rows as row (row.id)}
|
||||
<Table.Row data-state={row.getIsSelected() && 'selected'}>
|
||||
{#each row.getVisibleCells() as cell (cell.id)}
|
||||
<Table.Cell>
|
||||
<FlexRender content={cell.column.columnDef.cell} context={cell.getContext()} />
|
||||
</Table.Cell>
|
||||
{/each}
|
||||
</Table.Row>
|
||||
{:else}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={columns.length} class="h-24 text-center">
|
||||
No hay resultados.
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end space-x-2 py-4">
|
||||
<div class="flex-1 text-sm text-muted-foreground">
|
||||
Total: {totalItems}
|
||||
</div>
|
||||
<div class="space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => handlePageChange(Number($page.url.searchParams.get('page') || 1) - 1)}
|
||||
disabled={Number($page.url.searchParams.get('page') || 1) <= 1}
|
||||
>
|
||||
Anterior
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => handlePageChange(Number($page.url.searchParams.get('page') || 1) + 1)}
|
||||
disabled={Number($page.url.searchParams.get('page') || 1) >= pageCount}
|
||||
>
|
||||
Siguiente
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -344,6 +344,10 @@ export function getSidebarData(): SidebarData {
|
||||
title: "Transportistas",
|
||||
url: "/dashboard/general_catalogs/transporters",
|
||||
},
|
||||
{
|
||||
title: "Conductores",
|
||||
url: "/dashboard/general_catalogs/drivers",
|
||||
},
|
||||
{
|
||||
title: "Trailers",
|
||||
url: "/dashboard/general_catalogs/trailers",
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Plus } from 'lucide-svelte';
|
||||
|
||||
import DataTable from '$lib/components/dashboard/transportation/drivers/data-table.svelte';
|
||||
import CreateEditDialog from '$lib/components/dashboard/transportation/drivers/create-edit-dialog.svelte';
|
||||
import { createColumns } from '$lib/components/dashboard/transportation/drivers/columns';
|
||||
|
||||
import { driversApi, type Driver } from '$lib/api/dashboard/a76/drivers';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
let data = $state<Driver[]>([]);
|
||||
let totalItems = $state(0);
|
||||
let pageCount = $state(0);
|
||||
let loading = $state(false);
|
||||
let createDialogOpen = $state(false);
|
||||
|
||||
let currentPage = $derived(Number($page.url.searchParams.get('page')) || 1);
|
||||
let pageSize = 10;
|
||||
|
||||
let searchTransporterKey = $state($page.url.searchParams.get('transporter_key') || '');
|
||||
let searchDriverName = $state($page.url.searchParams.get('driver_name') || '');
|
||||
let searchTimeout: NodeJS.Timeout;
|
||||
|
||||
async function loadData() {
|
||||
if (!companyStore.activeCompany) return;
|
||||
loading = true;
|
||||
try {
|
||||
const params: Record<string, string | number> = {
|
||||
page: currentPage,
|
||||
page_size: pageSize
|
||||
};
|
||||
// Filtros: el backend aún no los soporta; se mantienen en URL para futura implementación
|
||||
// if (searchTransporterKey) params.transporter_key = searchTransporterKey;
|
||||
// if (searchDriverName) params.driver_name = searchDriverName;
|
||||
|
||||
const response = await driversApi.list(companyStore.activeCompany.id, params);
|
||||
|
||||
if (response.data) {
|
||||
data = response.data.items;
|
||||
totalItems = response.data.total;
|
||||
pageCount = Math.ceil(response.data.total / response.data.page_size) || 1;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading drivers:', error);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
if (!browser) return;
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
const url = new URL($page.url);
|
||||
url.searchParams.set('page', '1');
|
||||
if (searchTransporterKey) url.searchParams.set('transporter_key', searchTransporterKey);
|
||||
else url.searchParams.delete('transporter_key');
|
||||
if (searchDriverName) url.searchParams.set('driver_name', searchDriverName);
|
||||
else url.searchParams.delete('driver_name');
|
||||
goto(url, { keepFocus: true, noScroll: true });
|
||||
}, 500);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const _ = { p: $page.url.href, c: companyStore.activeCompany?.id };
|
||||
loadData();
|
||||
});
|
||||
|
||||
const columns = createColumns(loadData);
|
||||
</script>
|
||||
|
||||
<div class="flex h-full flex-col space-y-6 p-8">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold tracking-tight">Conductores</h2>
|
||||
<p class="text-muted-foreground">Gestión del catálogo de conductores</p>
|
||||
</div>
|
||||
<Button onclick={() => (createDialogOpen = true)}>
|
||||
<Plus class="mr-2 h-4 w-4" /> Nuevo Conductor
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2">
|
||||
<Input
|
||||
placeholder="Buscar por clave transportista..."
|
||||
class="h-8 w-[250px] bg-card"
|
||||
bind:value={searchTransporterKey}
|
||||
oninput={handleSearch}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Buscar por nombre..."
|
||||
class="h-8 w-[250px] bg-card"
|
||||
bind:value={searchDriverName}
|
||||
oninput={handleSearch}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if loading && data.length === 0}
|
||||
<div class="flex h-64 items-center justify-center text-muted-foreground">
|
||||
Cargando conductores...
|
||||
</div>
|
||||
{:else}
|
||||
<DataTable {data} {columns} {pageCount} {totalItems} />
|
||||
{/if}
|
||||
|
||||
<CreateEditDialog bind:open={createDialogOpen} onSuccess={loadData} />
|
||||
</div>
|
||||
Reference in New Issue
Block a user