diff --git a/.gitignore b/.gitignore index dd984801..58dcf173 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,10 @@ node_modules .gemini/ .playwright-cli/ .tanstack/ +.wrangler/ + +# Reference contagem spreadsheets — kept locally for schema reference, not source +*.xlsx docs diff --git a/app/admin/contagens/parser/xlsx-import.ts b/app/admin/contagens/parser/xlsx-import.ts new file mode 100644 index 00000000..5e451769 --- /dev/null +++ b/app/admin/contagens/parser/xlsx-import.ts @@ -0,0 +1,309 @@ +import * as XLSX from "xlsx"; +import type { Topology } from "~/components/Admin/topology/types"; +import type { + BucketMinutes, + NovaFormValues, +} from "~/admin/contagens/schema/nova-form"; + +/** + * Parser for the Ameciclo "Dados da Contagem" xlsx template (Resumo + Dados + * sheets). Returns a partial NovaFormValues the form layer can spread into + * its defaults; values can be reviewed and edited before submit. + * + * Per-bucket fidelity: every hourly column becomes one entry in the value + * array (movements, characteristics, outros[].counts). The form switches to + * "Por hora" automatically when imported data is per-bucket. + */ + +/** xlsx label → canonical leaf key (CHARACTERISTICS in contagem-data.ts). */ +const LEAF_BY_LABEL: Record = { + Mulher: "women", + "Crianças e adolescentes": "juveniles", + Capacete: "helmet", + Calçada: "sidewalk", + Máscara: "mascara", + "Carona criança": "carona_crianca", + "Carona mulher": "carona_mulher", + "Carona homem": "carona_homem", + "Cargueira tradicional": "cargueira_tradicional", + "Adaptada a carga": "cargueira_adaptada", + "Serviço APP": "servico_app", + "Contramão para conversão": "contramao_para_conversao", + "Bike PE": "bike_pe", + Empurrando: "empurrando", + Triciclo: "triciclo", + Carroça: "carroca", + Elétrica: "eletrica", + Motorizada: "motorizada", + Ciclomotor: "ciclomotor", + "Skate e outros patináveis": "skate_patinaveis", + Cadeirante: "cadeirante", + Handbike: "handbike", + "Grupos de Pedal": "grupos_pedal", + "Faixa Azul": "faixa_azul", +}; + +const ROLLUP_PLURAL_LABELS = new Set(["Caronas", "Cargueiras", "Serviços", "Contramãos"]); + +export type ImportResult = { + values: Partial; + warnings: string[]; +}; + +export function parseContagemXlsx(buffer: ArrayBuffer): ImportResult { + const wb = XLSX.read(buffer, { type: "array", cellDates: true }); + const warnings: string[] = []; + const out: Partial = {}; + + /* ----- Resumo: name + date ------------------------------------------- */ + const resumo = wb.Sheets["Resumo"]; + if (resumo) { + const resumoRows = XLSX.utils.sheet_to_json(resumo, { + header: 1, + defval: null, + raw: true, + }); + const resumoMap: Record = {}; + for (const row of resumoRows) { + if (Array.isArray(row) && row.length >= 2 && typeof row[0] === "string") { + resumoMap[row[0]] = row[1]; + } + } + const cruzamento = String(resumoMap["Cruzamento"] ?? "").trim(); + if (cruzamento) { + out.locationMode = "new"; + out.locationName = cruzamento; + } + const dateValue = resumoMap["Data"]; + if (dateValue instanceof Date) { + const yyyy = dateValue.getUTCFullYear(); + const mm = String(dateValue.getUTCMonth() + 1).padStart(2, "0"); + const dd = String(dateValue.getUTCDate()).padStart(2, "0"); + out.date = `${yyyy}-${mm}-${dd}`; + } + const coords = String(resumoMap["Coordenadas Geográficas"] ?? ""); + const coordMatch = coords.match(/(-?\d+\.\d+)[\s,]+(-?\d+\.\d+)/); + if (coordMatch) { + warnings.push( + `Coordenadas detectadas (${coordMatch[1]}, ${coordMatch[2]}); o formulário ainda não captura lat/lng.`, + ); + } + } else { + warnings.push('Aba "Resumo" não encontrada — faltarão local e data.'); + } + + /* ----- Dados: movements + characteristics + outros ------------------- */ + const dados = wb.Sheets["Dados"]; + if (!dados) { + warnings.push('Aba "Dados" não encontrada — sem movimentos ou características.'); + return { values: out, warnings }; + } + + const rows = XLSX.utils.sheet_to_json(dados, { + header: 1, + defval: null, + raw: true, + }); + + /* ----- detect hourly column geometry from movement table header ------ */ + const movHeaderIdx = rows.findIndex( + (r) => Array.isArray(r) && r.includes("ORIGEM") && r.includes("DESTINO"), + ); + + let bucketCount = 0; + let bucketMinutes: BucketMinutes = "60"; + let startHour = 6; + + if (movHeaderIdx >= 0) { + const header = rows[movHeaderIdx] as unknown[]; + const totalCol = header.indexOf("TOTAL"); + const destinoCol = header.indexOf("DESTINO"); + const hourlyHeaders: { col: number; hour: number }[] = []; + for (let c = destinoCol + 1; c < (totalCol >= 0 ? totalCol : header.length); c++) { + const v = header[c]; + if (typeof v === "number" && Number.isFinite(v)) { + hourlyHeaders.push({ col: c, hour: v }); + } + } + if (hourlyHeaders.length > 0) { + bucketCount = hourlyHeaders.length; + startHour = hourlyHeaders[0].hour; + if (hourlyHeaders.length >= 2) { + const diffMin = Math.round((hourlyHeaders[1].hour - hourlyHeaders[0].hour) * 60); + if (diffMin === 15 || diffMin === 30 || diffMin === 60 || diffMin === 120) { + bucketMinutes = String(diffMin) as BucketMinutes; + } else { + warnings.push( + `Largura de bucket atípica (${diffMin}min); usando 60 min como fallback.`, + ); + } + } + const widthMin = Number(bucketMinutes); + const startMin = startHour * 60; + const endMin = startMin + bucketCount * widthMin; + out.bucketMinutes = bucketMinutes; + out.startTime = formatHHMM(startMin); + out.endTime = formatHHMM(endMin); + } + } + + if (bucketCount === 0) { + warnings.push("Não foi possível detectar a granularidade horária; usando 1 bucket."); + bucketCount = 1; + } + + /* ----- movement table ------------------------------------------------- */ + if (movHeaderIdx === -1) { + warnings.push("Tabela de movimentos não localizada."); + } else { + const header = rows[movHeaderIdx] as unknown[]; + const origemCol = header.indexOf("ORIGEM"); + const destinoCol = header.indexOf("DESTINO"); + const totalCol = header.indexOf("TOTAL"); + + type MovRow = { origem: string; destino: string; buckets: string[] }; + const movRows: MovRow[] = []; + for (let i = movHeaderIdx + 1; i < rows.length; i++) { + const row = rows[i]; + if (!Array.isArray(row)) break; + const origem = row[origemCol]; + const destino = row[destinoCol]; + if (typeof origem !== "string" || typeof destino !== "string") break; + + const buckets: string[] = []; + const last = totalCol >= 0 ? totalCol : row.length; + for (let c = destinoCol + 1; c < last; c++) { + const n = Number(row[c]); + buckets.push(Number.isFinite(n) && n > 0 ? String(Math.trunc(n)) : ""); + } + // Truncate / pad to bucketCount in case the row is misaligned. + while (buckets.length < bucketCount) buckets.push(""); + buckets.length = bucketCount; + + movRows.push({ origem: origem.trim(), destino: destino.trim(), buckets }); + } + + const approachLabels: string[] = []; + for (const m of movRows) if (!approachLabels.includes(m.origem)) approachLabels.push(m.origem); + for (const m of movRows) if (!approachLabels.includes(m.destino)) approachLabels.push(m.destino); + + out.approaches = approachLabels; + out.topology = + approachLabels.length === 2 + ? "point" + : approachLabels.length === 3 + ? "t_junction" + : approachLabels.length === 4 + ? "crossroad" + : ((): Topology => { + warnings.push( + `Número inesperado de aproximações (${approachLabels.length}); usando "crossroad".`, + ); + return "crossroad"; + })(); + + const movements: Record = {}; + for (let i = 0; i < approachLabels.length; i++) { + for (let j = 0; j < approachLabels.length; j++) { + if (i !== j) movements[`${i}-${j}`] = Array.from({ length: bucketCount }, () => ""); + } + } + for (const m of movRows) { + const i = approachLabels.indexOf(m.origem); + const j = approachLabels.indexOf(m.destino); + if (i >= 0 && j >= 0 && i !== j) { + movements[`${i}-${j}`] = m.buckets; + } + } + out.movements = movements; + } + + /* ----- characteristic tables ----------------------------------------- */ + const charHeaderIdxs: number[] = []; + for (let i = 0; i < rows.length; i++) { + const row = rows[i]; + if (Array.isArray(row) && row.includes("Característica")) charHeaderIdxs.push(i); + } + + const characteristics: Record = {}; + const outros: { label: string; counts: string[] }[] = []; + + for (let s = 0; s < charHeaderIdxs.length; s++) { + const headerIdx = charHeaderIdxs[s]; + const header = rows[headerIdx] as unknown[]; + const labelCol = header.indexOf("Característica"); + const totalC = header.indexOf("TOTAL"); + + type SectionRow = { label: string; total: number; buckets: string[] }; + const sectionRows: SectionRow[] = []; + const sectionEnd = charHeaderIdxs[s + 1] ?? rows.length; + for (let i = headerIdx + 1; i < sectionEnd; i++) { + const row = rows[i]; + if (!Array.isArray(row)) continue; + const label = row[labelCol]; + if (typeof label !== "string") continue; + if (label.startsWith("Dados qualitativos")) continue; + + const buckets: string[] = []; + const last = totalC >= 0 ? totalC : row.length; + for (let c = labelCol + 1; c < last; c++) { + const n = Number(row[c]); + buckets.push(Number.isFinite(n) && n > 0 ? String(Math.trunc(n)) : ""); + } + while (buckets.length < bucketCount) buckets.push(""); + buckets.length = bucketCount; + + const total = + totalC >= 0 && row[totalC] != null + ? Number(row[totalC]) + : buckets.reduce((acc, x) => acc + (Number(x) || 0), 0); + sectionRows.push({ label: label.trim(), total: Number.isFinite(total) ? total : 0, buckets }); + } + + const labels = new Set(sectionRows.map((r) => r.label)); + const servicoIsLeaf = labels.has("Serviço APP"); + const contramaoIsLeaf = labels.has("Contramão para conversão"); + + for (const { label, total, buckets } of sectionRows) { + if (ROLLUP_PLURAL_LABELS.has(label)) continue; + + if (label === "Serviço") { + if (servicoIsLeaf && total > 0) characteristics["servico"] = buckets; + continue; + } + if (label === "Contramão") { + if (contramaoIsLeaf && total > 0) characteristics["contramao"] = buckets; + continue; + } + + if (/^"?Outros"?\s*(-\s*.+)?$/.test(label)) { + const desc = label.replace(/^"?Outros"?\s*-?\s*/, "").trim(); + if (total > 0 && desc.length > 0) { + outros.push({ label: desc, counts: buckets }); + } + continue; + } + + const key = LEAF_BY_LABEL[label]; + if (!key) { + if (total > 0) { + warnings.push(`Característica não mapeada: "${label}" (total: ${total}).`); + } + continue; + } + if (total > 0) characteristics[key] = buckets; + } + } + + out.characteristics = characteristics; + out.outros = outros.map((o) => ({ label: o.label, counts: o.counts })); + + return { values: out, warnings }; +} + +function formatHHMM(totalMinutes: number): string { + const m = Math.max(0, Math.round(totalMinutes)); + const h = Math.floor(m / 60).toString().padStart(2, "0"); + const mm = (m % 60).toString().padStart(2, "0"); + return `${h}:${mm}`; +} diff --git a/app/admin/contagens/schema/contagem-data.ts b/app/admin/contagens/schema/contagem-data.ts new file mode 100644 index 00000000..ce06eece --- /dev/null +++ b/app/admin/contagens/schema/contagem-data.ts @@ -0,0 +1,186 @@ +import { z } from "zod"; + +/** + * Zod schemas for the `contagem.data` JSON column. Dialect-independent — + * survives the SQLite → Postgres swap untouched. + * + * Each variant carries a `schema` discriminator. Code dispatches on it via + * the discriminated union at the bottom; readers always go through + * `parseContagemData()` so old rows can be lazily upgraded if/when we add + * a v2. + */ + +const NonNegInt = z.number().int().nonnegative(); + +const HourlyBucketsArray = z.array(NonNegInt); + +/** "fromIdx-toIdx", e.g. "0-2". Excludes self-loops. */ +const MovementKey = z + .string() + .regex(/^\d+-\d+$/, "movement key must look like '-'") + .refine( + (k) => { + const [a, b] = k.split("-"); + return a !== b; + }, + { message: "U-turns (from === to) are not stored" }, + ); + +const Outros = z.object({ + label: z.string().min(1), + buckets: HourlyBucketsArray, +}); + +const BucketNote = z.object({ + fromBucket: z.number().int().nonnegative(), + toBucket: z.number().int().nonnegative(), + note: z.string().min(1), +}); + +const Totals = z.object({ + cyclists: NonNegInt, + peakBucketCount: NonNegInt, +}); + +/* ----- ameciclo.v1 ------------------------------------------------------ */ + +export const AmecicloV1 = z + .object({ + schema: z.literal("ameciclo.v1"), + + // Ordered approach labels, snapshotted on the contagem so renaming a + // place later doesn't rewrite history. Length matches topology + // (point=2, t_junction=3, crossroad=4). + approaches: z.array(z.string().min(1)).min(2).max(8), + + // Quantitativo: per-movement hourly arrays. Each value array has + // length === parent contagem's bucketCount. + movements: z.record(MovementKey, HourlyBucketsArray), + + // Qualitativo (canonical taxonomy keys; rollups derived at read). + characteristics: z.record(z.string().min(1), HourlyBucketsArray), + + // Per-session ad-hoc rows ("Outros - corte de caminho pela calçada…"). + outros: z.array(Outros).default([]), + + // Per-bucket-range narrative ("choveu fraco das 10h às 11h"). + bucketNotes: z.array(BucketNote).default([]), + + // Materialized aggregates. Written by the producer (form / importer) so + // generated columns can pull from a stable path. + totals: Totals, + }) + .strict(); + +export type AmecicloV1Data = z.infer; + +/* ----- discriminated union ---------------------------------------------- */ + +export const ContagemData = z.discriminatedUnion("schema", [ + AmecicloV1, + // Future variants (e.g. external.parana.2024, ameciclo.v2) plug in here. +]); + +export type ContagemData = z.infer; + +/* ----- canonical characteristic taxonomy -------------------------------- */ + +/** Static seed of recognized characteristic keys + their parents (for rollups). + * Adding a new key here is a non-breaking change — old data simply has no + * bucket vector for that key. */ +export const CHARACTERISTICS: Record< + string, + { label: string; group: string; parent?: string } +> = { + // profile + women: { label: "Mulher", group: "profile" }, + juveniles: { label: "Crianças e adolescentes", group: "profile" }, + + // behavior + helmet: { label: "Capacete", group: "behavior" }, + sidewalk: { label: "Calçada", group: "behavior" }, + mascara: { label: "Máscara", group: "behavior" }, + + // carona (rolls up to "caronas") + carona_crianca: { label: "Carona criança", group: "carona", parent: "caronas" }, + carona_mulher: { label: "Carona mulher", group: "carona", parent: "caronas" }, + carona_homem: { label: "Carona homem", group: "carona", parent: "caronas" }, + + // cargueira (rolls up to "cargueiras") + cargueira_tradicional: { + label: "Cargueira tradicional", + group: "cargueira", + parent: "cargueiras", + }, + cargueira_adaptada: { + label: "Adaptada a carga", + group: "cargueira", + parent: "cargueiras", + }, + + // serviço (rolls up to "servicos") + servico: { label: "Serviço", group: "servico", parent: "servicos" }, + servico_app: { label: "Serviço APP", group: "servico", parent: "servicos" }, + + // contramão (rolls up to "contramaos") + contramao: { label: "Contramão", group: "contramao", parent: "contramaos" }, + contramao_para_conversao: { + label: "Contramão para conversão", + group: "contramao", + parent: "contramaos", + }, + + // modal / programa (no rollups — all leaves) + bike_pe: { label: "Bike PE", group: "program" }, + empurrando: { label: "Empurrando", group: "modal" }, + triciclo: { label: "Triciclo", group: "modal" }, + carroca: { label: "Carroça", group: "modal" }, + eletrica: { label: "Elétrica", group: "modal" }, + motorizada: { label: "Motorizada", group: "modal" }, + ciclomotor: { label: "Ciclomotor", group: "modal" }, + skate_patinaveis: { label: "Skate e outros patináveis", group: "modal" }, + cadeirante: { label: "Cadeirante", group: "modal" }, + handbike: { label: "Handbike", group: "modal" }, + grupos_pedal: { label: "Grupos de Pedal", group: "program" }, + faixa_azul: { label: "Faixa Azul", group: "program" }, +}; + +/* ----- parse / migrate / build ----------------------------------------- */ + +export function parseContagemData(raw: unknown): ContagemData { + return ContagemData.parse(raw); +} + +/** Sums every value in every bucket array. */ +function sumAll(map: Record): number { + let total = 0; + for (const arr of Object.values(map)) { + for (const v of arr) total += v; + } + return total; +} + +/** Highest single-bucket sum across all movements. */ +function peakBucket(movements: Record, bucketCount: number): number { + let peak = 0; + for (let i = 0; i < bucketCount; i++) { + let s = 0; + for (const arr of Object.values(movements)) s += arr[i] ?? 0; + if (s > peak) peak = s; + } + return peak; +} + +/** + * Compute `totals` from the rest of the payload. Producers (form, importer) + * call this before writing so the generated columns and JSON agree. + */ +export function computeTotals(input: { + movements: Record; + bucketCount: number; +}): { cyclists: number; peakBucketCount: number } { + return { + cyclists: sumAll(input.movements), + peakBucketCount: peakBucket(input.movements, input.bucketCount), + }; +} diff --git a/app/admin/contagens/schema/nova-form.ts b/app/admin/contagens/schema/nova-form.ts new file mode 100644 index 00000000..4fb5253f --- /dev/null +++ b/app/admin/contagens/schema/nova-form.ts @@ -0,0 +1,179 @@ +import { z } from "zod"; +import { TOPOLOGY_DIRECTIONS } from "~/components/Admin/topology/types"; +import { CHARACTERISTICS } from "~/admin/contagens/schema/contagem-data"; + +/** + * Form-side Zod schema for the Nova Contagem form. Holds form state directly. + * + * Per-bucket data: `movements`, `characteristics` and `outros[].counts` are + * arrays of strings, one entry per bucket. Length tracks `bucketCount` derived + * from `startTime`, `endTime` and `bucketMinutes`. The form renders either + * totals (sum of the array) or each bucket cell directly depending on the + * current view mode (UI-only state). + */ +export const CHARACTERISTIC_KEYS = Object.keys(CHARACTERISTICS) as Array< + keyof typeof CHARACTERISTICS +>; +export type CharacteristicKey = (typeof CHARACTERISTIC_KEYS)[number]; + +const StringBucketArray = z.array(z.string()); + +const OutroRow = z.object({ + label: z.string().trim().default(""), + counts: StringBucketArray.default([]), +}); +export type OutroRow = z.infer; + +export const BUCKET_MINUTES_OPTIONS = ["15", "30", "60", "120"] as const; +export type BucketMinutes = (typeof BUCKET_MINUTES_OPTIONS)[number]; + +export const NovaFormSchema = z + .object({ + locationMode: z.enum(["existing", "new"]), + existingLocationId: z.union([z.number(), z.string(), z.null()]).default(null), + locationName: z.string().trim().min(1, "Informe o nome do local."), + topology: z.enum(["point", "t_junction", "crossroad"]), + approaches: z.array(z.string()), + movements: z.record(z.string(), StringBucketArray), + date: z.string().min(1, "Informe a data."), + startTime: z.string().min(1, "Informe o início."), + endTime: z.string().min(1, "Informe o término."), + bucketMinutes: z.enum(BUCKET_MINUTES_OPTIONS).default("60"), + maxHourCyclists: z.string().default(""), + weatherConditions: z.string().default(""), + notes: z.string().default(""), + characteristics: z.record(z.string(), StringBucketArray).default({}), + outros: z.array(OutroRow).default([]), + }) + .superRefine((v, ctx) => { + if (v.locationMode === "existing" && v.existingLocationId == null) { + ctx.addIssue({ + code: "custom", + path: ["existingLocationId"], + message: "Selecione um ponto da lista.", + }); + } + if (v.startTime && v.endTime && v.endTime <= v.startTime) { + ctx.addIssue({ + code: "custom", + path: ["endTime"], + message: "Deve ser depois do início.", + }); + } + const expectedApproaches = TOPOLOGY_DIRECTIONS[v.topology].length; + if (v.approaches.length !== expectedApproaches) { + ctx.addIssue({ + code: "custom", + path: ["approaches"], + message: `Esperadas ${expectedApproaches} aproximações para ${v.topology}.`, + }); + } + let total = 0; + for (const arr of Object.values(v.movements)) { + for (const cell of arr) { + const n = Number(cell); + if (Number.isFinite(n) && n > 0) total += n; + } + } + if (total === 0) { + ctx.addIssue({ + code: "custom", + path: ["movements"], + message: "A matriz precisa ter ao menos um movimento contado.", + }); + } + v.outros.forEach((row, i) => { + const hasLabel = row.label.trim().length > 0; + const hasCount = sumStringArray(row.counts) > 0; + if (hasCount && !hasLabel) { + ctx.addIssue({ + code: "custom", + path: ["outros", i, "label"], + message: "Descreva a observação.", + }); + } + if (hasLabel && !hasCount) { + ctx.addIssue({ + code: "custom", + path: ["outros", i, "counts"], + message: "Informe ao menos uma contagem.", + }); + } + }); + }); + +export type NovaFormValues = z.infer; + +/* ---------- helpers ---------------------------------------------------- */ + +export function timeToMinutes(t: string): number { + if (!t) return 0; + const [h, m] = t.split(":").map(Number); + return (h || 0) * 60 + (m || 0); +} + +/** Number of buckets covered by [startTime, endTime) at bucketMinutes width. + * Returns 0 when times are missing/invalid. */ +export function deriveBucketCount( + startTime: string, + endTime: string, + bucketMinutes: BucketMinutes, +): number { + const start = timeToMinutes(startTime); + const end = timeToMinutes(endTime); + const width = Number(bucketMinutes); + if (!start || !end || end <= start || !width) return 0; + return Math.max(0, Math.floor((end - start) / width)); +} + +/** Sum of numbers in a string array (NaN/"" treated as 0). */ +export function sumStringArray(arr: string[] | undefined): number { + if (!arr) return 0; + let s = 0; + for (const v of arr) { + const n = Number(v); + if (Number.isFinite(n) && n > 0) s += Math.trunc(n); + } + return s; +} + +/** Build an array of length n filled with "". */ +export function emptyBucketArray(n: number): string[] { + return Array.from({ length: Math.max(0, n) }, () => ""); +} + +/** Read a string-array bucket cell with safe fallback. */ +export function readCell(arr: string[] | undefined, idx: number): string { + return arr?.[idx] ?? ""; +} + +/** Returns a copy of `arr` resized to `n`, padding with "" or truncating. */ +export function resizeBucketArray(arr: string[] | undefined, n: number): string[] { + const out = emptyBucketArray(n); + if (!arr) return out; + for (let i = 0; i < Math.min(arr.length, n); i++) out[i] = arr[i]; + return out; +} + +/* ---------- characteristic taxonomy display --------------------------- */ + +export const CHARACTERISTIC_GROUPS: Array<{ + group: string; + label: string; + rolledUpAs?: string; +}> = [ + { group: "profile", label: "Perfil" }, + { group: "behavior", label: "Comportamento" }, + { group: "carona", label: "Carona", rolledUpAs: "Caronas" }, + { group: "cargueira", label: "Cargueira", rolledUpAs: "Cargueiras" }, + { group: "servico", label: "Serviço", rolledUpAs: "Serviços" }, + { group: "contramao", label: "Contramão", rolledUpAs: "Contramãos" }, + { group: "modal", label: "Modal" }, + { group: "program", label: "Programa" }, +]; + +export function characteristicsInGroup(group: string): string[] { + return Object.entries(CHARACTERISTICS) + .filter(([, meta]) => meta.group === group) + .map(([key]) => key); +} diff --git a/app/admin/contagens/server/createContagem.ts b/app/admin/contagens/server/createContagem.ts new file mode 100644 index 00000000..a37412ed --- /dev/null +++ b/app/admin/contagens/server/createContagem.ts @@ -0,0 +1,68 @@ +import { createServerFn } from "@tanstack/react-start"; +import { z } from "zod"; +import { db } from "~/db/client"; +import { contagem } from "~/db/schema"; +import { + AmecicloV1, + computeTotals, + type AmecicloV1Data, +} from "~/admin/contagens/schema/contagem-data"; + +/** + * Form-side input shape. Mirrors the column split: the things that live in + * proper columns travel as top-level fields, the rest is the (validated) + * `data` payload. + */ +const Input = z.object({ + localName: z.string().trim().min(1, "Informe o nome do local."), + startedAt: z + .string() + .regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2})?$/, + "Use ISO sem fuso (ex: 2024-06-06T06:00).", + ), + timezone: z.string().default("America/Recife"), + bucketMinutes: z.number().int().positive(), + bucketCount: z.number().int().positive(), + latitude: z.number().nullable().optional(), + longitude: z.number().nullable().optional(), + topology: z.enum(["point", "t_junction", "crossroad"]), + notes: z.string().nullable().optional(), + // The variant payload — strict-validated against the schema discriminator. + data: AmecicloV1.omit({ totals: true, schema: true }), +}); + +export type CreateContagemInput = z.input; + +export const createContagem = createServerFn({ method: "POST" }) + .inputValidator((raw: unknown) => Input.parse(raw)) + .handler(async ({ data: input }) => { + const totals = computeTotals({ + movements: input.data.movements, + bucketCount: input.bucketCount, + }); + + const dataPayload: AmecicloV1Data = { + schema: "ameciclo.v1", + ...input.data, + totals, + }; + + const [row] = await db() + .insert(contagem) + .values({ + localName: input.localName, + startedAt: input.startedAt, + timezone: input.timezone, + bucketMinutes: input.bucketMinutes, + bucketCount: input.bucketCount, + latitude: input.latitude ?? null, + longitude: input.longitude ?? null, + topology: input.topology, + notes: input.notes ?? null, + schema: "ameciclo.v1", + data: dataPayload, + }) + .returning({ id: contagem.id }); + + return { id: row.id, totals }; + }); diff --git a/app/components/Admin/NovaContagemForm.tsx b/app/components/Admin/NovaContagemForm.tsx index 7e36ff20..5bfae9a8 100644 --- a/app/components/Admin/NovaContagemForm.tsx +++ b/app/components/Admin/NovaContagemForm.tsx @@ -1,7 +1,10 @@ -import { useState, useMemo, useEffect } from "react"; -import { Link } from "@tanstack/react-router"; -import { ArrowLeft, Save, Info } from "lucide-react"; +import { Link, useRouter } from "@tanstack/react-router"; +import { useRef, useState } from "react"; +import { ArrowLeft, Save, Info, Loader2, Upload, Plus, Trash2 } from "lucide-react"; import { toast } from "sonner"; +import { useForm } from "@tanstack/react-form"; +import { z } from "zod"; + import { Button } from "~/components/ui/button"; import { Card, @@ -21,96 +24,41 @@ import { import { TopologyPicker } from "~/components/Admin/topology/TopologyPicker"; import { TopologyDiagram } from "~/components/Admin/topology/TopologyDiagram"; import { MovementMatrix } from "~/components/Admin/topology/MovementMatrix"; -import { - TOPOLOGY_DIRECTIONS, - emptyMovements, - totalCyclists, - type Movements, - type Topology, -} from "~/components/Admin/topology/types"; +import { MovementHourlyTable } from "~/components/Admin/topology/MovementHourlyTable"; +import { CharacteristicsHourlyTable } from "~/components/Admin/topology/CharacteristicsHourlyTable"; +import { TOPOLOGY_DIRECTIONS, type Topology } from "~/components/Admin/topology/types"; import { cn } from "~/lib/utils"; +import { createContagem } from "~/admin/contagens/server/createContagem"; +import { + NovaFormSchema, + CHARACTERISTIC_KEYS, + CHARACTERISTIC_GROUPS, + characteristicsInGroup, + deriveBucketCount, + emptyBucketArray, + resizeBucketArray, + sumStringArray, + timeToMinutes, + BUCKET_MINUTES_OPTIONS, + type BucketMinutes, + type CharacteristicKey, + type NovaFormValues, +} from "~/admin/contagens/schema/nova-form"; +import { CHARACTERISTICS } from "~/admin/contagens/schema/contagem-data"; + +/* ---------- defaults --------------------------------------------------- */ + +function emptyMovementBuckets(approachCount: number): Record { + const out: Record = {}; + for (let i = 0; i < approachCount; i++) { + for (let j = 0; j < approachCount; j++) { + if (i !== j) out[`${i}-${j}`] = []; + } + } + return out; +} -type Characteristics = { - women: string; - juveniles: string; - ride: string; - helmet: string; - service: string; - cargo: string; - shared_bike: string; - sidewalk: string; - wrong_way: string; - motor: string; - rain: string; - other_active_modes: string; - other_behaviors: string; - others: string; -}; - -const CHARACTERISTICS_FIELDS: Array<{ - key: keyof Characteristics; - label: string; - group: "perfil" | "comportamento" | "modal" | "ambiente"; -}> = [ - { key: "women", label: "Mulheres", group: "perfil" }, - { key: "juveniles", label: "Crianças e adolescentes", group: "perfil" }, - { key: "ride", label: "Carona", group: "perfil" }, - { key: "helmet", label: "Capacete", group: "comportamento" }, - { key: "wrong_way", label: "Contramão", group: "comportamento" }, - { key: "sidewalk", label: "Calçada", group: "comportamento" }, - { key: "service", label: "Serviço", group: "modal" }, - { key: "cargo", label: "Cargueira", group: "modal" }, - { key: "shared_bike", label: "Compartilhada", group: "modal" }, - { key: "motor", label: "Motor / elétrica", group: "modal" }, - { key: "other_active_modes", label: "Outros modos ativos", group: "modal" }, - { key: "rain", label: "Sob chuva", group: "ambiente" }, - { key: "other_behaviors", label: "Outros comportamentos", group: "comportamento" }, - { key: "others", label: "Outros", group: "ambiente" }, -]; - -const GROUP_LABELS: Record = { - perfil: "Perfil", - comportamento: "Comportamento", - modal: "Tipo de bicicleta", - ambiente: "Ambiente / outros", -}; - -type LocationMode = "existing" | "new"; - -type FormState = { - locationMode: LocationMode; - existingLocationId: number | string | null; - locationName: string; - topology: Topology; - approaches: string[]; - movements: Movements; - date: string; - start_time: string; - end_time: string; - max_hour_cyclists: string; - weather_conditions: string; - notes: string; - characteristics: Characteristics; -}; - -const EMPTY_CHARACTERISTICS: Characteristics = { - women: "", - juveniles: "", - ride: "", - helmet: "", - service: "", - cargo: "", - shared_bike: "", - sidewalk: "", - wrong_way: "", - motor: "", - rain: "", - other_active_modes: "", - other_behaviors: "", - others: "", -}; - -function initialState(): FormState { +function defaultValues(): NovaFormValues { const topology: Topology = "crossroad"; const approachCount = TOPOLOGY_DIRECTIONS[topology].length; return { @@ -119,158 +67,285 @@ function initialState(): FormState { locationName: "", topology, approaches: Array.from({ length: approachCount }, () => ""), - movements: emptyMovements(approachCount), + movements: emptyMovementBuckets(approachCount), date: "", - start_time: "", - end_time: "", - max_hour_cyclists: "", - weather_conditions: "", + startTime: "", + endTime: "", + bucketMinutes: "60", + maxHourCyclists: "", + weatherConditions: "", notes: "", - characteristics: EMPTY_CHARACTERISTICS, + characteristics: Object.fromEntries( + CHARACTERISTIC_KEYS.map((k) => [k, [] as string[]]), + ) as Record, + outros: [], }; } -function toIntOrZero(s: string): number { - const n = Number(s); - return Number.isFinite(n) ? Math.max(0, Math.trunc(n)) : 0; +/* ---------- helpers ---------------------------------------------------- */ + +function toIntArray(arr: string[]): number[] { + return arr.map((s) => { + const n = Number(s); + return Number.isFinite(n) ? Math.max(0, Math.trunc(n)) : 0; + }); +} + +function bucketsToTotals(map: Record): Record { + const out: Record = {}; + for (const [k, arr] of Object.entries(map)) { + const t = sumStringArray(arr); + out[k] = t > 0 ? String(t) : ""; + } + return out; +} + +function approachVolumeFromBuckets( + fromIdx: number, + approachCount: number, + movements: Record, +): number { + let total = 0; + for (let to = 0; to < approachCount; to++) { + if (to === fromIdx) continue; + total += sumStringArray(movements[`${fromIdx}-${to}`]); + } + return total; +} + +function totalCyclistsFromBuckets(movements: Record): number { + let t = 0; + for (const arr of Object.values(movements)) t += sumStringArray(arr); + return t; } +/* ---------- component -------------------------------------------------- */ + export function NovaContagemForm({ locations }: { locations: LocationOption[] }) { - const [form, setForm] = useState(initialState); - const [submitted, setSubmitted] = useState(false); - - // Keep approaches/movements arrays in sync when topology changes. - useEffect(() => { - const target = TOPOLOGY_DIRECTIONS[form.topology].length; - if (form.approaches.length === target) return; - setForm((s) => { - const approaches = Array.from( - { length: target }, - (_, i) => s.approaches[i] ?? "", + const router = useRouter(); + + // UI-only state — view modes for the matrix and characteristics cards. + const [movementsView, setMovementsView] = useState<"totals" | "hourly">("totals"); + const [characteristicsView, setCharacteristicsView] = useState<"totals" | "hourly">("totals"); + + const form = useForm({ + defaultValues: defaultValues(), + validators: { onSubmit: NovaFormSchema }, + onSubmit: async ({ value }) => { + const startedAt = `${value.date}T${value.startTime}:00`; + const bucketMin = Number(value.bucketMinutes); + const bucketCount = deriveBucketCount(value.startTime, value.endTime, value.bucketMinutes); + + const movementsArrays: Record = Object.fromEntries( + Object.entries(value.movements).map(([k, arr]) => [ + k, + toIntArray(resizeBucketArray(arr, bucketCount)), + ]), + ); + const characteristicsArrays: Record = Object.fromEntries( + Object.entries(value.characteristics) + .map(([k, arr]) => [k, toIntArray(resizeBucketArray(arr, bucketCount))] as const) + .filter(([, a]) => a.some((v) => v > 0)), ); - return { ...s, approaches, movements: emptyMovements(target) }; - }); - }, [form.topology, form.approaches.length]); - - const total = totalCyclists(form.movements); - const charsSum = useMemo( - () => - (Object.keys(form.characteristics) as Array).reduce( - (acc, k) => acc + toIntOrZero(form.characteristics[k]), - 0, - ), - [form.characteristics], - ); - const errors = useMemo(() => { - const e: Partial> = {}; - if (!form.locationName.trim()) e.locationName = "Informe um nome para o ponto."; - if (form.locationMode === "existing" && form.existingLocationId == null) { - e.existingLocationId = "Selecione um ponto da lista."; - } - if (!form.date) e.date = "Informe a data."; - if (!form.start_time) e.start_time = "Informe o horário de início."; - if (!form.end_time) e.end_time = "Informe o horário de término."; - if (form.start_time && form.end_time && form.end_time <= form.start_time) { - e.end_time = "Deve ser depois do início."; - } - if (total === 0) e.movements = "A matriz precisa ter ao menos um movimento contado."; - return e; - }, [form, total]); + try { + const result = await createContagem({ + data: { + localName: value.locationName, + startedAt, + timezone: "America/Recife", + bucketMinutes: bucketMin, + bucketCount, + latitude: null, + longitude: null, + topology: value.topology, + notes: value.notes || null, + data: { + approaches: value.approaches, + movements: movementsArrays, + characteristics: characteristicsArrays, + outros: value.outros + .filter((o) => o.label.trim() && sumStringArray(o.counts) > 0) + .map((o) => ({ + label: o.label.trim(), + buckets: toIntArray(resizeBucketArray(o.counts, bucketCount)), + })), + bucketNotes: value.weatherConditions + ? [{ fromBucket: 0, toBucket: 0, note: value.weatherConditions }] + : [], + }, + }, + }); + toast.success("Contagem registrada", { + description: `ID ${result.id} · ${result.totals.cyclists.toLocaleString("pt-BR")} ciclistas`, + }); + router.navigate({ to: "/admin/contagens" }); + } catch (err) { + const message = err instanceof Error ? err.message : "Erro ao salvar contagem."; + toast.error("Não foi possível salvar.", { description: message }); + } + }, + }); - const hasErrors = Object.keys(errors).length > 0; + /* ----- mode + topology change handlers ------------------------------- */ - function set(key: K, value: FormState[K]) { - setForm((s) => ({ ...s, [key]: value })); + function onModeChange(mode: NovaFormValues["locationMode"]) { + form.setFieldValue("locationMode", mode); + form.setFieldValue("existingLocationId", null); + form.setFieldValue("locationName", ""); } - function setApproach(i: number, value: string) { - setForm((s) => { - const approaches = [...s.approaches]; - approaches[i] = value; - return { ...s, approaches }; - }); + function onExistingSelect(id: number | string | null) { + const picked = locations.find((l) => String(l.id) === String(id)); + form.setFieldValue("existingLocationId", id); + form.setFieldValue("locationName", picked?.name ?? ""); } - function setMovement(key: string, value: string) { - setForm((s) => ({ ...s, movements: { ...s.movements, [key]: value } })); + function onTopologyChange(t: Topology) { + const target = TOPOLOGY_DIRECTIONS[t].length; + const current = form.getFieldValue("approaches") ?? []; + const next = Array.from({ length: target }, (_, i) => current[i] ?? ""); + form.setFieldValue("topology", t); + form.setFieldValue("approaches", next); + // Movement keys reference approach indices, so they're invalidated. + form.setFieldValue("movements", emptyMovementBuckets(target)); } - function setChar(key: keyof Characteristics, value: string) { - setForm((s) => ({ - ...s, - characteristics: { ...s.characteristics, [key]: value }, - })); + function onApproachChange(idx: number, value: string) { + const next = [...(form.getFieldValue("approaches") ?? [])]; + next[idx] = value; + form.setFieldValue("approaches", next); } - function changeMode(mode: LocationMode) { - setForm((s) => ({ - ...s, - locationMode: mode, - // Reset cross-mode fields so they don't leak between flows. - existingLocationId: null, - locationName: "", - })); + /* ----- per-bucket vs totals writers ---------------------------------- */ + + function getCurrentBucketCount(): number { + return deriveBucketCount( + form.getFieldValue("startTime"), + form.getFieldValue("endTime"), + form.getFieldValue("bucketMinutes"), + ); } - function selectExisting(id: number | string | null) { - const picked = locations.find((l) => String(l.id) === String(id)); - setForm((s) => ({ - ...s, - existingLocationId: id, - locationName: picked?.name ?? "", - })); + function setMovementTotal(key: string, totalStr: string) { + const bc = Math.max(getCurrentBucketCount(), 1); + const arr = emptyBucketArray(bc); + arr[0] = totalStr; + const map = { ...(form.getFieldValue("movements") ?? {}) }; + map[key] = arr; + form.setFieldValue("movements", map); } - function handleSubmit(event: React.FormEvent) { - event.preventDefault(); - setSubmitted(true); - if (hasErrors) { - toast.error("Confira os campos destacados."); - return; - } + function setMovementBucket(key: string, b: number, value: string) { + const map = { ...(form.getFieldValue("movements") ?? {}) }; + const bc = Math.max(getCurrentBucketCount(), b + 1); + const next = resizeBucketArray(map[key], bc); + next[b] = value; + map[key] = next; + form.setFieldValue("movements", map); + } - const payload = { - location: { - mode: form.locationMode, - existing_id: form.existingLocationId, - name: form.locationName, - topology: form.topology, - approaches: form.approaches.map((a, i) => ({ - index: i, - label: a, - direction: TOPOLOGY_DIRECTIONS[form.topology][i], - })), - }, - session: { - date: form.date, - start_time: form.start_time, - end_time: form.end_time, - }, - results: { - total_cyclists: total, - max_hour_cyclists: toIntOrZero(form.max_hour_cyclists), - movements: Object.fromEntries( - Object.entries(form.movements).map(([k, v]) => [k, toIntOrZero(v)]), - ), - }, - characteristics: Object.fromEntries( - (Object.keys(form.characteristics) as Array).map( - (k) => [k, toIntOrZero(form.characteristics[k])], - ), - ), - weather_conditions: form.weather_conditions || null, - notes: form.notes || null, - }; - - console.info("[admin/contagens/nova] payload", payload); - toast.success("Contagem pronta para envio", { - description: "A camada de dados ainda não está conectada — payload no console.", - }); + function setCharTotal(key: string, totalStr: string) { + const bc = Math.max(getCurrentBucketCount(), 1); + const arr = emptyBucketArray(bc); + arr[0] = totalStr; + const map = { ...(form.getFieldValue("characteristics") ?? {}) }; + map[key] = arr; + form.setFieldValue("characteristics", map); } + function setCharBucket(key: string, b: number, value: string) { + const map = { ...(form.getFieldValue("characteristics") ?? {}) }; + const bc = Math.max(getCurrentBucketCount(), b + 1); + const next = resizeBucketArray(map[key], bc); + next[b] = value; + map[key] = next; + form.setFieldValue("characteristics", map); + } + + function bucketLabel(b: number): string { + const start = timeToMinutes(form.getFieldValue("startTime") || "06:00"); + const width = Number(form.getFieldValue("bucketMinutes")) || 60; + const m = start + b * width; + const h = Math.floor(m / 60).toString().padStart(2, "0"); + const mm = (m % 60).toString().padStart(2, "0"); + return `${h}:${mm}`; + } + + /* ----- xlsx import --------------------------------------------------- */ + + const fileInputRef = useRef(null); + + async function handleImportFile(file: File) { + const buffer = await file.arrayBuffer(); + try { + const { parseContagemXlsx } = await import("~/admin/contagens/parser/xlsx-import"); + const { values, warnings } = parseContagemXlsx(buffer); + const merged = { ...defaultValues(), ...values } as NovaFormValues; + // form.reset(...) updates state but doesn't always rebroadcast to every + // Subscribe in our setup; setting each field explicitly is bulletproof. + (Object.keys(merged) as Array).forEach((key) => { + form.setFieldValue(key, merged[key] as never); + }); + // Auto-show hourly view when import brings per-bucket data. + const movsAreHourly = Object.values(values.movements ?? {}).some( + (arr) => Array.isArray(arr) && arr.length > 1, + ); + if (movsAreHourly) { + setMovementsView("hourly"); + setCharacteristicsView("hourly"); + } + if (warnings.length > 0) { + toast.warning("Importado com observações", { description: warnings.join(" · ") }); + } else { + toast.success("Planilha importada", { description: "Revise os campos antes de salvar." }); + } + } catch (err) { + const message = err instanceof Error ? err.message : "Erro ao ler a planilha."; + toast.error("Falha ao importar planilha", { description: message }); + } + } + + /* ----- render -------------------------------------------------------- */ + return ( -
+ { + e.preventDefault(); + form.handleSubmit(); + }} + className="space-y-6 max-w-5xl" + > + { + const file = e.target.files?.[0]; + if (file) handleImportFile(file); + e.target.value = ""; + }} + /> +
+
+

