diff --git a/app/routes.ts b/app/routes.ts index 4ba9f72..dd1e58f 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -9,6 +9,7 @@ export default [ layout("routes/authenticated/_layout.tsx", [ index("routes/authenticated/dashboard.tsx"), route("reviews", "routes/authenticated/reviews/index.tsx"), + route("reviews/:id", "routes/authenticated/reviews/detail.tsx"), route("logout", "routes/authenticated/logout.tsx"), ]), route("auth", "routes/unauthenticated/_layout.tsx", [ diff --git a/app/routes/authenticated/reviews/detail.tsx b/app/routes/authenticated/reviews/detail.tsx new file mode 100644 index 0000000..d6c7acf --- /dev/null +++ b/app/routes/authenticated/reviews/detail.tsx @@ -0,0 +1,793 @@ +import { ArrowBack } from "@mui/icons-material"; +import { + Alert, + Autocomplete, + Box, + Button, + Chip, + MenuItem, + Paper, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableRow, + TextField, + Typography, +} from "@mui/material"; +import { useEffect, useState } from "react"; +import { + type ActionFunction, + data, + Form, + type LoaderFunction, + Link as RouterLink, + redirect, + useActionData, + useFetcher, + useLoaderData, +} from "react-router"; +import { getDatabaseClient } from "~/api/client.server"; +import { getResourceRevisionAPI } from "~/api/resource-revisions/methods"; +import { getResourceEntryAPI } from "~/api/resources/methods"; +import { authMiddleware } from "~/middleware/auth"; +import type { + BathroomTag, + DispenserType, + DistributionType, + EntryType, + FoodType, + ForageTag, + ForageType, + OrganizationType, + ResourceEntry, + ResourceType, + WaterTag, +} from "~/types/ResourceEntry"; +import type { ResourceRevision } from "~/types/ResourceRevision"; +import { statusChipColor } from "~/utils/chipColors"; + +export const middleware = [authMiddleware]; + +const RESOURCE_TYPES: ResourceType[] = ["WATER", "FOOD", "FORAGE", "BATHROOM"]; +const ENTRY_TYPES: EntryType[] = ["OPEN", "RESTRICTED", "UNSURE"]; +const DISPENSER_TYPES: DispenserType[] = [ + "DRINKING_FOUNTAIN", + "BOTTLE_FILLER", + "SINK", + "JUG", + "SODA_MACHINE", + "PITCHER", + "WATER_COOLER", +]; +const WATER_TAGS: WaterTag[] = [ + "WHEELCHAIR_ACCESSIBLE", + "FILTERED", + "BYOB", + "ID_REQUIRED", +]; +const FOOD_TYPES: FoodType[] = ["PERISHABLE", "NON_PERISHABLE", "PREPARED"]; +const DISTRIBUTION_TYPES: DistributionType[] = [ + "EAT_ON_SITE", + "DELIVERY", + "PICKUP", +]; +const ORGANIZATION_TYPES: OrganizationType[] = [ + "GOVERNMENT", + "BUSINESS", + "NON_PROFIT", + "UNSURE", +]; +const FORAGE_TYPES: ForageType[] = [ + "NUT", + "FRUIT", + "LEAVES", + "BARK", + "FLOWERS", +]; +const FORAGE_TAGS: ForageTag[] = ["MEDICINAL", "IN_SEASON", "COMMUNITY_GARDEN"]; +const BATHROOM_TAGS: BathroomTag[] = [ + "WHEELCHAIR_ACCESSIBLE", + "GENDER_NEUTRAL", + "CHANGING_TABLE", + "SINGLE_OCCUPANCY", + "FAMILY", +]; + +const formatValue = (value: unknown): string => { + if (value === null || value === undefined || value === "") { + return "—"; + } + if (typeof value === "object") { + return JSON.stringify(value); + } + return String(value); +}; + +type EditableValues = { + name: string; + resource_type: ResourceType; + entry_type: EntryType | ""; + address: string; + city: string; + state: string; + zip_code: string; + latitude: number; + longitude: number; + description: string; + guidelines: string; + water: { dispenser_type: DispenserType[]; tags: WaterTag[] }; + food: { + food_type: FoodType[]; + distribution_type: DistributionType[]; + organization_type: OrganizationType[]; + organization_name: string; + organization_url: string; + }; + forage: { forage_type: ForageType[]; tags: ForageTag[] }; + bathroom: { tags: BathroomTag[] }; +}; + +const toEditableValues = (revision: ResourceRevision): EditableValues => ({ + name: revision.name ?? "", + resource_type: revision.resource_type, + entry_type: revision.entry_type ?? "", + address: revision.address ?? "", + city: revision.city ?? "", + state: revision.state ?? "", + zip_code: revision.zip_code ?? "", + latitude: revision.latitude, + longitude: revision.longitude, + description: revision.description ?? "", + guidelines: revision.guidelines ?? "", + water: { + dispenser_type: revision.water?.dispenser_type ?? [], + tags: revision.water?.tags ?? [], + }, + food: { + food_type: revision.food?.food_type ?? [], + distribution_type: revision.food?.distribution_type ?? [], + organization_type: revision.food?.organization_type ?? [], + organization_name: revision.food?.organization_name ?? "", + organization_url: revision.food?.organization_url ?? "", + }, + forage: { + forage_type: revision.forage?.forage_type ?? [], + tags: revision.forage?.tags ?? [], + }, + bathroom: { + tags: revision.bathroom?.tags ?? [], + }, +}); + +export const loader: LoaderFunction = async ({ request, params }) => { + const id = Number(params.id); + if (!Number.isInteger(id)) { + throw data("Not found", { status: 404 }); + } + + const { client } = getDatabaseClient(request); + const revisionAPI = getResourceRevisionAPI(client); + const revision = await revisionAPI.getById(id); + + let resource: ResourceEntry | null = null; + try { + const resourceAPI = getResourceEntryAPI(client); + resource = await resourceAPI.getById(String(revision.mapped_resources)); + } catch { + resource = null; + } + + return { revision, resource }; +}; + +export const action: ActionFunction = async ({ request, params }) => { + const id = Number(params.id); + if (!Number.isInteger(id)) { + throw data("Not found", { status: 404 }); + } + + const { client, headers } = getDatabaseClient(request); + const revisionAPI = getResourceRevisionAPI(client); + const contentType = request.headers.get("content-type") || ""; + + if (contentType.includes("application/json")) { + const body = await request.json(); + + if (body.intent !== "save") { + return data({ message: "Unknown action" }, { status: 400 }); + } + + try { + const updated = await revisionAPI.updateFields(id, body.values); + return data({ message: "Changes saved.", ok: true, revision: updated }); + } catch { + return data({ message: "Failed to save changes." }, { status: 400 }); + } + } + + const formData = await request.formData(); + const intent = formData.get("intent"); + + if (intent !== "approve" && intent !== "reject") { + return data({ message: "Unknown action" }, { status: 400 }); + } + + await revisionAPI.updateStatus( + id, + intent === "approve" ? "APPROVED" : "REJECTED", + ); + + return redirect("/reviews", { headers }); +}; + +const ReviewDetail = () => { + const { revision, resource } = useLoaderData<{ + revision: ResourceRevision; + resource: ResourceEntry | null; + }>(); + const actionData = useActionData<{ message?: string }>(); + const fetcher = useFetcher<{ message?: string; ok?: boolean }>(); + + const [values, setValues] = useState(() => + toEditableValues(revision), + ); + + useEffect(() => { + setValues(toEditableValues(revision)); + }, [revision]); + + const isPending = revision.status === "PENDING"; + const isSaving = fetcher.state !== "idle"; + + const setField = ( + key: K, + value: EditableValues[K], + ) => setValues((v) => ({ ...v, [key]: value })); + + const setWater = ( + key: K, + value: EditableValues["water"][K], + ) => setValues((v) => ({ ...v, water: { ...v.water, [key]: value } })); + + const setFood = ( + key: K, + value: EditableValues["food"][K], + ) => setValues((v) => ({ ...v, food: { ...v.food, [key]: value } })); + + const setForage = ( + key: K, + value: EditableValues["forage"][K], + ) => setValues((v) => ({ ...v, forage: { ...v.forage, [key]: value } })); + + const setBathroom = ( + key: K, + value: EditableValues["bathroom"][K], + ) => setValues((v) => ({ ...v, bathroom: { ...v.bathroom, [key]: value } })); + + const handleSave = () => { + const payload: Partial = { + name: values.name || null, + resource_type: values.resource_type, + entry_type: values.entry_type || null, + address: values.address || null, + city: values.city || null, + state: values.state || null, + zip_code: values.zip_code || null, + latitude: values.latitude, + longitude: values.longitude, + description: values.description || null, + guidelines: values.guidelines || null, + water: values.resource_type === "WATER" ? values.water : null, + food: + values.resource_type === "FOOD" + ? { + ...values.food, + organization_name: values.food.organization_name || undefined, + organization_url: values.food.organization_url || undefined, + } + : null, + forage: values.resource_type === "FORAGE" ? values.forage : null, + bathroom: values.resource_type === "BATHROOM" ? values.bathroom : null, + }; + + // biome-ignore lint/suspicious/noExplicitAny: react-router's JsonValue submit-target type isn't exported to cast against + fetcher.submit({ intent: "save", values: payload } as any, { + method: "post", + encType: "application/json", + }); + }; + + return ( + + + + + Back to review queue + + + + + Review proposed edit + + + + + Submitted{" "} + {new Date(revision.date_created).toLocaleString(undefined, { + dateStyle: "medium", + timeStyle: "short", + })} + {revision.creator ? ` by ${revision.creator}` : ""} + + + + + {!resource && ( + + The resource this revision targets (#{revision.mapped_resources}) + could not be found — it may have been deleted. + + )} + + + + + + Field + Current + Proposed + + + + Name + {formatValue(resource?.name)} + + setField("name", e.target.value)} + /> + + + + + + Resource type + + {formatValue(resource?.resource_type)} + + + setField("resource_type", e.target.value as ResourceType) + } + > + {RESOURCE_TYPES.map((type) => ( + + {type} + + ))} + + + + + + Entry type + {formatValue(resource?.entry_type)} + + + setField("entry_type", e.target.value as EntryType) + } + > + {ENTRY_TYPES.map((type) => ( + + {type} + + ))} + + + + + + + Description + + {formatValue(resource?.description)} + + setField("description", e.target.value)} + /> + + + + + Guidelines + {formatValue(resource?.guidelines)} + + setField("guidelines", e.target.value)} + /> + + + + + Address + {formatValue(resource?.address)} + + setField("address", e.target.value)} + /> + + + + + City + {formatValue(resource?.city)} + + setField("city", e.target.value)} + /> + + + + + State + {formatValue(resource?.state)} + + setField("state", e.target.value)} + /> + + + + + Zip code + {formatValue(resource?.zip_code)} + + setField("zip_code", e.target.value)} + /> + + + + + Latitude + {formatValue(resource?.latitude)} + + setField("latitude", Number(e.target.value))} + /> + + + + + Longitude + {formatValue(resource?.longitude)} + + + setField("longitude", Number(e.target.value)) + } + /> + + + + + Hours + {formatValue(resource?.hours)} + {formatValue(revision.hours)} + + + + Images + {formatValue(resource?.images)} + {formatValue(revision.images)} + + +
+
+ + {values.resource_type === "WATER" && ( + + + Water details + + + + + Current + + + {formatValue(resource?.water)} + + + + setWater("dispenser_type", val)} + renderInput={(params) => ( + + )} + /> + setWater("tags", val)} + renderInput={(params) => } + /> + + + + )} + + {values.resource_type === "FOOD" && ( + + + Food details + + + + + Current + + + {formatValue(resource?.food)} + + + + setFood("food_type", val)} + renderInput={(params) => ( + + )} + /> + setFood("distribution_type", val)} + renderInput={(params) => ( + + )} + /> + setFood("organization_type", val)} + renderInput={(params) => ( + + )} + /> + setFood("organization_name", e.target.value)} + /> + setFood("organization_url", e.target.value)} + /> + + + + )} + + {values.resource_type === "FORAGE" && ( + + + Forage details + + + + + Current + + + {formatValue(resource?.forage)} + + + + setForage("forage_type", val)} + renderInput={(params) => ( + + )} + /> + setForage("tags", val)} + renderInput={(params) => } + /> + + + + )} + + {values.resource_type === "BATHROOM" && ( + + + Bathroom details + + + + + Current + + + {formatValue(resource?.bathroom)} + + + + setBathroom("tags", val)} + renderInput={(params) => } + /> + + + + )} + + {fetcher.data?.message && ( + + {fetcher.data.message} + + )} + + {actionData?.message && ( + {actionData.message} + )} + + {isPending && ( + + + +
+ + + + +
+
+ )} +
+ ); +}; + +export default ReviewDetail;