diff --git a/app/components/Admin/LocationCombobox.tsx b/app/components/Admin/LocationCombobox.tsx new file mode 100644 index 00000000..a475b0bd --- /dev/null +++ b/app/components/Admin/LocationCombobox.tsx @@ -0,0 +1,97 @@ +import { useState } from "react"; +import { Check, ChevronsUpDown, MapPin } from "lucide-react"; +import { Button } from "~/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "~/components/ui/command"; +import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover"; +import { cn } from "~/lib/utils"; + +export type LocationOption = { + id: number | string; + name: string; + city?: string; +}; + +type Props = { + options: LocationOption[]; + value: number | string | null; + onChange: (id: number | string | null) => void; + placeholder?: string; + disabled?: boolean; +}; + +export function LocationCombobox({ + options, + value, + onChange, + placeholder = "Selecione um ponto de contagem...", + disabled, +}: Props) { + const [open, setOpen] = useState(false); + const selected = options.find((o) => String(o.id) === String(value)); + + return ( + + + + + + + + + Nenhum ponto encontrado. + + {options.map((opt) => { + const isSelected = String(opt.id) === String(value); + return ( + { + onChange(isSelected ? null : opt.id); + setOpen(false); + }} + > + +
+ {opt.name} + {opt.city && ( + {opt.city} + )} +
+
+ ); + })} +
+
+
+
+
+ ); +} diff --git a/app/components/Admin/NovaContagemForm.tsx b/app/components/Admin/NovaContagemForm.tsx new file mode 100644 index 00000000..9a8c6514 --- /dev/null +++ b/app/components/Admin/NovaContagemForm.tsx @@ -0,0 +1,636 @@ +import { useState, useMemo, useEffect } from "react"; +import { Link } from "@tanstack/react-router"; +import { ArrowLeft, Save, Info } from "lucide-react"; +import { toast } from "sonner"; +import { Button } from "~/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "~/components/ui/card"; +import { Input } from "~/components/ui/input"; +import { Label } from "~/components/ui/label"; +import { Textarea } from "~/components/ui/textarea"; +import { Badge } from "~/components/ui/badge"; +import { + LocationCombobox, + type LocationOption, +} from "~/components/Admin/LocationCombobox"; +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 { 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; +}; + +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 { + const topology: Topology = "crossroad"; + const approachCount = TOPOLOGY_DIRECTIONS[topology].length; + return { + locationMode: "existing", + existingLocationId: null, + locationName: "", + topology, + approaches: Array.from({ length: approachCount }, () => ""), + movements: emptyMovements(approachCount), + date: "", + start_time: "", + end_time: "", + max_hour_cyclists: "", + weather_conditions: "", + notes: "", + characteristics: EMPTY_CHARACTERISTICS, + }; +} + +function toIntOrZero(s: string): number { + const n = Number(s); + return Number.isFinite(n) ? Math.max(0, Math.trunc(n)) : 0; +} + +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 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 })); + } + + function setApproach(i: number, value: string) { + setForm((s) => { + const approaches = [...s.approaches]; + approaches[i] = value; + return { ...s, approaches }; + }); + } + + function setMovement(key: string, value: string) { + setForm((s) => ({ ...s, movements: { ...s.movements, [key]: value } })); + } + + function setChar(key: keyof Characteristics, value: string) { + setForm((s) => ({ + ...s, + characteristics: { ...s.characteristics, [key]: value }, + })); + } + + function changeMode(mode: LocationMode) { + setForm((s) => ({ + ...s, + locationMode: mode, + // Reset cross-mode fields so they don't leak between flows. + existingLocationId: null, + locationName: "", + })); + } + + 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 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.", + }); + } + + return ( +
+ {/* Local */} + + + Local + + Ponto onde a contagem foi realizada. Reaproveite um existente ou + cadastre um novo. + + + +
+ {(["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} +
+ )} + +
+ +

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

+ set("topology", t)} + /> +
+ +
+
+ +

+ Nomeie cada perna do {form.topology === "point" ? "trecho" : "cruzamento"}. + Os nomes aparecem no diagrama e nos cabeçalhos da matriz. +

+
+ {form.approaches.map((label, idx) => ( +
+ + {idx + 1} + + setApproach(idx, e.target.value)} + placeholder={`Aproximação ${idx + 1}`} + aria-label={`Nome da aproximação ${idx + 1}`} + /> +
+ ))} +
+
+ + +
+
+
+ + {/* Sessão */} + + + Sessão de contagem + 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 + /> + + + + + {/* Resultados */} + + + Resultados + + Movimentos contados de cada aproximação para as demais (sem retorno + na mesma perna). O total é calculado automaticamente. + + + + + {submitted && errors.movements && ( +

{errors.movements}

+ )} + +
+
+ Total de ciclistas + + {total.toLocaleString("pt-BR")} + +
+ + set("max_hour_cyclists", 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. + + + + + {(["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. + + )} + +
+
+
+ + {/* Observações */} + + + Observações + Contexto opcional sobre a contagem. + + + + set("weather_conditions", e.target.value)} + placeholder="Ensolarado, ~28 °C" + /> + + +