Importar de planilha

+

+ Carregue um arquivo .xlsx no template Ameciclo para preencher + automaticamente o formulário. Você pode revisar antes de salvar. +

+
+ +
+ {/* Local */} @@ -281,82 +356,119 @@ export function NovaContagemForm({ locations }: { locations: LocationOption[] }) -
- {(["existing", "new"] as const).map((m) => ( - - ))} -
+ s.values.locationMode}> + {(locationMode) => ( +
+ {(["existing", "new"] as const).map((m) => ( + + ))} +
+ )} +
- {form.locationMode === "existing" ? ( - - - - ) : ( - - set("locationName", e.target.value)} - placeholder="Cruzamento da Av. Caxangá com..." - required - /> - - )} + s.values.locationMode}> + {(locationMode) => + locationMode === "existing" ? ( + v !== null, "Selecione um ponto da lista."), + }} + > + {(field) => ( + + + + )} + + ) : ( + + {(field) => ( + + field.handleChange(e.target.value)} + placeholder="Cruzamento da Av. Caxangá com..." + /> + + )} + + ) + } + - {form.locationMode === "existing" && form.locationName && ( -
- Selecionado: - {form.locationName} -
- )} + [s.values.locationMode, s.values.locationName] as const}> + {([locationMode, locationName]) => + locationMode === "existing" && locationName ? ( +
+ Selecionado: + {locationName} +
+ ) : null + } +

