From 3c603ffd7763e1f9196758df717b4a8a37b089ac Mon Sep 17 00:00:00 2001 From: Pedro Paes Date: Sat, 2 May 2026 19:50:36 -0300 Subject: [PATCH 1/6] feat(admin/contagens): persistence layer (Drizzle + Zod, SQLite for dev) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stands up the data layer for contagens. Local dev runs against SQLite via Cloudflare's local D1 binding; production will swap to Postgres + Hyperdrive near the end of this PR (sqliteTable -> pgTable, drizzle-orm/d1 -> drizzle-orm/postgres-js, wrangler d1 binding -> hyperdrive). All Zod schemas, the form, and the server function are dialect-independent. Schema (`contagem`) - Real columns: id, local_name, started_at (ISO 8601 wall-clock), timezone (default America/Recife), bucket_minutes, bucket_count, latitude, longitude, topology, notes, schema (variant tag), data (jsonb-as-text), and timestamps. - Generated columns derived from `data.totals.*` for cheap list-view filters: total_cyclists, peak_bucket_count. - UNIQUE (local_name, date(started_at)) — one count per place per day, per the user spec; cross-year repeats are fine. JSON shape (Zod, app/admin/contagens/schema/contagem-data.ts) - Discriminated union on `schema`. v1: `ameciclo.v1` with approaches (snapshot of labels), per-bucket movements & characteristics, per-session outros, bucket-scoped notes, and totals. - Canonical characteristic taxonomy with parent_key for rollups (caronas/cargueiras/servicos/contramaos derive from leaves). Form (TanStack Form + Zod) - Migrated from useState to @tanstack/react-form so the rest of the TanStack stack is consistent. - Removed the topology-sync useEffect: approaches/movements are reset inline in the topology-change handler (event-driven, no post-render side effect). - Field-level validation via Zod resolver; submit calls the createContagem server fn. Server fn - app/admin/contagens/server/createContagem.ts — Zod-validated input, computes totals, inserts via Drizzle. End-to-end tested locally: form submission produces a row with derived totals correctly populated from the JSON path. Local-dev wiring - wrangler.jsonc: D1 binding (DB) for local-only — no database_id, no remote attachment. The on-disk file lives under .wrangler/state/v3/d1/. - pnpm db:generate → drizzle-kit migration generation - pnpm db:migrate → wrangler d1 migrations apply --local - pnpm db:studio → drizzle-kit studio - .gitignore: .wrangler/ and *.xlsx (reference spreadsheets stay local) Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 4 + app/admin/contagens/schema/contagem-data.ts | 186 +++++ app/admin/contagens/schema/nova-form.ts | 82 ++ app/admin/contagens/server/createContagem.ts | 68 ++ app/components/Admin/NovaContagemForm.tsx | 766 ++++++++++--------- app/db/client.ts | 29 + app/db/schema.ts | 76 ++ drizzle.config.ts | 17 + drizzle/0000_careful_risque.sql | 20 + drizzle/meta/0000_snapshot.json | 169 ++++ drizzle/meta/_journal.json | 13 + package.json | 9 +- pnpm-lock.yaml | 714 ++++++++++++++++- wrangler.jsonc | 15 + 14 files changed, 1786 insertions(+), 382 deletions(-) create mode 100644 app/admin/contagens/schema/contagem-data.ts create mode 100644 app/admin/contagens/schema/nova-form.ts create mode 100644 app/admin/contagens/server/createContagem.ts create mode 100644 app/db/client.ts create mode 100644 app/db/schema.ts create mode 100644 drizzle.config.ts create mode 100644 drizzle/0000_careful_risque.sql create mode 100644 drizzle/meta/0000_snapshot.json create mode 100644 drizzle/meta/_journal.json 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/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..9d132c06 --- /dev/null +++ b/app/admin/contagens/schema/nova-form.ts @@ -0,0 +1,82 @@ +import { z } from "zod"; +import { TOPOLOGY_DIRECTIONS } from "~/components/Admin/topology/types"; + +/** + * Form-side Zod schema for the Nova Contagem form. Holds RHF state directly. + * Numeric inputs are kept as strings so empty fields render naturally; we + * coerce to ints only when submitting to the server fn. + */ +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(), z.string()), + 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."), + maxHourCyclists: z.string().default(""), + weatherConditions: z.string().default(""), + notes: z.string().default(""), + characteristics: z.record(z.string(), z.string()).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}.`, + }); + } + // Total > 0 from movements + let total = 0; + for (const val of Object.values(v.movements)) { + const n = Number(val); + 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.", + }); + } + }); + +export type NovaFormValues = z.infer; + +/** All canonical characteristic keys we render fields for. */ +export const CHARACTERISTIC_KEYS = [ + "women", + "juveniles", + "ride", + "helmet", + "wrong_way", + "sidewalk", + "service", + "cargo", + "shared_bike", + "motor", + "other_active_modes", + "rain", + "other_behaviors", + "others", +] as const; +export type CharacteristicKey = (typeof CHARACTERISTIC_KEYS)[number]; 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..e6a5bc78 100644 --- a/app/components/Admin/NovaContagemForm.tsx +++ b/app/components/Admin/NovaContagemForm.tsx @@ -1,7 +1,8 @@ -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 { ArrowLeft, Save, Info, Loader2 } from "lucide-react"; import { toast } from "sonner"; +import { useForm } from "@tanstack/react-form"; + import { Button } from "~/components/ui/button"; import { Card, @@ -25,30 +26,19 @@ import { TOPOLOGY_DIRECTIONS, emptyMovements, totalCyclists, - type Movements, type Topology, } from "~/components/Admin/topology/types"; import { cn } from "~/lib/utils"; - -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; -}; +import { createContagem } from "~/admin/contagens/server/createContagem"; +import { + NovaFormSchema, + CHARACTERISTIC_KEYS, + type CharacteristicKey, + type NovaFormValues, +} from "~/admin/contagens/schema/nova-form"; const CHARACTERISTICS_FIELDS: Array<{ - key: keyof Characteristics; + key: CharacteristicKey; label: string; group: "perfil" | "comportamento" | "modal" | "ambiente"; }> = [ @@ -75,42 +65,7 @@ const GROUP_LABELS: Record = { 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 { @@ -121,12 +76,14 @@ function initialState(): FormState { approaches: Array.from({ length: approachCount }, () => ""), movements: emptyMovements(approachCount), date: "", - start_time: "", - end_time: "", - max_hour_cyclists: "", - weather_conditions: "", + startTime: "", + endTime: "", + maxHourCyclists: "", + weatherConditions: "", notes: "", - characteristics: EMPTY_CHARACTERISTICS, + characteristics: Object.fromEntries( + CHARACTERISTIC_KEYS.map((k) => [k, ""]), + ) as Record, }; } @@ -135,142 +92,112 @@ function toIntOrZero(s: string): number { return Number.isFinite(n) ? Math.max(0, Math.trunc(n)) : 0; } +function diffMinutes(a: string, b: string): number { + if (!a || !b) return 0; + const [ah, am] = a.split(":").map(Number); + const [bh, bm] = b.split(":").map(Number); + return bh * 60 + bm - (ah * 60 + am); +} + 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] ?? "", - ); - 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 router = useRouter(); - 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]); - - const hasErrors = Object.keys(errors).length > 0; - - function set(key: K, value: FormState[K]) { - setForm((s) => ({ ...s, [key]: value })); - } + const form = useForm({ + defaultValues: defaultValues(), + validators: { onSubmit: NovaFormSchema }, + onSubmit: async ({ value }) => { + const sessionMinutes = diffMinutes(value.startTime, value.endTime); + const startedAt = `${value.date}T${value.startTime}:00`; - function setApproach(i: number, value: string) { - setForm((s) => { - const approaches = [...s.approaches]; - approaches[i] = value; - return { ...s, approaches }; - }); - } + const movementsArrays: Record = Object.fromEntries( + Object.entries(value.movements).map(([k, v]) => [k, [toIntOrZero(v)]]), + ); + const characteristicsArrays: Record = Object.fromEntries( + Object.entries(value.characteristics) + .map(([k, v]) => [k, [toIntOrZero(v)]] as const) + .filter(([, [n]]) => n > 0), + ); - function setMovement(key: string, value: string) { - setForm((s) => ({ ...s, movements: { ...s.movements, [key]: value } })); + try { + const result = await createContagem({ + data: { + localName: value.locationName, + startedAt, + timezone: "America/Recife", + bucketMinutes: sessionMinutes > 0 ? sessionMinutes : 60, + bucketCount: 1, + latitude: null, + longitude: null, + topology: value.topology, + notes: value.notes || null, + data: { + approaches: value.approaches, + movements: movementsArrays, + characteristics: characteristicsArrays, + outros: [], + 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 }); + } + }, + }); + + /* ----- mode + topology change handlers (no useEffect) ---------------- */ + + function onModeChange(mode: NovaFormValues["locationMode"]) { + form.setFieldValue("locationMode", mode); + form.setFieldValue("existingLocationId", null); + form.setFieldValue("locationName", ""); } - function setChar(key: keyof Characteristics, value: string) { - setForm((s) => ({ - ...s, - characteristics: { ...s.characteristics, [key]: value }, - })); + 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 changeMode(mode: LocationMode) { - setForm((s) => ({ - ...s, - locationMode: mode, - // Reset cross-mode fields so they don't leak between flows. - existingLocationId: null, - locationName: "", - })); + 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 by a + // topology change. + form.setFieldValue("movements", emptyMovements(target)); } - 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 onApproachChange(idx: number, value: string) { + const next = [...(form.getFieldValue("approaches") ?? [])]; + next[idx] = value; + form.setFieldValue("approaches", next); } - function handleSubmit(event: React.FormEvent) { - event.preventDefault(); - setSubmitted(true); - if (hasErrors) { - toast.error("Confira os campos destacados."); - return; - } - - 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 onMovementChange(key: string, value: string) { + form.setFieldValue("movements", { + ...(form.getFieldValue("movements") ?? {}), + [key]: value, }); } return ( -
+ { + e.preventDefault(); + form.handleSubmit(); + }} + className="space-y-6 max-w-4xl" + > {/* Local */} @@ -281,82 +208,109 @@ export function NovaContagemForm({ locations }: { locations: LocationOption[] }) -
- {(["existing", "new"] as const).map((m) => ( - - ))} -
- - {form.locationMode === "existing" ? ( - - - - ) : ( - - set("locationName", e.target.value)} - placeholder="Cruzamento da Av. Caxangá com..." - required - /> - - )} - - {form.locationMode === "existing" && form.locationName && ( -
- Selecionado: - {form.locationName} -
- )} + s.values.locationMode}> + {(locationMode) => ( +
+ {(["existing", "new"] as const).map((m) => ( + + ))} +
+ )} +
+ + s.values.locationMode}> + {(locationMode) => + locationMode === "existing" ? ( + + {(field) => ( + + + + )} + + ) : ( + + {(field) => ( + + field.handleChange(e.target.value)} + placeholder="Cruzamento da Av. Caxangá com..." + /> + + )} + + ) + } + + + [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]) => ( + + )} +
@@ -367,43 +321,45 @@ export function NovaContagemForm({ locations }: { locations: LocationOption[] }) Quando a contagem foi feita. - - set("date", e.target.value)} - required - /> - - - set("start_time", e.target.value)} - required - /> - - - set("end_time", e.target.value)} - required - /> - + + {(field) => ( + + field.handleChange(e.target.value)} + /> + + )} + + + {(field) => ( + + field.handleChange(e.target.value)} + /> + + )} + + + {(field) => ( + + field.handleChange(e.target.value)} + /> + + )} + @@ -418,33 +374,38 @@ export function NovaContagemForm({ locations }: { locations: LocationOption[] })
-
- Total de ciclistas - - {total.toLocaleString("pt-BR")} - -
- - set("max_hour_cyclists", e.target.value)} - /> - + s.values.movements}> + {(movements) => ( +
+ Total de ciclistas + + {totalCyclists(movements).toLocaleString("pt-BR")} + +
+ )} +
+ + {(field) => ( + + field.handleChange(e.target.value)} + /> + + )} +
- {submitted && errors.movements && ( -

{errors.movements}

- )} -
Ver matriz completa @@ -452,11 +413,17 @@ export function NovaContagemForm({ locations }: { locations: LocationOption[] }) recolher
- + [s.values.approaches, s.values.movements] as const} + > + {([approaches, movements]) => ( + + )} +
@@ -483,36 +450,54 @@ export function NovaContagemForm({ locations }: { locations: LocationOption[] })
{CHARACTERISTICS_FIELDS.filter((f) => f.group === group).map((f) => ( - - setChar(f.key, e.target.value)} - /> - + + {(field) => ( + + field.handleChange(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. - - )} - -
+ [s.values.movements, s.values.characteristics] as const} + > + {([movements, characteristics]) => { + const total = totalCyclists(movements); + const charsSum = CHARACTERISTIC_KEYS.reduce( + (acc, k) => acc + toIntOrZero(characteristics[k] ?? ""), + 0, + ); + return ( +
+ 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. + + )} + +
+ ); + }} +
@@ -523,27 +508,37 @@ export function NovaContagemForm({ locations }: { locations: LocationOption[] }) Contexto opcional sobre a contagem. - - set("weather_conditions", e.target.value)} - placeholder="Ensolarado, ~28 °C" - /> - - -