feat(crm): tarifario — editor de cargos adicionales + alta manual de rutas
- Backend: CRUD de cargos (rate_charges) por tarifario. - Frontend: detalle del tarifario con alta manual de rutas (con editor de quiebres para aéreo/LCL) y sección de cargos adicionales (agregar/eliminar). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -21,6 +21,32 @@ class RateChargeDTO(BaseModel):
|
||||
condition: str | None = None
|
||||
|
||||
|
||||
class RateChargeCreate(BaseModel):
|
||||
concept: str = Field(..., max_length=60)
|
||||
charge_type: str = Field("fijo", max_length=20)
|
||||
value: Decimal | None = None
|
||||
condition: str | None = None
|
||||
rate_lane_id: int | None = None
|
||||
|
||||
|
||||
class RateChargeUpdate(BaseModel):
|
||||
concept: str | None = Field(None, max_length=60)
|
||||
charge_type: str | None = Field(None, max_length=20)
|
||||
value: Decimal | None = None
|
||||
condition: str | None = None
|
||||
|
||||
|
||||
class RateChargeResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
rate_sheet_id: int | None
|
||||
rate_lane_id: int | None
|
||||
concept: str
|
||||
charge_type: str
|
||||
value: Decimal | None
|
||||
condition: str | None
|
||||
|
||||
|
||||
# ---------- Rutas ----------
|
||||
class RateLaneBase(BaseModel):
|
||||
origin: str | None = Field(None, max_length=20)
|
||||
|
||||
@@ -14,6 +14,9 @@ from .dto import (
|
||||
CostResult,
|
||||
ImportPreview,
|
||||
RateBreakDTO,
|
||||
RateChargeCreate,
|
||||
RateChargeResponse,
|
||||
RateChargeUpdate,
|
||||
RateLaneCreate,
|
||||
RateLaneResponse,
|
||||
RateSheetCreate,
|
||||
@@ -187,6 +190,56 @@ def delete_lane(
|
||||
service.delete_lane(db, tenant_id, sheet_id, lane_id)
|
||||
|
||||
|
||||
# ---------------- Cargos adicionales ----------------
|
||||
@router.get("/{sheet_id}/charges", response_model=list[RateChargeResponse])
|
||||
def list_charges(
|
||||
sheet_id: int,
|
||||
company_id: int = Query(...),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
tenant_id, _ = _ctx(current_user)
|
||||
service.get_sheet(db, tenant_id, company_id, sheet_id)
|
||||
return service.list_charges(db, tenant_id, sheet_id)
|
||||
|
||||
|
||||
@router.post("/{sheet_id}/charges", response_model=RateChargeResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_charge(
|
||||
sheet_id: int,
|
||||
data: RateChargeCreate,
|
||||
company_id: int = Query(...),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
tenant_id, _ = _ctx(current_user)
|
||||
return service.create_charge(db, tenant_id, company_id, sheet_id, data)
|
||||
|
||||
|
||||
@router.patch("/{sheet_id}/charges/{charge_id}", response_model=RateChargeResponse)
|
||||
def update_charge(
|
||||
sheet_id: int,
|
||||
charge_id: int,
|
||||
data: RateChargeUpdate,
|
||||
company_id: int = Query(...),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
tenant_id, _ = _ctx(current_user)
|
||||
return service.update_charge(db, tenant_id, sheet_id, charge_id, data)
|
||||
|
||||
|
||||
@router.delete("/{sheet_id}/charges/{charge_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_charge(
|
||||
sheet_id: int,
|
||||
charge_id: int,
|
||||
company_id: int = Query(...),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
tenant_id, _ = _ctx(current_user)
|
||||
service.delete_charge(db, tenant_id, sheet_id, charge_id)
|
||||
|
||||
|
||||
# ---------------- Motor de costeo ----------------
|
||||
cost_router = APIRouter(tags=["Tarifario"])
|
||||
|
||||
|
||||
@@ -149,6 +149,60 @@ def delete_lane(db: Session, tenant_id: int, sheet_id: int, lane_id: int) -> Non
|
||||
db.commit()
|
||||
|
||||
|
||||
# ============================================================ Cargos adicionales
|
||||
def list_charges(db: Session, tenant_id: int, sheet_id: int) -> list[RateCharge]:
|
||||
return (
|
||||
db.query(RateCharge)
|
||||
.filter(RateCharge.rate_sheet_id == sheet_id, RateCharge.tenant_id == tenant_id,
|
||||
RateCharge.deleted_at.is_(None))
|
||||
.order_by(RateCharge.concept)
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
def create_charge(db: Session, tenant_id: int, company_id: int, sheet_id: int, data) -> RateCharge:
|
||||
get_sheet(db, tenant_id, company_id, sheet_id)
|
||||
ch = RateCharge(
|
||||
tenant_id=tenant_id, company_id=company_id, rate_sheet_id=sheet_id,
|
||||
rate_lane_id=data.rate_lane_id, concept=data.concept, charge_type=data.charge_type,
|
||||
value=data.value, condition=data.condition,
|
||||
)
|
||||
db.add(ch)
|
||||
db.commit()
|
||||
db.refresh(ch)
|
||||
return ch
|
||||
|
||||
|
||||
def update_charge(db: Session, tenant_id: int, sheet_id: int, charge_id: int, data) -> RateCharge:
|
||||
ch = (
|
||||
db.query(RateCharge)
|
||||
.filter(RateCharge.id == charge_id, RateCharge.rate_sheet_id == sheet_id,
|
||||
RateCharge.tenant_id == tenant_id)
|
||||
.first()
|
||||
)
|
||||
if not ch:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Cargo no encontrado")
|
||||
for field, value in data.model_dump(exclude_unset=True).items():
|
||||
setattr(ch, field, value)
|
||||
db.commit()
|
||||
db.refresh(ch)
|
||||
return ch
|
||||
|
||||
|
||||
def delete_charge(db: Session, tenant_id: int, sheet_id: int, charge_id: int) -> None:
|
||||
from sqlalchemy import func
|
||||
ch = (
|
||||
db.query(RateCharge)
|
||||
.filter(RateCharge.id == charge_id, RateCharge.rate_sheet_id == sheet_id,
|
||||
RateCharge.tenant_id == tenant_id)
|
||||
.first()
|
||||
)
|
||||
if not ch:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Cargo no encontrado")
|
||||
ch.deleted_at = func.now()
|
||||
db.commit()
|
||||
|
||||
|
||||
# ============================================================ Importación Excel
|
||||
# Plantillas por modo: encabezados esperados (orden libre, se detectan por nombre).
|
||||
TEMPLATES: dict[str, list[str]] = {
|
||||
|
||||
@@ -24,6 +24,12 @@ export type RateSheetInput = Partial<Omit<RateSheet, 'id' | 'created_at' | 'upda
|
||||
mode: RateMode; name: string;
|
||||
};
|
||||
|
||||
export interface RateCharge {
|
||||
id: number; rate_sheet_id: number | null; rate_lane_id: number | null;
|
||||
concept: string; charge_type: string; value: number | null; condition: string | null;
|
||||
}
|
||||
export interface RateChargeInput { concept: string; charge_type: string; value?: number | null; condition?: string | null; rate_lane_id?: number | null; }
|
||||
|
||||
export interface ImportPreviewRow { row: number; data: Record<string, unknown>; ok: boolean; warnings: string[]; errors: string[]; }
|
||||
export interface ImportPreview { mode: RateMode; total: number; valid: number; rows: ImportPreviewRow[]; columns: string[]; }
|
||||
|
||||
@@ -61,6 +67,9 @@ export const rateSheetsAPI = {
|
||||
lanes: (id: number, companyId: number) => unwrap<RateLane[]>(api.get(`/v1/crm/rate-sheets/${id}/lanes?${qp(companyId)}`)),
|
||||
addLane: (id: number, data: Partial<RateLane>, companyId: number) => unwrap<RateLane>(api.post(`/v1/crm/rate-sheets/${id}/lanes?${qp(companyId)}`, data)),
|
||||
removeLane: (id: number, laneId: number, companyId: number) => unwrap(api.delete(`/v1/crm/rate-sheets/${id}/lanes/${laneId}?${qp(companyId)}`)),
|
||||
charges: (id: number, companyId: number) => unwrap<RateCharge[]>(api.get(`/v1/crm/rate-sheets/${id}/charges?${qp(companyId)}`)),
|
||||
addCharge: (id: number, data: RateChargeInput, companyId: number) => unwrap<RateCharge>(api.post(`/v1/crm/rate-sheets/${id}/charges?${qp(companyId)}`, data)),
|
||||
removeCharge: (id: number, chargeId: number, companyId: number) => unwrap(api.delete(`/v1/crm/rate-sheets/${id}/charges/${chargeId}?${qp(companyId)}`)),
|
||||
|
||||
/** Descarga la plantilla Excel del modo. */
|
||||
async downloadTemplate(mode: RateMode, companyId: number): Promise<void> {
|
||||
|
||||
@@ -1,29 +1,48 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { ArrowLeft, FileSpreadsheet, Trash2, CheckCircle2 } from '@lucide/svelte';
|
||||
import { ArrowLeft, FileSpreadsheet, Trash2, CheckCircle2, Plus } from '@lucide/svelte';
|
||||
import { page } from '$app/state';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import * as Table from '$lib/components/ui/table';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
import { rateSheetsAPI, type RateSheet, type RateLane } from '$lib/api/crm/rates';
|
||||
import { rateSheetsAPI, type RateSheet, type RateLane, type RateCharge } from '$lib/api/crm/rates';
|
||||
import { crmCatalogs } from '$lib/stores/crm-catalogs.svelte';
|
||||
import { formatDate } from '$lib/components/crm/format';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
const sheetId = $derived(Number(page.params.id));
|
||||
const companyId = $derived(companyStore.activeCompany?.id ?? null);
|
||||
const inputCls = 'rounded-md border bg-transparent px-2 py-1 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring';
|
||||
|
||||
let sheet = $state<RateSheet | null>(null);
|
||||
let lanes = $state<RateLane[]>([]);
|
||||
let charges = $state<RateCharge[]>([]);
|
||||
let loading = $state(false);
|
||||
|
||||
onMount(() => void crmCatalogs.preload(['modo_tarifario', 'tipo_equipo']));
|
||||
const isAir = $derived(sheet?.mode === 'aereo');
|
||||
const isLcl = $derived(sheet?.mode === 'maritimo_lcl');
|
||||
const isFcl = $derived(sheet?.mode === 'maritimo_fcl');
|
||||
const usesBreaks = $derived(isAir || isLcl);
|
||||
const rateUnit = $derived(sheet?.mode === 'aereo' ? 'per_kg' : sheet?.mode === 'maritimo_lcl' ? 'per_wm' : sheet?.mode === 'maritimo_fcl' ? 'per_container' : 'flat');
|
||||
|
||||
// alta de ruta
|
||||
let showLane = $state(false);
|
||||
let lf = $state({ origin: '', destination: '', region: '', equipment_type: '', min_charge: null as number | null, flat_rate: null as number | null, transit_days: null as number | null, notes: '' });
|
||||
let brks = $state<{ from_qty: number | null; rate: number | null }[]>([{ from_qty: null, rate: null }]);
|
||||
// alta de cargo
|
||||
let cf = $state({ concept: '', charge_type: 'fijo', value: null as number | null, condition: '' });
|
||||
let working = $state(false);
|
||||
|
||||
onMount(() => void crmCatalogs.preload(['modo_tarifario', 'tipo_equipo', 'concepto_cargo']));
|
||||
$effect(() => { const cid = companyId, id = sheetId; if (cid && id) void load(cid, id); });
|
||||
|
||||
async function load(cid: number, id: number) {
|
||||
loading = true;
|
||||
try { sheet = await rateSheetsAPI.get(id, cid); lanes = await rateSheetsAPI.lanes(id, cid); }
|
||||
catch (e) { toast.error(e instanceof Error ? e.message : 'No se pudo cargar el tarifario'); }
|
||||
try {
|
||||
sheet = await rateSheetsAPI.get(id, cid);
|
||||
[lanes, charges] = await Promise.all([rateSheetsAPI.lanes(id, cid), rateSheetsAPI.charges(id, cid)]);
|
||||
} catch (e) { toast.error(e instanceof Error ? e.message : 'No se pudo cargar el tarifario'); }
|
||||
finally { loading = false; }
|
||||
}
|
||||
async function setStatus(status: string) {
|
||||
@@ -36,8 +55,60 @@
|
||||
try { await rateSheetsAPI.removeLane(sheet.id, l.id, companyId); await load(companyId, sheet.id); }
|
||||
catch (e) { toast.error(e instanceof Error ? e.message : 'No se pudo eliminar'); }
|
||||
}
|
||||
|
||||
function openLane() {
|
||||
lf = { origin: sheet?.default_origin ?? '', destination: '', region: '', equipment_type: '', min_charge: null, flat_rate: null, transit_days: null, notes: '' };
|
||||
brks = [{ from_qty: null, rate: null }];
|
||||
showLane = true;
|
||||
}
|
||||
async function saveLane() {
|
||||
if (!companyId || !sheet) return;
|
||||
if (!lf.destination.trim()) { toast.error('Indica el destino'); return; }
|
||||
const breaks = usesBreaks
|
||||
? brks.filter((b) => b.rate != null).map((b) => ({ from_qty: b.from_qty ?? 0, rate: b.rate as number }))
|
||||
: [];
|
||||
working = true;
|
||||
try {
|
||||
await rateSheetsAPI.addLane(sheet.id, {
|
||||
origin: lf.origin || null, destination: lf.destination, region: lf.region || null,
|
||||
equipment_type: isFcl ? (lf.equipment_type || null) : null, rate_unit: rateUnit,
|
||||
min_charge: usesBreaks ? lf.min_charge : null,
|
||||
flat_rate: usesBreaks ? null : lf.flat_rate,
|
||||
transit_days: lf.transit_days, notes: lf.notes || null, breaks
|
||||
} as any, companyId);
|
||||
toast.success('Ruta agregada');
|
||||
showLane = false;
|
||||
await load(companyId, sheet.id);
|
||||
} catch (e) { toast.error(e instanceof Error ? e.message : 'No se pudo guardar la ruta'); }
|
||||
finally { working = false; }
|
||||
}
|
||||
|
||||
async function addCharge() {
|
||||
if (!companyId || !sheet) return;
|
||||
if (!cf.concept) { toast.error('Elige el concepto del cargo'); return; }
|
||||
working = true;
|
||||
try {
|
||||
await rateSheetsAPI.addCharge(sheet.id, { concept: cf.concept, charge_type: cf.charge_type, value: cf.value, condition: cf.condition || null }, companyId);
|
||||
toast.success('Cargo agregado');
|
||||
cf = { concept: '', charge_type: 'fijo', value: null, condition: '' };
|
||||
charges = await rateSheetsAPI.charges(sheet.id, companyId);
|
||||
} catch (e) { toast.error(e instanceof Error ? e.message : 'No se pudo agregar el cargo'); }
|
||||
finally { working = false; }
|
||||
}
|
||||
async function delCharge(c: RateCharge) {
|
||||
if (!companyId || !sheet) return;
|
||||
try { await rateSheetsAPI.removeCharge(sheet.id, c.id, companyId); charges = await rateSheetsAPI.charges(sheet.id, companyId); }
|
||||
catch (e) { toast.error(e instanceof Error ? e.message : 'No se pudo eliminar'); }
|
||||
}
|
||||
|
||||
const eq = (c: string | null) => c ? crmCatalogs.label('tipo_equipo', c) : '—';
|
||||
const conceptLabel = (c: string) => crmCatalogs.label('concepto_cargo', c);
|
||||
const breaksTxt = (l: RateLane) => (l.breaks ?? []).map((b) => `${b.from_qty}: ${b.rate}`).join(' · ') || '—';
|
||||
const CHARGE_TYPES = [
|
||||
{ v: 'fijo', l: 'Fijo' }, { v: 'por_kg', l: 'Por kg' }, { v: 'por_guia', l: 'Por guía' },
|
||||
{ v: 'por_contenedor', l: 'Por contenedor' }, { v: 'porcentaje', l: '% sobre tarifa' }
|
||||
];
|
||||
const chargeTypeLabel = (t: string) => CHARGE_TYPES.find((x) => x.v === t)?.l ?? t;
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
@@ -64,12 +135,15 @@
|
||||
</div>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header><Card.Title class="text-base">Rutas ({lanes.length})</Card.Title>
|
||||
<Card.Description>El motor de cotización usa estas rutas cuando el tarifario está <b>activo</b> y vigente.</Card.Description>
|
||||
<Card.Header class="flex flex-row items-center justify-between">
|
||||
<div><Card.Title class="text-base">Rutas ({lanes.length})</Card.Title>
|
||||
<Card.Description>El motor de cotización usa estas rutas cuando el tarifario está <b>activo</b> y vigente.</Card.Description>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onclick={openLane}><Plus class="mr-1 h-4 w-4" /> Agregar ruta</Button>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
{#if lanes.length === 0}
|
||||
<p class="text-sm text-muted-foreground">Sin rutas.</p>
|
||||
<p class="text-sm text-muted-foreground">Sin rutas. Agrega una o importa por Excel.</p>
|
||||
{:else}
|
||||
<div class="overflow-x-auto">
|
||||
<Table.Root>
|
||||
@@ -93,5 +167,87 @@
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header><Card.Title class="text-base">Cargos adicionales ({charges.length})</Card.Title>
|
||||
<Card.Description>Recargos que el motor suma a la tarifa base (combustible, DGR, THC, maniobras…).</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<div class="mb-4 flex flex-wrap items-end gap-2">
|
||||
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Concepto</span>
|
||||
<select class={inputCls} bind:value={cf.concept}><option value="">—</option>{#each crmCatalogs.options('concepto_cargo') as c (c.value)}<option value={c.value}>{c.label}</option>{/each}</select>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Tipo</span>
|
||||
<select class={inputCls} bind:value={cf.charge_type}>{#each CHARGE_TYPES as t (t.v)}<option value={t.v}>{t.l}</option>{/each}</select>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Valor</span><input type="number" step="0.01" class="{inputCls} w-28" bind:value={cf.value} placeholder="monto o %" /></label>
|
||||
<label class="flex flex-1 flex-col gap-1 text-sm"><span class="font-medium">Condición</span><input class={inputCls} bind:value={cf.condition} placeholder="opcional (ej. solo DGR)" /></label>
|
||||
<Button size="sm" onclick={addCharge} disabled={working}><Plus class="mr-1 h-4 w-4" /> Agregar</Button>
|
||||
</div>
|
||||
{#if charges.length === 0}
|
||||
<p class="text-sm text-muted-foreground">Sin cargos adicionales.</p>
|
||||
{:else}
|
||||
<Table.Root>
|
||||
<Table.Header><Table.Row><Table.Head>Concepto</Table.Head><Table.Head>Tipo</Table.Head><Table.Head>Valor</Table.Head><Table.Head>Condición</Table.Head><Table.Head></Table.Head></Table.Row></Table.Header>
|
||||
<Table.Body>
|
||||
{#each charges as c (c.id)}
|
||||
<Table.Row>
|
||||
<Table.Cell class="font-medium">{conceptLabel(c.concept)}</Table.Cell>
|
||||
<Table.Cell class="text-xs">{chargeTypeLabel(c.charge_type)}</Table.Cell>
|
||||
<Table.Cell>{c.value ?? '—'}{c.charge_type === 'porcentaje' ? ' %' : ''}</Table.Cell>
|
||||
<Table.Cell class="text-xs text-muted-foreground">{c.condition ?? '—'}</Table.Cell>
|
||||
<Table.Cell class="text-right"><Button variant="ghost" size="sm" onclick={() => delCharge(c)} aria-label="Eliminar"><Trash2 class="h-4 w-4 text-destructive" /></Button></Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if showLane && sheet}
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" role="presentation" onclick={() => (showLane = false)}>
|
||||
<div class="max-h-[92vh] w-full max-w-xl overflow-y-auto rounded-lg border bg-card p-6 shadow-lg" role="dialog" aria-modal="true" tabindex="-1" onclick={(e) => e.stopPropagation()}>
|
||||
<h3 class="mb-4 text-base font-semibold">Nueva ruta</h3>
|
||||
<div class="grid gap-3 sm:grid-cols-3">
|
||||
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Origen</span><input class={inputCls} bind:value={lf.origin} placeholder={sheet.default_origin ?? 'NLU'} /></label>
|
||||
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Destino *</span><input class={inputCls} bind:value={lf.destination} placeholder="FRA / CNSHA" /></label>
|
||||
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Región</span><input class={inputCls} bind:value={lf.region} /></label>
|
||||
{#if isFcl}
|
||||
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Tipo de equipo</span>
|
||||
<select class={inputCls} bind:value={lf.equipment_type}><option value="">—</option>{#each crmCatalogs.options('tipo_equipo') as t (t.value)}<option value={t.value}>{t.label}</option>{/each}</select>
|
||||
</label>
|
||||
{/if}
|
||||
{#if usesBreaks}
|
||||
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Cargo mínimo</span><input type="number" step="0.01" class={inputCls} bind:value={lf.min_charge} /></label>
|
||||
{:else}
|
||||
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Tarifa</span><input type="number" step="0.01" class={inputCls} bind:value={lf.flat_rate} /></label>
|
||||
{/if}
|
||||
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Tránsito (días)</span><input type="number" class={inputCls} bind:value={lf.transit_days} /></label>
|
||||
</div>
|
||||
|
||||
{#if usesBreaks}
|
||||
<div class="mt-4">
|
||||
<div class="mb-1 flex items-center justify-between"><span class="text-sm font-medium">Quiebres ({isAir ? 'kg' : 'W/M'} → tarifa)</span>
|
||||
<Button size="sm" variant="ghost" onclick={() => (brks = [...brks, { from_qty: null, rate: null }])}><Plus class="mr-1 h-4 w-4" /> Fila</Button>
|
||||
</div>
|
||||
{#each brks as b, i (i)}
|
||||
<div class="mb-1 flex items-center gap-2">
|
||||
<input type="number" step="0.001" class="{inputCls} w-32" bind:value={b.from_qty} placeholder="desde (100)" />
|
||||
<input type="number" step="0.0001" class="{inputCls} w-32" bind:value={b.rate} placeholder="tarifa (1.00)" />
|
||||
<button type="button" class="text-destructive" onclick={() => (brks = brks.filter((_, j) => j !== i))} aria-label="Quitar">×</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<label class="mt-3 flex flex-col gap-1 text-sm"><span class="font-medium">Notas</span><input class={inputCls} bind:value={lf.notes} /></label>
|
||||
|
||||
<div class="mt-5 flex justify-end gap-2 border-t pt-4">
|
||||
<Button variant="outline" onclick={() => (showLane = false)}>Cancelar</Button>
|
||||
<Button onclick={saveLane} disabled={working}>{working ? 'Guardando…' : 'Guardar ruta'}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user