Define quantas aproximações o ponto possui e os movimentos possíveis.

- set("topology", t)} - /> + s.values.topology}> + {(topology) => } +
- + + [s.values.topology, s.values.approaches, s.values.movements] as const + } + > + {([topology, approaches, movements]) => ( + + )} +
@@ -364,155 +476,396 @@ export function NovaContagemForm({ locations }: { locations: LocationOption[] }) Sessão de contagem - Quando a contagem foi feita. + + Quando a contagem foi feita e em que granularidade os dados são + registrados. + - - - set("date", e.target.value)} - required - /> - - + - set("start_time", e.target.value)} - required - /> - - ( + + field.handleChange(e.target.value)} + /> + + )} + + - set("end_time", e.target.value)} - required - /> - + {(field) => ( + + field.handleChange(e.target.value)} + /> + + )} + + + {(field) => ( + + field.handleChange(e.target.value)} + /> + + )} + + + {(field) => ( + + + + )} + {/* Resultados */} - - Resultados - - Os movimentos são editados clicando nas setas do diagrama acima. - Use a tabela quando precisar revisar todos os valores de uma vez. - + +
+ Resultados + + Edite movimentos clicando nas setas do diagrama ou — para fidelidade + hora a hora — alterne para "Por hora". + +
+
-
-
- Total de ciclistas - - {total.toLocaleString("pt-BR")} - -
- - set("max_hour_cyclists", e.target.value)} - /> - -
+ + [ + s.values.movements, + s.values.startTime, + s.values.endTime, + s.values.bucketMinutes, + ] as const + } + > + {([movements, startTime, endTime, bucketMinutes]) => { + const total = totalCyclistsFromBuckets(movements); + const bc = deriveBucketCount(startTime, endTime, bucketMinutes); + return ( +
+
+ Total de ciclistas + + {total.toLocaleString("pt-BR")} + +
+
+ Buckets + + {bc > 0 ? `${bc} × ${bucketMinutes}min` : "—"} + +
+
+ ); + }} +
- {submitted && errors.movements && ( -

{errors.movements}

+ {movementsView === "totals" ? ( +
+ + Matriz de totais + expandir + recolher + +
+ [s.values.approaches, s.values.movements] as const} + > + {([approaches, movements]) => ( + + )} + +
+
+ ) : ( + + [ + s.values.topology, + s.values.approaches, + s.values.movements, + s.values.startTime, + s.values.endTime, + s.values.bucketMinutes, + ] as const + } + > + {([topology, approaches, movements, startTime, endTime, bucketMinutes]) => { + const bc = deriveBucketCount(startTime, endTime, bucketMinutes); + return ( + + ); + }} + )} -
- - Ver matriz completa - expandir - recolher - -
- -
-
+ + {(field) => ( + + field.handleChange(e.target.value)} + /> + + )} +
{/* Características */} - - Características - - - - Cada característica é a contagem de ciclistas observados naquele - perfil/comportamento. Não precisam somar exatamente o total — - uma mesma pessoa pode entrar em várias categorias. - - + +
+ Características + + + + Cada característica é a contagem de ciclistas observados naquele + perfil/comportamento. Não precisam somar exatamente o total — uma + mesma pessoa pode entrar em várias categorias. + + +
+
- {(["perfil", "comportamento", "modal", "ambiente"] as const).map((group) => ( -
-

- {GROUP_LABELS[group]} -

-
- {CHARACTERISTICS_FIELDS.filter((f) => f.group === group).map((f) => ( - - setChar(f.key, e.target.value)} - /> - - ))} -
-
- ))} - -
- Soma das características vs. total - - - {charsSum.toLocaleString("pt-BR")} / {total.toLocaleString("pt-BR")} - - {total > 0 && charsSum > total && ( - - Soma maior que o total — confira se é esperado. - - )} - + {characteristicsView === "totals" ? ( + CHARACTERISTIC_GROUPS.map(({ group, label, rolledUpAs }) => { + const keys = characteristicsInGroup(group); + if (keys.length === 0) return null; + return ( +
+
+

+ {label} +

+ {rolledUpAs && ( + s.values.characteristics}> + {(characteristics) => { + const sum = keys.reduce( + (acc, k) => acc + sumStringArray(characteristics[k]), + 0, + ); + return ( + + {rolledUpAs}{": "} + + {sum.toLocaleString("pt-BR")} + + + ); + }} + + )} +
+
+ {keys.map((k) => ( + s.values.characteristics[k]} + > + {(arr) => ( + + 0 ? String(sumStringArray(arr)) : ""} + onChange={(e) => setCharTotal(k, e.target.value)} + /> + + )} + + ))} +
+
+ ); + }) + ) : ( + + [ + s.values.characteristics, + s.values.startTime, + s.values.endTime, + s.values.bucketMinutes, + ] as const + } + > + {([characteristics, startTime, endTime, bucketMinutes]) => { + const bc = deriveBucketCount(startTime, endTime, bucketMinutes); + return ( + + ); + }} + + )} + + + + {/* Outros */} + + +
+ Outros + + Observações pontuais que não estão na taxonomia padrão (ex: "corte + de caminho pela calçada do posto"). +
+ + {(field) => ( + + )} + +
+ + + {(field) => + field.state.value.length === 0 ? ( +

+ Nenhuma observação adicional. Use o botão acima para registrar uma. +

+ ) : ( +
+ {field.state.value.map((row, i) => ( +
+ + {(sub) => ( + + sub.handleChange(e.target.value)} + placeholder="Corte de caminho pela calçada do posto" + /> + + )} + + + 0 ? String(sumStringArray(row.counts)) : ""} + onChange={(e) => { + const bc = Math.max(getCurrentBucketCount(), 1); + const arr = emptyBucketArray(bc); + arr[0] = e.target.value; + const all = form.getFieldValue("outros") ?? []; + const next = [...all]; + next[i] = { ...next[i], counts: arr }; + form.setFieldValue("outros", next); + }} + /> + + +
+ ))} +
+ ) + } +
@@ -523,27 +876,37 @@ export function NovaContagemForm({ locations }: { locations: LocationOption[] }) Contexto opcional sobre a contagem. - - set("weather_conditions", e.target.value)} - placeholder="Ensolarado, ~28 °C" - /> - - -