Imported from juancarloszuluagaranzon-afk/sam (
sam-app/.agent/skills/managing-assignments/SKILL.md). Install upstream withnpx skills add juancarloszuluagaranzon-afk/sam --skill managing-assignments. Copyright stays with the author.
Managing Assignments — SAM
Ciclo de vida de una asignación
PENDIENTE → EN_PROCESO → COMPLETADA
↘ PARCIAL (sigue activa) → EN_PROCESO → COMPLETADA
↘ CANCELADA
PENDIENTE / PARCIAL → CANCELADA
- Solo el supervisor u owner puede crear y cancelar asignaciones (cancelar también aplica a PARCIAL)
- Solo el operador puede iniciar (
EN_PROCESO) y finalizar (→COMPLETADAoPARCIAL) COMPLETADAyCANCELADAson inmutables desde la UIPARCIALsigue activa: el operario original puede continuar al día siguiente, otro operario la puede tomar en campo, o el supervisor la puede reasignar desde el modal de detalle de Labores
Estado PARCIAL — reglas operativas
Cuando un operario finaliza con executedArea < area planificada, el finishAssignment decide:
executedArea + ε >= area→COMPLETADA(toggle "100%" o el área ingresada cubre el total)executedArea < area→PARCIAL(la labor sigue activa)
Migración SQL canónica: supabase/migrations/20260527160000_status_parcial.sql — extiende el CHECK constraint de estado para incluir 'PARCIAL'. Aplicada en VPS via Studio SQL Editor (2026-05-27).
Filtros del operador (operatorAssignments en OperatorView.tsx):
- Activas = PENDIENTE + EN_PROCESO + PARCIAL, excluyendo
liberada === true(ver "Liberar un parcial") - Historial = COMPLETADA + CANCELADA + PARCIAL (la PARCIAL sigue activa pero el operario necesita ver su avance)
Al abrir una PARCIAL en el sheet de finalizar, un useEffect pre-llena finishDraft.area con el executedArea previo. Banner amarillo .partial-progress-banner muestra "Acumulado previo: X.XX ha de Y.YY ha".
Métricas con PARCIAL
summarizeAssignments (samApi.ts) y todas las agregaciones por operador/equipo/labor del Resumen suman executedArea de COMPLETADA + PARCIAL. El área hecha cuenta aunque la labor siga abierta. inProgress sigue contando solo EN_PROCESO. completed (conteo) sigue contando solo COMPLETADA.
executionDateKey trata PARCIAL como COMPLETADA (agrupa por finishedAt).
getRemainingArea resta el executedArea de COMPLETADA + PARCIAL del área del maestro — evita oversubscribir trabajo si otro operario toma la PARCIAL.
Reasignación de operario (supervisor)
Desde Labores → click en la card → Editar: aparece un SearchableSelect "Operador (reasignar)". Al cambiar:
- Se actualiza
operador_id+operador_nombreen DB (mapeo ensamApi.updateAssignment) - El status NO cambia (PARCIAL sigue PARCIAL, PENDIENTE sigue PENDIENTE)
- El
executedAreaprevio se preserva — el nuevo operario ve la labor con el banner de continuación - Hint contextual en el form si reasigna una PARCIAL: "El nuevo operario verá esta labor como parcial con X.XX ha ya hechas."
editAssignment en useAssignmentActions.ts valida que el operario destino NO tenga ya una activa con el mismo suerteCode + labor antes de aceptar el cambio.
UpdateAssignmentInput (domain/sam.ts) acepta operatorId y operatorName.
Validación de duplicados (suerte + labor + operario activos)
Tres puntos de chequeo:
useAssignmentForm.createAssignment(supervisor asigna): chequea contra operador 1 y operador 2 si está presente.useFreeFieldForm.takeFreeField(operario toma en campo): chequea contra sí mismo.useAssignmentActions.editAssignment(supervisor reasigna): chequea cuandopatch.operatorIdcambia.
"Activa" = PENDIENTE + EN_PROCESO + PARCIAL. COMPLETADA y CANCELADA no bloquean. [2026-06-24] PENDIENTE ya NO bloquea: se REUSA (ver sección "Reutilizar la línea PENDIENTE original" abajo) — hasActiveDuplicate en useFreeFieldForm/useAssignmentForm ahora solo considera EN_PROCESO/PARCIAL.
Permitido a propósito: asignar la misma labor en la misma suerte a operarios distintos (el supervisor puede repartir trabajo entre dos operarios para acelerar — ver el par operador1/operador2).
Reutilizar la línea PENDIENTE original — NO duplicar programadas [2026-06-24]
Regla de negocio: una suerte+labor nunca debe quedar programada dos/tres veces. Si una labor se abrió y quedó PENDIENTE (incluida una vencida por la regla de 72h — sigue PENDIENTE en la base, solo oculta de Activas), al re-tomarla en campo o re-asignarla (al mismo operario o a otro) se reutiliza esa misma fila, cambiando operario/equipo/supervisor/fecha — NO se crea otra línea. Reasignar la misma programación 10 veces en 10 meses → 1 sola línea, no 10.
COMPLETADA real es "otra situación": una labor que SÍ se trabajó y cerró NO se reusa → crea línea nueva = re-laboreo (preserva el histórico del ciclo anterior). El STATUS separa los dos casos solo.
- Helper único:
findReusableAssignment(assignments, suerteCode, labor, excludeIds?)ensrc/utils/suerteCycle.ts→ líneaPENDIENTE && !startedAt && executedArea===0 && !liberada(elexcludeIdsevita que el par-2 tome la misma que reusó el par-1). useFreeFieldForm.takeFreeFieldyuseAssignmentForm.createAssignment: por cada suerte (y por cada operario del par) deciden reuse-or-create, online (updateAssignment) y offline (outboxUPDATE). Mensaje "N reutilizada(s) de la programación existente".- Al reutilizar se resetea
createdAta hoy (víaUpdateAssignmentInput.createdAt→payload.created_at) → reinicia el reloj de 72h y la labor reaparece en Activas (no queda oculta).dateKeyse recalcula decreated_at. UpdateAssignmentInputganócreatedAt,supervisorId,supervisorName(la reasignación queda bajo el supervisor que la toma, para el scope correcto).updateAssignmentmapeacreated_at/supervisor_id/supervisor_nombre.- El botón manual
reuseExisting(supervisor) sigue existiendo y ahora también resetea fecha + supervisor (consistente con el auto-reuse).
Agrupación por CICLO en avance compartido (isSameCycle, ventana de días)
CRÍTICO: getSuerteProgress, el cap en finishAssignment (suerteExecutedOthers) y los 4 getRemainingArea agrupan el avance de la misma suerte+labor por CICLO, usando isSameCycle(a.dateKey, b.dateKey) de src/utils/suerteCycle.ts (ventana CYCLE_WINDOW_DAYS = 21 días) — NO por dateKey === dateKey exacto.
Por qué NO exacto: el filtro exacto (a.dateKey === assignment.dateKey) rompía las labores tomadas en campo (LIBRE) trabajadas por varios operarios en días distintos. Cada operario crea su propia row con dateKey = día de creación; si Julio toma la suerte el lunes y Rolando el martes, sus dateKey difieren, no se ven entre sí, y la labor nunca cierra aunque entre ambos cubran el área total (caso real: LA ESPERANZA-04U, 18.86 ha = Julio 9.66 + Rolando 9.20, ambos atascados en PARCIAL).
Por qué tampoco sin filtro: sin ninguna separación, una COMPLETADA histórica de un ciclo viejo (ej: DESPEJE en marzo) sumaría al frente operativo actual (DESPEJE en mayo) → "X realizadas · Falta 0" o totales que exceden el área (bug SAN MIGUEL 020).
La ventana resuelve ambos: una colaboración real entre operarios ocurre en días (≤ 21) → mismo ciclo, su avance se suma. Un re-laboreo meses después queda fuera de la ventana → ciclo distinto, no comparte avance. Para los getRemainingArea (que no tienen una asignación "ancla") la ventana se mide contra todayKey — por eso ahora reciben todayKey como último parámetro.
Si necesitas ajustar la tolerancia, cambia solo CYCLE_WINDOW_DAYS en src/utils/suerteCycle.ts (fuente única). No vuelvas a dateKey === dateKey.
Asignaciones "zombie" — filtrar de Activas si remaining = 0
Si la suerte se cierra por trabajo conjunto (otro operario aportó lo suficiente), las asignaciones del mismo ciclo del mismo operario con executedArea parcial pueden quedar huérfanas en Activas con un cap de 0 — el operario no puede agregar nada pero la card sigue ahí confundiendo.
activeAssignments aplica un segundo filter:
- Si
progress.remaining > 0→ muestra (aún hay trabajo) - Si
remaining = 0y statusEN_PROCESO→ muestra igual (operario está adentro, no se la quitamos a medio camino) - Si
remaining = 0y status PENDIENTE/PARCIAL → oculta (suerte cerrada, nada que hacer)
La asignación sigue en DB con su executedArea propio (no se borra ni modifica el status) — preserva atribución para métricas y reportes. El operario la ve en Historial con su aporte real.
Regla de 72h + orden de Activas [2026-06-24]
activeAssignments (OperatorView) aplica además, al final del chain:
- Vencimiento a 72h: una labor creada hace más de 72 h sale de Activas (
now - createdAt > 72h). Excepción:EN_PROCESO(operario adentro) NO se retira. SicreatedAtes inválido, no se vence (visible por seguridad). Es solo filtro de vista — la fila sigue PENDIENTE en DB, por eso se puede REUSAR (ver sección de reuse). ElSTALE_MS = 72*60*60*1000. - Orden: siempre más recientes primero →
.sort((a,b) => b.createdAt.localeCompare(a.createdAt))(ISO ordena cronológico como texto).
Avance compartido entre operarios (multi-operario en la misma suerte)
Cuando el supervisor asigna a OP-A y OP-B la misma labor + suerte, ambos tienen su propia asignación en DB con area = área total de la suerte. Comparten el trabajo en campo. Si OP-A reporta 5 ha de las 10 totales, OP-B debe ver al instante que ya solo le quedan 5 ha por hacer (no 10).
Helper en OperatorView.tsx:
getSuerteProgress(assignment, allAssignments) → {
executedTotal, // suma executedArea de todas las asignaciones COMPLETADA + PARCIAL de la misma suerte+labor (incluida la propia)
sharedExecuted, // executedTotal - ownExecuted (lo aportado por otros operarios)
ownExecuted, // assignment.executedArea
remaining, // max(0, assignment.area - executedTotal)
hasProgress, // executedTotal > 0
hasSharedProgress, // sharedExecuted > 0
}
Esto modifica:
- Card en Activas: si
hasProgress, agrega inline"X.XX realizadas · Falta Y.YY"con la clase.partial-inline(ámbar). NO crea una tercera línea — va en la misma línea de la labor. - Status pill derivado: si DB dice
PENDIENTEperohasSharedProgress, el badge se muestra como "Parcial". El status DB no cambia (sigue PENDIENTE hasta que el operario aporte). - Sheet de detalle: el banner ámbar cambia título según el caso ("Continuando labor parcial" propio vs "Labor compartida con otro operario" ajeno). Mensaje explica cuánto hizo cada uno y cuánto falta.
- Input pre-llenado con
progress.remaining(no solo el propio remaining). - finishAssignment cap: el sessionMax = remaining global de la suerte (no solo el propio). Mensaje específico: "Otro operario ya avanzó en esta suerte. Solo puedes registrar hasta X ha."
Semántica del input en el finish form (DELTA, no TOTAL)
El campo "Ha ejecutadas" cambia su significado según el contexto:
| Caso | El input representa | Al finalizar |
|---|---|---|
| PENDIENTE / EN_PROCESO sin shared progress | Total ejecutado de SU asignación | executedArea = inputValue |
| PARCIAL propia (continuando) | Delta de esta sesión | executedArea = previousOwn + inputValue |
| PENDIENTE con shared progress | Su aporte de esta sesión | executedArea = inputValue (primer aporte) |
| Toggle "100%" (cualquier caso) | n/a | executedArea = ownExecuted + sessionMax |
sessionMax = remaining global de la suerte. El input se pre-llena con sessionMax para que el operario solo confirme si hizo todo lo que faltaba.
Label cambia para PARCIAL/multi-op: "Ha ejecutadas en esta sesión". Para PENDIENTE puro: "Ha ejecutadas".
Cierre por suerte completa (multi-operario)
isFullyDone en finishAssignment ahora considera 3 condiciones:
isComplete(toggle "100%" activado)executedArea propio + ε >= assignment.area(su parte sola cubre el total — caso single-operario)suerteExecutedOthers + executedArea propio + ε >= assignment.area(la suerte completa se cierra con el aporte conjunto)
Sin la condición 3, en multi-operario las dos asignaciones quedarían PARCIAL para siempre aunque la suerte esté operativamente terminada. Con la condición 3, el operario que aporta para cerrar la suerte queda COMPLETADA aunque su parte sea menor al área planificada individual.
Historial del operario incluye PARCIAL
historyAssignments = COMPLETADA + CANCELADA + PARCIAL. La PARCIAL aparece en Activas (para continuarla) Y en Historial (para ver avance del mes). Es decisión consciente — el badge ámbar la diferencia visualmente.
KPIs del Historial:
ha planificadasyha ejecutadassumanCOMPLETADA + PARCIALcompletadas(count) cuenta soloCOMPLETADA; el label muestra(+N parciales)si las hayeficiencia= ejecutadas / planificadas sobre el set ampliado
"Tu jornada" (pestaña Campo):
cerradasexcluye PARCIALha ejecutadasincluye PARCIAL
Liberar (rechazar) un parcial — flag liberada
Un operario puede liberar una labor activa que no va a poder terminar (situación particular). No quiere que le quede "estorbando" en Activas, pero su avance NO se pierde.
Columna DB: asignaciones.liberada boolean NOT NULL DEFAULT false (migración 20260530140000_asignaciones_liberada.sql, aplicar en Studio ANTES del push). Mapeada en mapAssignment (Boolean(row.liberada ?? false)) y updateAssignment (if (input.liberada !== undefined) payload.liberada = input.liberada). En domain/sam.ts: Assignment.liberada? y UpdateAssignmentInput.liberada?.
Flujo:
- Operario → sheet de la labor activa → botón "No puedo continuar — liberar esta labor" (
.release-labor-btn) → modal de confirmación (.release-confirm-card, evita accidentes) →releaseAssignmentenuseAssignmentActions.ts. releaseAssignmenthaceupdateAssignment(id, { liberada: true })— NO cambia el status (un PARCIAL sigue PARCIAL con suexecutedAreaintacto). Maneja offline igual quecancelAssignment.activeAssignments(OperatorView) excluyea.liberada→ sale de las Activas del operario. Sigue en Historial (PARCIAL) con su aporte.- Supervisor la ve en Labores con badge
.liberada-badge("Liberada"). Puede:- Reasignarla: Editar → cambiar Operador.
editAssignmentponeliberada = falseautomáticamente cuandooperatorIdcambia (si no, quedaría oculta también para el nuevo operario). El nuevo operario la ve en SUS Activas y continúa el restante. - Cancelarla: botón "Cancelar labor" (status → CANCELADA) si ya no se hará.
- Reasignarla: Editar → cambiar Operador.
- Operario también puede retomar el restante en Campo. Las tres
hasActiveDuplicate(useFreeFieldForm, useAssignmentForm, editAssignment) excluyenliberadapara no bloquear la re-toma con "ya tienes una activa".
Área TOTAL de la suerte = MAX de áreas del ciclo (re-tomas del restante)
CRÍTICO: getSuerteProgress (OperatorView) y el cap/cierre en finishAssignment calculan el restante contra suerteTotalArea = Math.max(assignment.area, ...áreas del ciclo no-CANCELADA), NO contra assignment.area directo.
Por qué: una RE-TOMA en campo del restante (ej: tras liberar un parcial de 12.20 de 19.52) se crea con area = getRemainingArea = 7.32 (no el área completa). Si getSuerteProgress restara 7.32 - executedTotal(12.20) daría remaining < 0 → 0 y la card desaparecería al instante como "zombie", o el cap del finish quedaría en 0 (el operario no podría registrar nada). Tomando el MAX de las áreas del ciclo recuperamos el área completa real (la primera toma / la ASIGNADA siempre lleva el área completa), y el restante se calcula bien (19.52 - 12.20 = 7.32). En el caso normal (todas las áreas = completa) MAX = assignment.area → sin cambio de comportamiento.
Pestaña Validación (jefe/owner + administración)
Componente src/views/ValidationTab.tsx (usa useAppData() directo, sin props). Tab 'validacion' en SupervisorTab. Acceso: owner (en el menú "Más") y administracion (tab directo). Render gated: (role==='owner'||role==='administracion') && supervisorTab==='validacion'.
Propósito: que jefe/administración validen que TODO lo del Excel paralelo ya está diligenciado en el app mientras prueban el sistema. Decisiones (2026-05-31):
- Regla ×2 (DESPEJE/REENCALLE/REENCALLE V se facturan doble): en el Excel es una marca manual
FACTURA X2(~46 de 4642 filas, NO es factor automático por labor). En el app quedan dos líneas que suman el doble del área. El dashboard NO multiplica por factor: solo SUMA elexecutedArea(las dos líneas ya dan el doble = el "facturar 2"). Por eso el usuario eligió "sumar las 2 líneas, no tocar flujo". - Cruce app ↔ Excel (objetivo real, 2026-06-01): el usuario SUBE su Excel ("Resumen de Labores") y la app compara contra los registros del app. Parser:
import('xlsx')dinámico →XLSX.read(Uint8Array, {type:'array', cellDates:true})→ autodetecta la hoja cuyo header tenga LABOR/HACIENDA/SUERTE/HA (por índice de columna, robusto a reordenar). Agrupa AMBOS lados porgroupKey = normTxt(hacienda) | normSuerte(suerte) | canonLabor(labor)dentro del mes/quincena elegido.normSuertequita ceros a la izquierda si es numérica ("034"=="34");canonLaborunifica alias Excel↔app (REENCALLE↔RENCALLE, REENCALLE V/EN V↔RENCALLE V, DESPAJE↔DESPEJE). Salida en 3 buckets: 🔴 falta en app (en Excel, no en app — lo clave), 🟡 solo en app, 🟢 en ambos (Ha Excel vs Ha app, deben coincidir). Regla ×2 corregida (2026-06-01): NO es por nombre de labor. En el Excel, una línea marcada "FACTURA X2"/"SE FACTURA X2" (detectado escaneando TODAS las celdas de la fila con regex, la columna de nota varía) cuenta su área DOS VECES → así iguala a las dos líneas separadas del app. En el app el ×2 es "data-driven": un grupo suerte+labor cuyoΣ executedArea >= 1.8 × área de la suerte(del maestro por haciendaCode+suerte) — eso marca el badge ×2 y la columna "FACTURA X2" del export. Esto distingue un ×2 real (área doblada) de trabajo compartido entre 2 operarios (suma = 1× área, NO es ×2). Bucket de labor (2026-06-01): para el cruce, DESPEJE/REENCALLE/REENCALLE V se agrupan como UNA unidad por suerte (bucketLabor→'DESPEJE/REENCALLE'), porque el Excel colapsa el par en una línea "DESPEJE X2" mientras el app lo registra como 2 labores separadas (DESPEJE + REENCALLE). Así ambas representaciones suman lo mismo (Excel 3.03×2 = app 3.03+3.03 = 6.06) y cuadran; sin el bucket, COLOMBINA 010 salía partido (DESPEJE en "ambos" con dif y REENCALLE en "solo en app"). FECHA del Excel se parsea de Date real o "M/D/YY" (formato US del archivo). Limitación: el match depende de que el NOMBRE de hacienda coincida (normalizado); si el Excel escribe distinto a la maestra, sale como falta/solo. - Validación = consistencia interna (modo sin Excel cargado): semáforo por registro — 🟢 completa (COMPLETADA con área ejec + horómetro final + operario), 🟡 en curso (EN_PROCESO/PARCIAL), 🔴 incompleta (PENDIENTE o COMPLETADA con campos faltantes). Aprobación va en columna aparte, no en el rojo.
- Export a Excel con
aoa_to_sheet(array-of-arrays) para replicar EXACTO el encabezado de la hoja "Resumen de Labores" (incluye columnas en blanco y "MATAS" duplicada quejson_to_sheetno soporta). Mapeo app→Excel: EMPRESA fijoAGROMORALES, CLIENTE=ingenio (sin prefijo "Ingenio", uppercase), SECTOR=zona, CABO=supervisor (resuelto porusers), HA=executedArea, FACTURA/VALOR/ACTA/etc=vacío (los llena admin en Excel).import('xlsx')dinámico (lazy) como enApp.tsx handleDownloadReport.
WORKFLOW — Secuencia de labores
⚠️ Desde 2026-06-15 los selectores de labor usan el catálogo
labores_catalogo(activeLabores/fieldLaboresdel contexto), NO esta constante.WORKFLOWse conserva SOLO paragetSuggestedLabor/cálculo de progreso y como fallback offline. Ver gotcha del catálogo.
El orden importa. Es la secuencia canónica para una suerte:
export const WORKFLOW = [
'DESPEJE',
'REPIQUE',
'RENCALLE',
'SUBSUELO',
'TRIPLE',
'FERTILIZACION',
'ZANJAS',
]
getSuggestedLabor encuentra la primera labor del WORKFLOW que aún no está COMPLETADA en esa suerte. Úsala para pre-seleccionar la labor en el formulario.
Tomar suerte en campo (LIBRE) — simplificado + cliente/zona en aprobación
[2026-06-01] El operario al "Tomar suerte en campo" YA NO captura Cliente ni Zona (se quitaron del form en OperatorView). La labor LIBRE se crea con cliente=null, zona=null (takeFreeField en useFreeFieldForm). El operario sí elige Ingenio, Hacienda, Suerte, Labor, Equipo y Supervisor (el supervisor es lo que la scopea).
El supervisor diligencia cliente+zona al APROBAR: el botón "Aprobar" de la lista de Labores, si la labor es kind==='LIBRE' y le falta cliente o zone, abre un modal (approveTarget en SupervisorView) que OBLIGA a elegir Cliente (ingenios/proveedores) + Zona (Norte/Sur) antes de aprobar (botón deshabilitado hasta tener ambos). approveAssignment(assignment, { cliente, zone }) → decideApproval mezcla esos campos en el mismo update; updateAssignment mapea cliente y zona. Las ASIGNADAS sí traen cliente/zona (el form de asignar los exige), así que aprueban directo sin modal.
Scoping (ya existía): scopedAssignments filtra a.supervisorId === session.id para rol supervisor → cada supervisor solo ve las de campo donde lo eligieron + las que él asigna. Owner/administración ven todas. CreateAssignmentInput.cliente es opcional; UpdateAssignmentInput acepta cliente y zone.
Tipos de registro (kind)
| kind | Creado por | Descripción |
|---|---|---|
'ASIGNADA' |
Supervisor u Owner | Asignación formal a un operador |
'LIBRE' |
Operador | El operador toma una suerte por iniciativa propia |
Roles y acceso
type Role = 'owner' | 'supervisor' | 'operador'
- owner / supervisor: tabs
resumen,asignar,labores,equipos,tablero - operador: tabs
activas,campo,historial
Los operadores solo ven sus propias asignaciones. El matching es triple para cubrir filas históricas:
assignmentOperatorId === sessionId ||
(assignmentOperatorId === '' && assignmentOperatorName === sessionName) ||
assignmentOperatorName === sessionName
Haciendas y suertes (maestro)
haciendaCodees numérico (ej:103,105,108,126)suertees string con ceros a la izquierda (ej:'0001','0002')suerteCode="${haciendaCode}-${suerte}"(ej:"103-0001")- El área viene en hectáreas con 2 decimales
Métricas del dashboard (summarizeAssignments)
Solo cuenta asignaciones del día actual (dateKey === todayKey) y excluye CANCELADA:
plannedArea = suma de area de todas las no-canceladas de hoy
executedArea = suma de executedArea de las COMPLETADAS de hoy
completion = Math.round((executedArea / plannedArea) * 100)
inProgress = count de EN_PROCESO de hoy
Inicio de labor — validación de equipo
Al iniciar (handleStartAssignment), el equipo DEBE estar en el catálogo equipment. Si el operador no selecciona uno, se usa en cascada:
startEquipmentDrafts[assignment.id]assignment.equipmentCodesession.equipmentCode
Si ninguno resuelve a un equipo válido → error, no iniciar.
Finalización de labor
executedArea viene del draft del operador. Si no ingresó nada, default al area planificada:
const executedArea = Number(draft?.area ?? assignment.area)
Modal de detalle de labor (AssignmentDetailModal)
Componente compartido en src/components/AssignmentDetailModal.tsx. Recibe assignment: Assignment | null y onClose. Renderiza secciones read-only, todas hide-if-empty:
- Header: hacienda + "Suerte N", status pill + kind badge (Programada / Campo libre)
- Labor
- Áreas (ejecutada, planificada, % cumplimiento, barra de progreso)
- Personas y equipo
- Tiempos (programada / creada / iniciada / finalizada)
- Horómetros (solo si hay valores)
- Contexto (zona, cliente — solo si hay)
- Aprobación (solo si APROBADA o RECHAZADA)
- Notas (solo si hay texto)
Actualmente lo abre EntityHistoryModal al hacer click en una fila .movement-row--clickable del listado del histórico. Se renderiza como sibling dentro del overlay del modal padre (no portal). Ver gotcha sobre bubbling.
Si exponés un campo nuevo en Assignment que un supervisor quiera ver, agregalo aquí — es el render path único para detalle desde el modal histórico.
Scope por supervisor: Reporte y Resumen consistentes (id, no nombre)
Tanto el Resumen (scopedAssignments, SupervisorView) como el Reporte (filteredReport, App.tsx) filtran supervisorId === session.id cuando role === 'supervisor' (owner/admin/soporte ven TODO). Antes el Reporte mostraba todo a un supervisor → inconsistente con su Resumen (2026-06-23). El agrupamiento por persona es por id, nunca por nombre.
⚠️ Si una labor aparece en el Reporte pero NO en el Resumen (o viceversa) para un supervisor: casi seguro su supervisor_id no es el del supervisor logueado — típicamente por un usuario duplicado con el mismo nombre pero id distinto (caso JULIO CESAR NIÑO U033/U040). Ver el gotcha en managing-supabase (consolidar al id real + índice único app_usuarios_nombre_activo_uniq + guard anti-duplicados en el form).
Filtro de búsqueda en secciones del Resumen
Pestaña Resumen tiene un <input className="user-search-input" type="search"> arriba del grid de cards en las secciones Por Operador y Por Equipo. Estados independientes (summaryOperatorSearch, summaryEquipmentSearch). Memos filteredOperators / filteredEquipment filtran case-insensitive por .name DESPUÉS del agrupamiento — no recalcula métricas globales ni la sección "Por Labor".
Empty state contextual:
- Filtro vacío + sin datos → "Sin labores en el periodo seleccionado."
- Filtro con texto + 0 matches → "Sin coincidencias."
Reutilizable para cualquier sección con grid de cards que necesite filtro por nombre.
Filtros del Tablero — mes, zona, ingenio
El tablero tiene tres filtros combinados que se aplican simultáneamente:
- Mes (
tableroMonth): default = mes actual America/Bogota. FiltratableroAssignmentspora.dateKey.startsWith(YYYY-MM). - Zona (
tableroZone):TODAS / NORTE / SUR. FiltratableroAssignmentspora.zone. - Ingenio (
tableroIngenio, agregado 2026-05-11):TODOS / risaralda / pichichi / mayaguez / san_carlos / riopaila. FiltraprogrammedSuerteRows(notableroAssignments) porrow.ingenio_id === tableroIngenio.
Diseño: los filtros mes/zona se aplican sobre tableroAssignments. El filtro de ingenio se aplica sobre programmedSuerteRows (las filas a renderizar). Como las celdas WORKFLOW se llenan con tableroAssignments.find(... === suerteCode), filtrar las filas alcanza para que las celdas solo muestren labores de las suertes visibles. No hay inconsistencia.
Estados viven en App.tsx y se pasan como props a SupervisorView. La lista INGENIOS ya está hardcodeada arriba de SupervisorView.tsx — reutilizar, no duplicar.
Etiquetas de estado en EntityHistoryModal y AssignmentDetailModal
En estos dos componentes específicos, getStatusMeta(assignment) usa "Programada" para status === 'PENDIENTE' (en lugar del "Pendiente" del resto de la app). Decisión textual del usuario (2026-05-11): "completada, parcial o programada" como los tres estados que quería ver en el modal histórico.
La regla "Parcial" (COMPLETADA && executedArea > 0 && executedArea < area) es idéntica a la de SupervisorView/OperatorView — solo cambia el label de PENDIENTE.
Dashboards/KPIs abren en la QUINCENA ACTUAL [2026-06-24]
Todos los dashboards de indicadores arrancan filtrados en la quincena en curso (no "todo el mes" ni la quincena pasada). Helper único: currentQuincena(todayKey) en EntityHistoryModal.tsx → 'PRIMERA' (día 1–15) | 'SEGUNDA' (16–fin). Se usa como default del state, sigue siendo cambiable a otros períodos por el usuario.
| Módulo | State | Archivo |
|---|---|---|
| Resumen (supervisor/owner) | summaryQuincena |
SupervisorView |
| Reporte | reportFilters.period |
App.tsx |
| Realizadas | dateSeg |
RealizadasTab |
| Planilla | planillaQuincena |
PlanillaTab (ya lo hacía; unificada al helper) |
| Historial del operario | historyMonth + historyPeriod |
App.tsx (mes actual + Q1/Q2 según el día) |
Historial del operario [2026-06-24]: SIEMPRE abre en mes actual + quincena en curso. Se ELIMINÓ la persistencia en localStorage (HISTORIAL_PREF_KEY/sam:historial-operario-pref, lazy-init + efecto de persistencia) y el useEffect de auto-salto al "mes más reciente con datos" (contradecía "siempre la quincena actual"). historyMonths ahora siempre incluye el mes actual (todayKey.slice(0,7)) para que sea opción aunque esté vacío. Si el corte actual no tiene labores, se ve vacío y el operario navega manualmente. historyPeriod sigue siendo 'Q1'|'Q2'|'MES' (el usuario puede cambiar a mes completo).
NO se aplicó a Validación (cola de aprobaciones) — ahí el default amplio es deseable (ocultar la quincena anterior escondería pendientes que sí se necesitan ver).
Registro rápido de labor REALIZADA por el supervisor [2026-06-29]
Para el ~5% de operarios poco afines a la tecnología, el supervisor anota lo que hicieron desde Asignar → "✓ Registrar labor realizada" (RegistrarLaborModal, autónomo vía useAppData). Una sola pantalla: operario, tipo de cliente, ingenio, hacienda, suerte, labor, equipo, horómetro inicial+final (opcionales), hectáreas. Supervisor y zona NO se piden — salen del supervisor logueado (users.find(session.id).zona).
- Crea la labor en UN paso vía
samApi.registrarLaborRealizada(input): INSERT que naceestado=COMPLETADA,aprobacion=APROBADA(el supervisor la respalda),area_asignada = area_realizada = hectáreas,fecha_inicio = fecha_fin = now,tipo_registro='ASIGNADA'. - Equipo se autocompleta con el del operario (
operator.equipmentCode), editable. - Toggle "Hizo el 100% de la suerte" (APAGADO por default): el área de la suerte se muestra siempre como referencia (recuadro
suerte-area-info); encender el toggle pone las hectáreas = área completa y bloquea el campo; apagado = editable (parcial). - Online-only (si
!isOnline, pide conexión). Tras crear:setAssignments(prev => [created, ...prev])+db.assignments.put.
Editar el ESTADO de una labor desde el Reporte [2026-06-29]
El modal Editar del Reporte (selectedLabor + editLaborDraft en SupervisorView) ahora incluye un selector Estado: Programada/Pendiente · Laborando · Parcial · Terminada. Al guardar (handleEditAssignment), el estado define fechas, área ejecutada y aprobación de forma coherente:
| Cambia a… | aprobación (si el estado CAMBIÓ) | fechas | executedArea |
|---|---|---|---|
| COMPLETADA / PARCIAL | → PENDIENTE (cae en la bandeja del supervisor dueño) | fija finishedAt (día elegido o now) |
ha ingresadas, o area si quedó vacío |
| EN_PROCESO | → APROBADA | fija startedAt, limpia finishedAt |
conserva |
| PENDIENTE | → APROBADA | limpia inicio y fin | 0 (no cuenta avance) |
EditPatch(useAssignmentActions) ganóapproval/approvedBy/approvedAt(fluyen aupdateAssignment). La aprobación solo se toca si el estado cambió — editar otros campos no saca de la bandeja lo ya aprobado.- Fijar bien
finishedAt/startedAtes CRÍTICO:executionDateKeyagrupa por esas fechas → sin ellas la labor caería en el día equivocado en Planilla/Reporte/Historial. - Bordes conocidos: (1) revertir a Programada una labor creada hace +72h → no sale en Activas del operario (regla 72h), sigue en el sistema; (2) pasar a estado activo cuando el operario ya tiene esa suerte+labor activa puede duplicar la tarjeta (la validación de duplicados solo corre al cambiar de operario).
Barras de búsqueda (patrón user-search-input) [2026-06-29]
Listas con búsqueda libre que filtra SOLO la vista (no los KPIs): Historial del operario (historySearch en OperatorView, filtra visibleHistoryItems por hacienda/suerte/labor + nombre de novedad) y "Últimos movimientos" del supervisor (movSearch en SupervisorView, filtra visibleRecent por hacienda/suerte/labor/operario/equipo). Mismo input user-search-input del Resumen. Empty state contextual: "Sin coincidencias…".
Correcciones de la auditoría integral [2026-07-05]
Barrido con 6 agentes (correctitud, seguridad, integridad, sync, performance, calidad). Fixes de correctitud aplicados — NO revertir:
- Doble-conteo del área CORREGIDO en el Resumen/dashboard.
summarizeAssignments(samApi) ysummaryMetrics(SupervisorView) sumabana.areade TODAS las filas → el split de cruce-de-día y el multi-operario (varias filas con el área COMPLETA de la misma suerte) inflaban el planificado (bug de facturación). AhoraplannedAreadeduplica porsuerteCode|labortomando el MAX.executedAreaSÍ se suma (cada aporte real). La Planilla ya lo hacía; esto lo alineó. (Pendiente menor: las cards "Por Operador/Labor/Equipo" pueden tener aún un sobre-conteo en planificado.) finishAssignment: el cap final usasuerteTotalArea, noassignment.area(useAssignmentActions.ts:282). Antes truncaba el área en una RE-TOMA del restante (fila con área reducida 7.32) → perdía el acumulado (12.20+7.32→7.32).getSuerteProgresscuenta EN_PROCESO solo de la PROPIA asignación (a.id === assignment.id). Antes el EN_PROCESO ajeno (área "en vuelo") cerraba prematuramente la card de un tercero (zombie).decideApprovalNO pisa la zona con null: solo seteacliente/zonesi el supervisor los diligenció (antes el spread crudo deextraconzone:nullborraba una zona válida → la labor caía de los filtros del Tablero). También estampaeditadoPor.- Registro rápido: avisa si el área excede lo ya ejecutado de la suerte (aviso cliente antes del trigger de BD) y crea traza en
labor_sesiones(antes no aparecía en horas-máquina). - [2026-07-06] TOPE de área ejecutada ≤ área de la suerte — blindaje en 4 capas (NO revertir). El área ejecutada NUNCA puede superar el área de la suerte (se paga por ejecutada → ejec>plan = sobrepago/sobrefacturación; caso real: DESPEJE 25A Miraflores ejec 4.00 sobre plan 2.00 = 200%). Capas: (1)
finishAssignmenttopa asuerteTotalArea; (2)RegistrarLaborModalavisa/bloquea; (3)editAssignmentahora topa (useAssignmentActions.ts— antes solo validaba>=0, ERA EL HUECO); (4) trigger BDasignaciones_cap_area(mig.20260706120000): el previo hacíareturn NEWsi la suerte no estaba en el maestro (dejaba pasar cualquier área) → ahora, sin maestro, usa como tope elarea_asignadaMAX de la suerte+labor en el ciclo. Cubre ASIGNADA y LIBRE por igual:mapAssignmentPayloadllena los mismos campos para ambas, y el trigger SUMA las dos juntas (filtra porsuerte+labor+nombre_hacienda+ciclo+estado, NO portipo_registro) contra el área de la suerte. Regla viva: ver [[feedback-test-programadas-campo]]. mapRoleúnico (samApi): el mapeo rol DB→app estaba duplicado enloadAppUsersyappLogin, yappLoginomitíasupervisor_insumos→ ese usuario entraba degradado a operador. Fuente única ahora.
Facturación — Área facturada [2026-07-05]
Administración asigna un N° de factura a las labores YA realizadas. Columna asignaciones.factura_numero text (mig. 20260705150000, idempotente, sin FK; factura_numero no vacío = facturada). Assignment.facturaNumero (string | null), UpdateAssignmentInput.facturaNumero, EditPatch.facturaNumero. updateAssignment mapea factura_numero (|| null para desfacturar).
- Asignar factura: (1) Vista dedicada
FacturacionTab(tab'facturacion', menú Más, owner/administración) — lista las realizadas del período con segmento Sin facturar / Facturadas / Todas, búsqueda y selección múltiple → asigna un N° en lote víasetFacturaBulk(ids, num, editadoPor)(o "Desfacturar"). (2) También desde el modal Editar del Reporte/Labores (campo "N° de factura" →handleEditAssignment({ facturaNumero })). En el Excel del Reporte hay columna Factura. - KPI "Área facturada":
summarizeAssignmentsdevuelvebilledArea(ΣexecutedAreade COMPLETADA/PARCIAL confacturaNumero). Aparece en la franja "Hoy"/Reporte junto a "Ha ejecut.", ambas destacadas (claseday-status-item--emph, número más grande) y facturada en morado (--billed). El Reporte lo recalcula inline (facturada).
Novedad "Máquina varada" (MV) [2026-06-30]
Tipo de novedad MV = "Máquina varada" (día no trabajado por daño de la máquina en campo). Agregado a NovedadTipo/NOVEDAD_TIPOS/NOVEDAD_LABEL/NOV_ICON (🚜) + color .planilla-nov--mv (naranja-rojo) + leyenda. Aparece como botón automático en la Planilla (clic en el nombre del operario) y en "Registrar novedad" del operario. Sin migración (operario_novedades.tipo es texto libre).
⚠️ OBSOLETO desde el 18-ago-2026 — ya NO se agrega una novedad tocando código.
Antes había que tocar seis sitios (NovedadTipo, NOVEDAD_TIPOS, ALL_NOVEDAD,
NOVEDAD_LABEL, NOV_ICON, la leyenda y el CSS .planilla-nov--xx) y desplegar.
Hoy las crea administración desde Más → 🏷️ Novedades de la planilla. Ver la
sección "Novedades dinámicas" más abajo.
Sesión julio 2026 — ciclo de vida, "Labores a facturar", rendimiento (NO revertir)
-
[2026-07-08] Prioridad a lo CERRADO + retención automática. El Reporte abre por defecto en filtro "Cerradas" (
reportFilters.estado='CERRADAS'en App.tsx →status in (PARCIAL,COMPLETADA); selector con Cerradas/Completada/Parcial/Pendiente/En proceso/Cancelada;''=Todos). Retención (func BDsam_run_retention, mig.20260708130000): Nivel 1 cancelaPENDIENTEyEN_PROCESOconarea_realizada=0y +3 días → CANCELADA; Nivel 2 borra CANCELADA conarea_realizada=0y +3 días. NUNCA toca COMPLETADA/PARCIAL ni EN_PROCESO con avance real (guardaarea_realizada=0). La dispara owner/admin 1×/día desde el cliente (throttle localStoragesam-retention-lasten AppDataContext) + opcional pg_cron. Umbrales elegidos por el usuario: cancelar 3d, purgar 3d. -
[2026-07-11] "Labores a facturar" = aprobación solo de lo CERRADO. Nav/pestaña "Aprobar" → "A facturar" (heading "Labores a facturar",
supervisorTab='aprobaciones'). Regla: solo PARCIAL/COMPLETADA entran a la bandeja. Bug de raíz: la toma en campo (useFreeFieldForm) nacíaapproval='PENDIENTE'→ labores no cerradas cargaban "por aprobar". Fix: la toma en campo ahora naceapproval='APROBADA'(6 sitios); el "pendiente de aprobación" lo dispara SOLO el cierre (finishAssignment) o editar-estado→Terminada/Parcial.pendingApprovalsfiltraapproval==='PENDIENTE' && status in (PARCIAL,COMPLETADA). -
[2026-07-14] RECHAZAR = fuera de toda parte + auditoría. Al rechazar en "A facturar",
decideApprovalcon RECHAZADA ahora también ponestatus='CANCELADA'→ la labor sale de TODO conteo de área (los que ya filtran CANCELADA) y NUNCA cuenta como área realizada. Se conserva la fila conapproval=RECHAZADA;getStatusMeta(OperatorView+SupervisorView, Pick incluyeapproval) la muestra "Rechazada" (no "Cancelada") = auditoría. También:rendimientotopa a COMPLETADA/PARCIAL; Excel ('Área Ejec.') solo COMPLETADA/PARCIAL (else vacío — antes mostraba executedArea>0 y colaba las rechazadas). Datos viejos: normalizar 1 vezupdate asignaciones set estado='CANCELADA' where aprobacion='RECHAZADA' and estado in ('COMPLETADA','PARCIAL')(se hizo: 16 filas). La retención NO las purga (area>0) → quedan como auditoría. Editar habilitado a supervisores en el Reporte (columna Acciones concanEditAssignments; Eliminar sigue solo owner/admin). -
[2026-07-12] Rendimiento/productividad del operario (motivación). Meta ha/día por labor (
labores_catalogo.meta_ha_dia, editable en línea en Catálogos→Labores;Labor.metaHaDia;loadLabores/create/updateusanselect('*')). KPI en OperatorView (tab Activas), 100% cliente desdequincenaHistory+metas: % quincenal = "jornadas cumplidas" Σ(ejec_labor/meta_labor) / días hábiles transcurridos (sin domingos); indicador diario = promedio ha/día (ha_quincena/días trabajados) + último día trabajado, vsmotivacion.meta_dia_ref(default 15, plano) — aplica aunque no haya metas por labor. Felicitación configurable (tablamotivacion, mig.20260712120000/...130000): mensaje+imagen/GIF (bucketavatarsprefijomotivacion/, máx 3MB)+umbral, editable en Catálogos→🏆 Motivación (MotivacionTab, owner/admin) con vista previa; se muestra al operario cuandopct>=umbral. Ver [[project-rendimiento-operario]]. -
[2026-07-08] KPIs de área del Resumen clicables →
AreaDetailModal. Las tarjetas "HA PLANIFICADAS"/"HA EJECUTADAS" del Resumen abren un modal con los registros que COMPONEN el número (búsqueda + filtros hacienda/operador + total). Planificadas = dedup por suerte+labor (MAX, igual queplannedArea); Ejecutadas = COMPLETADA/PARCIAL con fallback.
🔴 [2026-07-15] Incidente de REPUTACIÓN: "parece ejecutada estando pendiente" (NO revertir)
Reclamo grave del cliente (perdía confianza; el dueño llamó molesto). Investigado con agentes. Dos problemas distintos que se confundían:
A) Display: una PENDIENTE mostraba el PLANIFICADO como ejecutado (7.49 / 7.49 en una tomada en campo sin iniciar). La tarjeta de Labores hacía displayed = (COMPLETADA||PARCIAL) && exec>0 ? exec : area → el else caía a area. Fix (commit a24bbaf): helper único areaEjecutadaVisible(a) en src/utils/suerteCycle.ts → cerrada: exec>0?exec:area; NO cerrada: exec>0?exec:0 (nunca el planificado). Aplicado en tarjeta Labores (SupervisorView ~2857, formato ejec / asignada siempre, denominador maestroRow?.area ?? assignment.area), "A facturar" (~2962), EntityHistoryModal (~215), export ValidationTab (~344). Toda vista nueva con área ejecutada DEBE usar el helper. Verificado con agente: los 6 puntos de KPI/pago (Resumen, summarizeAssignments, Excel, Planilla, OperatorView, Facturación) ya filtraban bien por COMPLETADA/PARCIAL → nunca se pagó de más; era solo DISPLAY.
B) Datos anómalos PENDIENTE + area_realizada > 0 (7 filas reales). Causa trazada: updateAssignment escribe columnas independientes (samApi ~2076 estado, ~2079 area_realizada). El culpable: AssignmentDetailModal.handleSave (~111-128) arma un patch diferencial que manda executedArea pero nunca status; se llega desde EntityHistoryModal (que lista PENDIENTE/EN_PROCESO) → un supervisor corrige "Hectáreas ejecutadas" sin cerrar la labor → queda PENDIENTE con área. (El editor de estado de SupervisorView es SEGURO: manda ambos y limpia executedArea=0 al pasar a PENDIENTE. Reuso/create/start también seguros. EN_PROCESO + área>0 es LEGÍTIMO: PARCIAL reabierta conserva el avance.)
Fix (commit 74ad052): guarda de coherencia estado↔área centralizada en editAssignment (useAssignmentActions, antes del write): si el RESULTADO tendría executedArea>0 y status==='PENDIENTE' → se promueve a COMPLETADA (si cubre el área, +0.001) o PARCIAL, y se estampa finishedAt si falta. No toca EN_PROCESO.
Lección de la corrección de datos: al normalizar las 7 se usó fecha_fin = coalesce(fecha_fin, now()) → como tenían fecha_fin=null, quedaron con fecha de HOY y saltaron a la quincena actual (el cliente reclamó: "se hicieron pero no en esta quincena"). Sus created_at eran de junio y fecha_inicio=null. Corregido con fecha_fin = created_at. ⚠️ Al normalizar estados masivamente NUNCA usar now() para fecha_fin — usar coalesce(fecha_inicio, created_at), que es la fecha real de origen (un UPDATE de estado NO toca created_at/fecha_inicio, así que la fecha original siempre se puede recuperar sin backup).
[2026-07-15] Permisos por rol (commit d73890f)
- Ventana de 36 h para aprobar:
APROBACION_HORAS = 36+horasDesdeCierre(a)(ref:finishedAt ?? updatedAt ?? createdAt) en SupervisorView. En "A facturar": si pasaron +36 h desde el CIERRE, el supervisor ve chip⏳ Vencida · solo administracióny el botón Aprobar disabled; administración y owner sí pueden. Decisión del dueño: que las aprobaciones no se queden colgadas. [2026-07-17] Rechazar TAMBIÉN escala: pasadas las 36 h el supervisor no puede Aprobar NI Rechazar — toda la decisión pasa a administración/dueño (ambos botones disabled con el mismo gatepuedeAprobar). - "+ Nueva suerte" solo administración/dueño (toca el MAESTRO): quitado de los 3 sitios — modal "Tomar suerte en campo" del operario (
OperatorView~2040), form Asignar (SupervisorView~1740), pestaña Maestros (MaestrosTab~183). - Eliminar en el Reporte: ahora también supervisores (antes solo owner/admin). La columna Acciones ya se mostraba con
canEditAssignments; se quitó el gate extra del botón Eliminar. Sigue con confirmación (borrado permanente).
Gotchas
-
[2026-07-15] ⚠️ La auditoría SOLO tiene lo posterior a su instalación.
asignaciones_auditoria(trigger, mig.20260630130000) no puede decir quién creó/editó algo anterior. En el incidente de las 7 filas, el query de auditoría devolvió solo nuestra corrección de hoy — sin "Creación" ni "Edición del área" → eran legacy. No prometer trazabilidad retroactiva. Para datos viejos, las pistas son del propio registro:created_at,fecha_inicio,tipo_registro(LIBRE=el operario la tomó / ASIGNADA=un supervisor la creó),operador_nombre,editado_por(último editor). Query de auditoría (ojo con los tipos):asignaciones.ides uuid yasignaciones_auditoria.asignacion_ides text → el join necesita cast:join asignaciones s on s.id::text = au.asignacion_idyleft join app_usuarios u on u.id::text = au.editado_por(u.nombre_completo).editado_pornull se muestra como "sistema" (nombreUsuario, SupervisorView ~576) — pasa en las tomas en campo porquemapAssignmentPayloadNO estampaeditado_poral crear (gap conocido: la Creación de ASIGNADA/LIBRE sale sin autor). -
[2026-07-06] ⚠️ RECURRENTE: agregar una columna que el cliente ESCRIBE rompe TODA edición si la migración no se corrió (
Could not find the 'X' column ... in the schema cache, PostgREST 42703). Pasó coneditado_pory otra vez confactura_numero. Dos defensas: (1) correr la migración ANTES/junto con el deploy (siempre, para toda columna nueva de escritura); (2) en el patch de edición, mandar la columna opcional SOLO si cambió — así una edición normal (estado/área) no depende de esa columna (ej.facturaNumeroen el save handler del Reporte solo se incluye sieditLaborDraft.facturaNumero !== selectedLabor.facturaNumero).editado_porsí se manda siempre (auditoría) → su migración ya está corrida. Al agregar una columna nueva de escritura, aplica el patrón (2) para columnas secundarias. -
[2026-07-06] ⚠️ SIEMPRE probar la edición/flujos en labores PROGRAMADAS (ASIGNADA) y TOMADAS EN CAMPO (LIBRE). El modal de edición, el cambio de estado, la reutilización de línea y las guardas de duplicados no distinguen por
kind, pero los dos tipos llegan por caminos distintos (asignar vs takeFreeField) con datos distintos (cliente/zona nulos en LIBRE hasta aprobar, etc.). Un cambio eneditAssignment/updateAssignment/guardas debe verificarse en AMBOS. Regla del usuario (repetida). -
[2026-07-06] ✅ DECIDIDO: "ha ejecutada" =
executedArea > 0 ? executedArea : area(fallback), consistente en TODA la app. Regla de negocio confirmada por el dueño: a los operarios se les PAGA a razón del área de la labor COMPLETADA aunque no se haya capturado elexecutedArea(una COMPLETADA = se hizo la labor → cuenta su área planificada). Por eso el número operativo es el 266 (fallback), no el 240 (crudo). El descuadre reportado era: pantalla=266 pero el Excel exportaba crudo (240) → misma vista, dos números.- Fix: el Excel (
App.tsxhandleDownloadReport, columna "Área Ejec.") ahora usa el MISMO fallback → Excel = pantalla = 266. La columna "ÁREA" mixta del Reporte se separó en dos: "Ha plan." (a.area) y "Ha ejec." (executedArea>0?executedArea:areapara COMPLETADA/PARCIAL,—para el resto) —SupervisorView.tsxtabla del Reporte. - Regla: TODA vista que sume/ muestre ejecutado usa
executedArea > 0 ? executedArea : areapara COMPLETADA/PARCIAL. Vale para KPIs (Resumen/Hoy/Reporte/Facturación), tablas y Excel. (Ya estaba así en pantalla desdeee4080f; faltaba el Excel + separar la columna.) - Matiz técnico (documentado, no bloquea): en trabajo compartido, un 2º operario puede cerrar al 100% una suerte que otros ya hicieron → su fila queda
area=0; con el fallback cuenta su área planificada. El dueño acepta esto porque así se paga. Si en el futuro se quiere el número "neto por suerte" (sin doble), sería otra métrica aparte. Ver [[project-facturacion-240-266]].
- Fix: el Excel (
-
[2026-06-18] Aprobación OBLIGATORIA al finalizar (asignadas y de campo). Antes solo las LIBRE pedían aprobación; las ASIGNADAS nacían
APROBADAy nadie revisaba el área → facturación recibía áreas que no cuadraban. Fix:finishAssignment(useAssignmentActions) ahora poneapproval: 'PENDIENTE'en elfinishPayload(y en el path offline) → TODA labor finalizada (parcial o completa) vuelve a "por aprobar".decideApprovalse relajó: aprueban el supervisor asignado O owner/administración. Bandeja: pestaña'aprobaciones'(pendingApprovals=scopedAssignmentsconapproval==='PENDIENTE'y status COMPLETADA/PARCIAL), botón nav ✔ Aprobar con badge rojo (.nav-badge, pulso.has-pending) + banner.mini-banner--approveen Labores. Aprobar/Rechazar reusahandleApproveAssignment/handleRejectAssignment(LIBRE sin cliente/zona abre el modalapproveTarget). Los dashboards/Planilla siguen sumando área independiente de la aprobación (el estado es solo gate/flag; si piden "facturar solo aprobadas" hay que filtrar porapproval==='APROBADA'). -
[2026-06-18] Novedades del operario → Planilla. Tabla
operario_novedades(ver managing-supabase), tiposV/T/NP/D/P/C(NOVEDAD_TIPOS/NOVEDAD_LABELen samApi). El operario las reporta con un botón "Registrar novedad" que abre un modal (tipo + rango de fechas). El propietario también desde la Planilla al oprimir el nombre del operario. En la celda de la Planilla la novedad muestra la LETRA (color de texto por tipo) y reemplaza las ha.setOperarioNovedades(opId, fechas[], tipo)hace upsert por día;clearOperarioNovedadesborra. -
[2026-06-18] Planilla: muchas mejoras. (1) Muestra TODOS los operarios del catálogo (
operators) aunque no trabajen, orden alfabético con.trim()(ver gotcha de nombres). (2) Colores del número: naranja (.planilla-num--proceso) si hay labor EN_PROCESO ese día, verde (.planilla-num--terminada) si está cerrada (PARCIAL/COMPLETADA) —perDayProcesopor celda. (3) Resaltado por color (azul/rojo/amarillo/verde pastel) =planilla_revisiones.color; herramienta "Resaltar" con picker; es fondo, independiente de la letra de novedad. (4) Convenciones (legend) bajo el título. (5) Selector "Operarios": checklist persistido en localStorage (planilla-operarios-ocultos) para ocultar/mostrar. (6) Clic en el número → modal con las labores de esa celda (operario ×executionDateKey) con Editar (callbackonEditLabor→setSelectedLabordel padre) y Eliminar (deleteAssignment). La celda cuentaa.areade EN_PROCESO/PARCIAL/COMPLETADA porexecutionDateKey. -
[2026-06-18]
SearchableSelecten móvil: detecta(pointer: coarse). En móvil el input esreadOnlyhasta que el usuario pide buscar → abrir muestra la lista SIN teclado (ya no lo tapa); tocar el campo de nuevo o la opción "🔍 Buscar…" activa el teclado (searching+ focus). En escritorio, comportamiento original (foco abre y escribe). NO romper este split si tocas el componente. -
[2026-06-17] Realizadas consolida por CICLO, no por día. Agrupa por
suerteCode|labory dentro clusteriza por ciclo (isSameCycle, adyacentes ≤21 días) → una tarjeta por corte conΣ executedArea / área asignada (maestro), rango de fechas, "N parciales". Clic → detalle de cada parcial (operario · fecha · ha · estado). Un re-laboreo en otro ciclo = otra tarjeta. -
[2026-06-17] Historial del operario recuerda la última selección enREVERTIDO [2026-06-24]: se eliminó la persistencia (localStoragesam:historial-operario-pref) Y el auto-salto ahistoryMonths[0]. El Historial ahora SIEMPRE abre en mes actual + quincena en curso (ver sección "Dashboards/KPIs abren en la quincena actual"). No reintroducir la persistencia. -
[2026-06-16] Reporte (owner/admin) ahora EDITA y ELIMINA líneas (ajuste de liquidación final). Vista "Por labor", columna Acciones gateada a
canEditAssignments: Editar abre el modalselectedLaborexistente (setSelectedLabor(a)— edita área ejecutada/operario/horómetros/equipo/notas para cualquier estado) y Eliminar abre un modal de confirmación (deleteReportTarget) →handleDeleteReportRow→samApi.deleteAssignment(id)(DELETE real, irreversible) +setAssignments(filter)+db.assignments.delete(id). Requiere la policyasignaciones_delete(mig.20260616120000) — sin ella el delete borra 0 filas en server y la fila REAPARECE al sync (ver managing-supabase). El modal de confirmación es obligatorio (el usuario lo pidió explícito). El "Editar" NO toca cliente/fecha/estado (solo lo del modal); si lo piden, extendereditLaborDraft+editAssignment/UpdateAssignmentInput. -
[2026-06-16] Banner "pendientes viejas" → ahora abre DETALLE (antes era confirmar-y-borrar-todo). En Labores, el botón Limpiar del
stale-bannerabre un modal que LISTA cada pendiente vieja (stalePendientes.list, hacienda·suerte, labor — operario, ha, "hace N días" víadiasDesde) con ✕ Limpiar por ítem (handleCleanOneStale, cancela esa sola → CANCELADA) + Limpiar todas (handleCleanStale).stalePendientesahora retorna{list, count, area, ids}. Reversible (CANCELADA, no borra). NO confundir con el DELETE real del Reporte. -
[2026-06-15] Catálogo de labores CRUD (
labores_catalogo) reemplaza la constanteWORKFLOWpara los PICKERS. Pestaña'catalogo'(LaboresTab.tsx, owner+admin, menú "Más" → "Labores"): crear/renombrar/activar-desactivar/eliminar + tipoMECANIZADA/MANUAL. El contexto exponeactiveLabores(activas, alfabético) yfieldLabores(activas + no-manuales). Pickers: asignar (SupervisorView) y Tablero usanactiveLabores; el de campo del operario (OperatorView) usafieldLabores→ un operador de tractor NO ve labores manuales (REPIQUE). Al asignar una labor manual, aviso.field-warning(no bloquea).WORKFLOWSOLO se conserva paragetSuggestedLabor/progreso y como fallback. Detalle de tablas/Dexie en managing-supabase. REPIQUE arranca DESACTIVADA y MANUAL (caso que originó esto: era manual mal registrada en operador de tractor). -
[2026-06-15] Caso REPIQUE/operador de tractor (diagnóstico tipo). Una labor "Laborando" (EN_PROCESO) que lleva días sin cerrar y muestra ha pero sin hora =
fecha_finNULL (el historial solo muestra hora si hayfinishedAt). Si además es una labor MANUAL en un operador de TRACTOR (conequipode tractor + horómetro inicial pero sin final ni área), es un registro errado en campo (LIBRE) que el operario nunca cerró → se corrige conUPDATE asignaciones SET estado='CANCELADA', observaciones='...' WHERE id=.... Por eso se tipificaron las labores (manual/mecanizada) y se filtró el picker de campo. -
[2026-06-15] Vista "Realizadas" del propietario (
RealizadasTab.tsx, owner+admin; owner la tiene en la barra principal junto a Labores, admin en "Más"). Lista labores COMPLETADA+PARCIAL, filtros por hacienda y labor (SearchableSelect), orden hacienda alfabético → fecha de ejecución desc → suerte. Segmentador de fecha:Todas / Mes / 1ra quinc. / 2da quinc. / Hoy / Rango(rango =desde/hastacon<input type=date>), reusamatchesSummaryFilter. Encabezado "N labores · X ha ejecutadas". -
[2026-06-15] Planilla: orden ALFABÉTICO + resaltado de revisadas (azul celeste, persistente). Filas ordenadas por
name.localeCompare(...'es',{sensitivity:'base'})(antes era desc por total). Herramienta de revisión: botón 🖍 Marcar revisadas (modo) → clic en celda la pinta azul (planilla-revisada); en modo marcar el clic NO abre detalle. Persistencia en tablaplanilla_revisiones(claveoperador_id|fecha) víaloadPlanillaRevisiones/setPlanillaRevision(optimista). Botón 📋 Revisadas (N) → modal con detalle (operario·fecha·ha) + limpiar una (setPlanillaRevision(false)) o todas (clearAllPlanillaRevisiones). El marcado equivalente en las TARJETAS de Labores se construyó y luego se REVIRTIÓ (la tablalabor_revisionesysamApi.*LaborRevision*quedaron sin uso) — la petición real era el banner de pendientes, no marcar tarjetas. -
[2026-06-14] Parciales que cruzan de día (mismo operario) → SPLIT en entradas por día. Antes, continuar una PARCIAL al día siguiente SOBRESCRIBÍA la misma fila (executedArea acumulaba,
finishedAt=hoy, status=COMPLETADA) → toda la labor aparecía HOY y la porción de ayer "desaparecía" (era una sola fila por operario+suerte+labor). Fix enstartAssignment(useAssignmentActions): sistatus==='PARCIAL' && executedArea>0 && executionDateKey(a)!==todayKey && isOnline→ (1) congela la fila de ayer como COMPLETADA (conserva suexecutedAreay sufecha_fin=ayer), (2) crea una entrada NUEVA concreateAssignment(área completa de la suerte,initialStatus:'EN_PROCESO',startedAt=hoy,approval:'APROBADA'), (3)updateAssignment(nueva.id,{horometroInicial}). Cada día queda en SU fecha y se agrupan por ciclo (isSameCycle) — igual que ya pasaba entre dos operarios distintos.supervisorNamese resuelve desupervisors(useAppData). Offline cae al flujo normal (mutar). Aplica a ASIGNADA y LIBRE. El mismo-día NO hace split (solo cruce de día). Limitación: el horómetro sigue siendo 1 par por fila, pero ahora hay 1 fila por día → cada día tiene su par. -
[2026-06-18] ⚠️ CAMBIO de lógica de la celda — la Planilla suma ÁREA REAL, no planificada. Antes (2026-06-11) sumaba
a.area(planificada) al ABRIR, lo que duplicaba las labores que cruzan de día (el split crea 2 filas con área completa → 13.3+13.3=26.7 cuando el trabajo real fue 13.3). AHORA, por celda (operario ×executionDateKey): para CERRADAS (PARCIAL/COMPLETADA) sumaexecutedArea(lo hecho esa sesión → el split reparte el avance por día, suma = total real); para EN_PROCESO suma el restante estimado =a.area − (Σ executedArea de lo CERRADO en la misma suerte+labor del mismo ciclo, isSameCycle)→ así no duplica lo ya hecho y sigue mostrando en naranja lo que se trabaja hoy.cerradoBySuerteprecalcula el avance cerrado p
*Truncated - read the full file at https://github.com/juancarloszuluagaranzon-afk/sam/blob/63d3d659b882bf787a23f77385976ca60f8eead2/sam-app/.agent/skills/managing-assignmen