feat: initial commit with seeded database and premium ui changes
This commit is contained in:
@@ -0,0 +1,772 @@
|
||||
# Religion Dialog Dropdown + Radius Fix Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Make the religion-POI edit dialog use the shared `dropdown-menu.tsx`
|
||||
component (matching the create dialog), let users edit a POI's radius, and
|
||||
fix the missing `radius_meters` DB column so radius circles actually render
|
||||
on the map.
|
||||
|
||||
**Architecture:** Extract the radius slider markup from `religion-dialog.tsx`
|
||||
into a shared `RadiusSlider` component, reuse it in both the create
|
||||
(`religion-dialog.tsx`) and edit (`marker-edit-dialog.tsx`) dialogs. Swap
|
||||
`marker-edit-dialog.tsx`'s religion-type field from the legacy `SelectDropdown`
|
||||
to the `dropdown-menu.tsx` primitives, expand its religion type list from 3 to
|
||||
7 entries to match the create dialog, and wire radius load/edit/save through
|
||||
to `onUpdated` so `leaflet-map.tsx` redraws the circle. Separately, rebuild the
|
||||
backend Docker container so `initDb()` adds the missing `radius_meters` column
|
||||
to the live database.
|
||||
|
||||
**Tech Stack:** Next.js 16 / React 19 (frontend), Express + `pg` (backend),
|
||||
Docker Compose, no test runner configured (verification via `eslint` +
|
||||
`tsc --noEmit` + manual check with the dev server).
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Extract shared `RadiusSlider` component
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/components/poverty/radius-slider.tsx`
|
||||
|
||||
- [ ] **Step 1: Create the component**
|
||||
|
||||
```tsx
|
||||
"use client";
|
||||
|
||||
interface RadiusSliderProps {
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
}
|
||||
|
||||
export function RadiusSlider({ value, onChange }: RadiusSliderProps) {
|
||||
const pct = (value / 2000) * 100;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={2000}
|
||||
step={50}
|
||||
value={value}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
style={{
|
||||
background: value === 0
|
||||
? "var(--border)"
|
||||
: `linear-gradient(to right, #10b981 0%, #10b981 ${pct}%, var(--border) ${pct}%, var(--border) 100%)`,
|
||||
}}
|
||||
className="
|
||||
flex-1 h-[3px] cursor-pointer appearance-none rounded-full outline-none
|
||||
[&::-webkit-slider-thumb]:appearance-none
|
||||
[&::-webkit-slider-thumb]:w-3
|
||||
[&::-webkit-slider-thumb]:h-3
|
||||
[&::-webkit-slider-thumb]:rounded-full
|
||||
[&::-webkit-slider-thumb]:bg-white
|
||||
[&::-webkit-slider-thumb]:shadow
|
||||
[&::-webkit-slider-thumb]:border
|
||||
[&::-webkit-slider-thumb]:border-border
|
||||
[&::-moz-range-thumb]:w-3
|
||||
[&::-moz-range-thumb]:h-3
|
||||
[&::-moz-range-thumb]:rounded-full
|
||||
[&::-moz-range-thumb]:bg-white
|
||||
[&::-moz-range-thumb]:border
|
||||
[&::-moz-range-thumb]:border-border
|
||||
"
|
||||
/>
|
||||
<span
|
||||
className="w-12 shrink-0 text-center text-xs font-bold tabular-nums"
|
||||
style={{ color: value === 0 ? "var(--muted-foreground)" : "#10b981" }}
|
||||
>
|
||||
{value === 0 ? "—" : `${value}m`}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">0 = tidak tampilkan lingkaran</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Lint check**
|
||||
|
||||
Run: `cd /Users/pangkywaradjodi/pba-new/frontend && npx eslint components/poverty/radius-slider.tsx`
|
||||
Expected: no output, exit code 0.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
cd /Users/pangkywaradjodi/pba-new/frontend
|
||||
git add components/poverty/radius-slider.tsx
|
||||
git commit -m "$(cat <<'EOF'
|
||||
Extract shared RadiusSlider component
|
||||
|
||||
Pulls the radius range-input markup out of religion-dialog.tsx so
|
||||
marker-edit-dialog.tsx can reuse it for radius editing.
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Use `RadiusSlider` in `religion-dialog.tsx`
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/components/poverty/religion-dialog.tsx`
|
||||
|
||||
- [ ] **Step 1: Add the import**
|
||||
|
||||
In `frontend/components/poverty/religion-dialog.tsx`, find:
|
||||
|
||||
```tsx
|
||||
import { PhosphorIcon } from "@/components/ui/collection-grid-disclosure";
|
||||
import { ChevronDownIcon } from "@/components/ui/phosphor-icons";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { UserMarker } from "@/components/leaflet-map";
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```tsx
|
||||
import { PhosphorIcon } from "@/components/ui/collection-grid-disclosure";
|
||||
import { ChevronDownIcon } from "@/components/ui/phosphor-icons";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { RadiusSlider } from "@/components/poverty/radius-slider";
|
||||
import type { UserMarker } from "@/components/leaflet-map";
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Replace the inline slider with `RadiusSlider`**
|
||||
|
||||
Find the `Field label="Radius Jangkauan (meter)"` block:
|
||||
|
||||
```tsx
|
||||
<Field label="Radius Jangkauan (meter)">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={2000}
|
||||
step={50}
|
||||
value={radiusMeters}
|
||||
onChange={(e) => setRadiusMeters(Number(e.target.value))}
|
||||
style={{
|
||||
background: radiusMeters === 0
|
||||
? "var(--border)"
|
||||
: `linear-gradient(to right, #10b981 0%, #10b981 ${(radiusMeters / 2000) * 100}%, var(--border) ${(radiusMeters / 2000) * 100}%, var(--border) 100%)`,
|
||||
}}
|
||||
className="
|
||||
flex-1 h-[3px] cursor-pointer appearance-none rounded-full outline-none
|
||||
[&::-webkit-slider-thumb]:appearance-none
|
||||
[&::-webkit-slider-thumb]:w-3
|
||||
[&::-webkit-slider-thumb]:h-3
|
||||
[&::-webkit-slider-thumb]:rounded-full
|
||||
[&::-webkit-slider-thumb]:bg-white
|
||||
[&::-webkit-slider-thumb]:shadow
|
||||
[&::-webkit-slider-thumb]:border
|
||||
[&::-webkit-slider-thumb]:border-border
|
||||
[&::-moz-range-thumb]:w-3
|
||||
[&::-moz-range-thumb]:h-3
|
||||
[&::-moz-range-thumb]:rounded-full
|
||||
[&::-moz-range-thumb]:bg-white
|
||||
[&::-moz-range-thumb]:border
|
||||
[&::-moz-range-thumb]:border-border
|
||||
"
|
||||
/>
|
||||
<span className="w-12 shrink-0 text-center text-xs font-bold tabular-nums" style={{ color: radiusMeters === 0 ? "var(--muted-foreground)" : "#10b981" }}>
|
||||
{radiusMeters === 0 ? "—" : `${radiusMeters}m`}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">0 = tidak tampilkan lingkaran</p>
|
||||
</div>
|
||||
</Field>
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```tsx
|
||||
<Field label="Radius Jangkauan (meter)">
|
||||
<RadiusSlider value={radiusMeters} onChange={setRadiusMeters} />
|
||||
</Field>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Lint check**
|
||||
|
||||
Run: `cd /Users/pangkywaradjodi/pba-new/frontend && npx eslint components/poverty/religion-dialog.tsx`
|
||||
Expected: no output, exit code 0.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
cd /Users/pangkywaradjodi/pba-new/frontend
|
||||
git add components/poverty/religion-dialog.tsx
|
||||
git commit -m "$(cat <<'EOF'
|
||||
Use shared RadiusSlider in religion-dialog
|
||||
|
||||
No behavior change, just dedup ahead of reusing the slider in the
|
||||
marker edit dialog.
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Update `marker-edit-dialog.tsx` — dropdown swap, expanded religion list, radius editing
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/components/poverty/marker-edit-dialog.tsx`
|
||||
|
||||
- [ ] **Step 1: Replace imports**
|
||||
|
||||
Find:
|
||||
|
||||
```tsx
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { SelectDropdown } from "@/components/ui/dropdown";
|
||||
import type { UserMarker } from "@/components/leaflet-map";
|
||||
import { TimedUndoAction } from "@/components/time-undo-action";
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```tsx
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { PhosphorIcon } from "@/components/ui/collection-grid-disclosure";
|
||||
import { ChevronDownIcon } from "@/components/ui/phosphor-icons";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { RadiusSlider } from "@/components/poverty/radius-slider";
|
||||
import type { UserMarker } from "@/components/leaflet-map";
|
||||
import { TimedUndoAction } from "@/components/time-undo-action";
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Expand `RELIGION_OPTIONS` to 7 entries and extend `MARKER_LABELS`**
|
||||
|
||||
Find:
|
||||
|
||||
```tsx
|
||||
const RELIGION_OPTIONS = [
|
||||
{ value: "mosque", label: "Masjid", icon: "mosque" },
|
||||
{ value: "church", label: "Gereja", icon: "church" },
|
||||
{ value: "synagogue", label: "Sinagog", icon: "synagogue" },
|
||||
];
|
||||
|
||||
const MARKER_LABELS: Record<string, string> = {
|
||||
marker: "Warga Miskin",
|
||||
mosque: "Masjid",
|
||||
church: "Gereja",
|
||||
synagogue: "Sinagog",
|
||||
clinic: "Klinik",
|
||||
"food-bank": "Bank Makanan",
|
||||
school: "Sekolah",
|
||||
};
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```tsx
|
||||
const RELIGION_OPTIONS = [
|
||||
{ value: "mosque", label: "Masjid (Islam)", icon: "mosque" },
|
||||
{ value: "church", label: "Gereja (Kristen)", icon: "church" },
|
||||
{ value: "cathedral", label: "Gereja Katolik", icon: "crown-cross" },
|
||||
{ value: "temple", label: "Pura (Hindu)", icon: "hands-praying" },
|
||||
{ value: "vihara", label: "Vihara (Buddha)", icon: "hands-praying" },
|
||||
{ value: "klenteng", label: "Klenteng (Konghucu)", icon: "hands-praying" },
|
||||
{ value: "synagogue", label: "Sinagog (Yahudi)", icon: "synagogue" },
|
||||
];
|
||||
|
||||
const MARKER_LABELS: Record<string, string> = {
|
||||
marker: "Warga Miskin",
|
||||
mosque: "Masjid",
|
||||
church: "Gereja",
|
||||
cathedral: "Gereja Katolik",
|
||||
temple: "Pura",
|
||||
vihara: "Vihara",
|
||||
klenteng: "Klenteng",
|
||||
synagogue: "Sinagog",
|
||||
clinic: "Klinik",
|
||||
"food-bank": "Bank Makanan",
|
||||
school: "Sekolah",
|
||||
};
|
||||
```
|
||||
|
||||
This also fixes a side issue: editing a cathedral/temple/vihara/klenteng POI
|
||||
previously showed no type selector at all because `isReligion` was `false`
|
||||
for those types (they weren't in the old 3-entry list).
|
||||
|
||||
- [ ] **Step 3: Add `radiusMeters` state and `selectedReligion` lookup**
|
||||
|
||||
Find:
|
||||
|
||||
```tsx
|
||||
// POI fields
|
||||
const [placeName, setPlaceName] = useState("");
|
||||
const [religionType, setReligionType] = useState<string | null>(null);
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```tsx
|
||||
// POI fields
|
||||
const [placeName, setPlaceName] = useState("");
|
||||
const [religionType, setReligionType] = useState<string | null>(null);
|
||||
const [radiusMeters, setRadiusMeters] = useState(0);
|
||||
|
||||
const selectedReligion = RELIGION_OPTIONS.find((o) => o.value === religionType);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Load `radius_meters` when the dialog opens**
|
||||
|
||||
Find:
|
||||
|
||||
```tsx
|
||||
} else if (isPOI) {
|
||||
fetch(`${BACKEND_URL}/poi/${dbId}`, { headers })
|
||||
.then((r) => r.json())
|
||||
.then((row: any) => {
|
||||
setPlaceName(row.name ?? "");
|
||||
setReligionType(row.religion_subtype ?? null);
|
||||
})
|
||||
.catch(() => setError("Gagal memuat data"))
|
||||
.finally(() => setIsLoading(false));
|
||||
}
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```tsx
|
||||
} else if (isPOI) {
|
||||
fetch(`${BACKEND_URL}/poi/${dbId}`, { headers })
|
||||
.then((r) => r.json())
|
||||
.then((row: any) => {
|
||||
setPlaceName(row.name ?? "");
|
||||
setReligionType(row.religion_subtype ?? null);
|
||||
setRadiusMeters(row.radius_meters ?? 0);
|
||||
})
|
||||
.catch(() => setError("Gagal memuat data"))
|
||||
.finally(() => setIsLoading(false));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Send `radius_meters` on save and update `meta.radius` for the map**
|
||||
|
||||
Find:
|
||||
|
||||
```tsx
|
||||
} else {
|
||||
const name = placeName.trim() || (marker.name);
|
||||
const body: any = { name };
|
||||
if (isReligion) { body.poi_type = "religion"; body.religion_subtype = religionType; }
|
||||
const res = await fetch(`${BACKEND_URL}/poi/${dbId}`, {
|
||||
method: "PUT", headers,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error ?? "Gagal menyimpan");
|
||||
onUpdated({ ...marker, name, ...(isReligion && religionType ? { type: religionType } : {}) });
|
||||
}
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```tsx
|
||||
} else {
|
||||
const name = placeName.trim() || (marker.name);
|
||||
const body: any = { name };
|
||||
if (isReligion) { body.poi_type = "religion"; body.religion_subtype = religionType; body.radius_meters = radiusMeters; }
|
||||
const res = await fetch(`${BACKEND_URL}/poi/${dbId}`, {
|
||||
method: "PUT", headers,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error ?? "Gagal menyimpan");
|
||||
onUpdated({
|
||||
...marker,
|
||||
name,
|
||||
...(isReligion && religionType ? { type: religionType } : {}),
|
||||
...(isReligion ? { meta: { ...marker.meta, radius: radiusMeters } } : {}),
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Swap the religion-type field to `DropdownMenu` and add the radius field**
|
||||
|
||||
Find:
|
||||
|
||||
```tsx
|
||||
) : (
|
||||
<>
|
||||
{isReligion && (
|
||||
<Field label="Jenis Tempat Ibadah">
|
||||
<SelectDropdown options={RELIGION_OPTIONS} value={religionType} onChange={setReligionType} />
|
||||
</Field>
|
||||
)}
|
||||
<Field label="Nama Tempat">
|
||||
<Input value={placeName} onChange={(e) => setPlaceName(e.target.value)} className="bg-muted/50 border border-border focus-visible:border-ring" />
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```tsx
|
||||
) : (
|
||||
<>
|
||||
{isReligion && (
|
||||
<Field label="Jenis Tempat Ibadah">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
className="flex w-full items-center justify-between gap-2 rounded-lg border border-border bg-muted/50 px-3 py-2 text-sm transition-colors hover:bg-muted focus-visible:border-ring focus-visible:outline-none"
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
{selectedReligion?.icon && (
|
||||
<span className="size-4 shrink-0 text-foreground">
|
||||
<PhosphorIcon name={selectedReligion.icon} className="size-4" />
|
||||
</span>
|
||||
)}
|
||||
<span className={cn("truncate", !selectedReligion && "text-muted-foreground")}>
|
||||
{selectedReligion ? selectedReligion.label : "Pilih jenis…"}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronDownIcon className="size-4 shrink-0 text-muted-foreground" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-(--anchor-width)">
|
||||
<DropdownMenuRadioGroup value={religionType ?? ""} onValueChange={setReligionType}>
|
||||
{RELIGION_OPTIONS.map((opt) => (
|
||||
<DropdownMenuRadioItem key={opt.value} value={opt.value}>
|
||||
<span className="size-4 shrink-0 text-foreground">
|
||||
<PhosphorIcon name={opt.icon} className="size-4" />
|
||||
</span>
|
||||
{opt.label}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</Field>
|
||||
)}
|
||||
<Field label="Nama Tempat">
|
||||
<Input value={placeName} onChange={(e) => setPlaceName(e.target.value)} className="bg-muted/50 border border-border focus-visible:border-ring" />
|
||||
</Field>
|
||||
{isReligion && (
|
||||
<Field label="Radius Jangkauan (meter)">
|
||||
<RadiusSlider value={radiusMeters} onChange={setRadiusMeters} />
|
||||
</Field>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Lint check**
|
||||
|
||||
Run: `cd /Users/pangkywaradjodi/pba-new/frontend && npx eslint components/poverty/marker-edit-dialog.tsx`
|
||||
Expected: no output, exit code 0.
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
cd /Users/pangkywaradjodi/pba-new/frontend
|
||||
git add components/poverty/marker-edit-dialog.tsx
|
||||
git commit -m "$(cat <<'EOF'
|
||||
Switch marker-edit-dialog to dropdown-menu + add radius editing
|
||||
|
||||
- Replace SelectDropdown with the shared dropdown-menu primitives,
|
||||
matching religion-dialog.tsx.
|
||||
- Expand religion type options from 3 to 7 (adds cathedral, temple,
|
||||
vihara, klenteng) so editing those POI types now shows a type
|
||||
selector at all.
|
||||
- Load radius_meters when the dialog opens, expose a radius slider
|
||||
for religion POIs, and send radius_meters on save while updating
|
||||
meta.radius so the map redraws the circle immediately.
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Remove the now-unused `SelectDropdown` from `ui/dropdown.tsx`
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/components/ui/dropdown.tsx`
|
||||
|
||||
This component (and its `SelectOption`/`dropdownSpring` helpers) was only
|
||||
used by `marker-edit-dialog.tsx`, which Task 3 migrated to `dropdown-menu.tsx`.
|
||||
|
||||
- [ ] **Step 1: Confirm no remaining usages**
|
||||
|
||||
Run: `cd /Users/pangkywaradjodi/pba-new/frontend && grep -rn "SelectDropdown\|SelectOption" --include="*.tsx" --include="*.ts" . | grep -v node_modules | grep -v .next`
|
||||
Expected: only matches inside `components/ui/dropdown.tsx` itself.
|
||||
|
||||
- [ ] **Step 2: Trim unused imports**
|
||||
|
||||
Find:
|
||||
|
||||
```tsx
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef, type FC, type ReactNode } from "react";
|
||||
import { motion, AnimatePresence, MotionConfig } from "motion/react";
|
||||
import { ChevronUpIcon } from "lucide-react";
|
||||
import { PhosphorIcon } from "@/components/ui/collection-grid-disclosure";
|
||||
import { cn } from "@/lib/utils";
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```tsx
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, type FC, type ReactNode } from "react";
|
||||
import { motion, AnimatePresence, MotionConfig } from "motion/react";
|
||||
import { ChevronUpIcon } from "lucide-react";
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Remove the `SelectDropdown` section**
|
||||
|
||||
Find:
|
||||
|
||||
```tsx
|
||||
// ─── Select Dropdown ────────────────────────────────────────────────────────
|
||||
|
||||
export interface SelectOption {
|
||||
value: string;
|
||||
label: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
interface SelectDropdownProps {
|
||||
options: SelectOption[];
|
||||
value: string | null;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const dropdownSpring = { type: "spring" as const, stiffness: 200, damping: 20, mass: 1.1 };
|
||||
|
||||
export const SelectDropdown: FC<SelectDropdownProps> = ({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "Select…",
|
||||
className,
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, [open]);
|
||||
|
||||
const selected = options.find((o) => o.value === value);
|
||||
|
||||
return (
|
||||
<MotionConfig transition={dropdownSpring}>
|
||||
<div ref={ref} className={cn("relative w-full", className)}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex w-full items-center justify-between gap-2 rounded-lg border border-border/60 bg-background/95 px-3 py-2 text-sm shadow-sm backdrop-blur-md transition-colors hover:bg-muted/50"
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{selected?.icon && (
|
||||
<span className="size-4 shrink-0 text-foreground">
|
||||
<PhosphorIcon name={selected.icon} className="size-4" />
|
||||
</span>
|
||||
)}
|
||||
<span className={cn("truncate", !selected && "text-muted-foreground")}>
|
||||
{selected ? selected.label : placeholder}
|
||||
</span>
|
||||
</div>
|
||||
<motion.span animate={{ rotate: open ? 180 : 0 }} className="shrink-0 text-muted-foreground">
|
||||
<ChevronUpIcon className="size-4" />
|
||||
</motion.span>
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -4, scaleY: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scaleY: 1 }}
|
||||
exit={{ opacity: 0, y: -4, scaleY: 0.95 }}
|
||||
style={{ originY: 0 }}
|
||||
className="absolute left-0 right-0 top-full z-50 mt-1 overflow-hidden rounded-lg border border-border/60 bg-background/95 shadow-lg backdrop-blur-md"
|
||||
>
|
||||
{options.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => { onChange(opt.value); setOpen(false); }}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-sm transition-colors hover:bg-muted/50"
|
||||
>
|
||||
{opt.icon && (
|
||||
<span className="size-4 shrink-0 text-foreground">
|
||||
<PhosphorIcon name={opt.icon} className="size-4" />
|
||||
</span>
|
||||
)}
|
||||
<span className="flex-1 text-left">{opt.label}</span>
|
||||
{value === opt.value && (
|
||||
<span className="size-3.5 shrink-0 text-primary">
|
||||
<PhosphorIcon name="check" className="size-3.5" />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</MotionConfig>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Activities Card (existing) ──────────────────────────────────────────────
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```tsx
|
||||
// ─── Activities Card (existing) ──────────────────────────────────────────────
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Lint + full type check**
|
||||
|
||||
Run: `cd /Users/pangkywaradjodi/pba-new/frontend && npx eslint components/ui/dropdown.tsx components/poverty/marker-edit-dialog.tsx components/poverty/religion-dialog.tsx components/poverty/radius-slider.tsx`
|
||||
Expected: no output, exit code 0.
|
||||
|
||||
Run: `cd /Users/pangkywaradjodi/pba-new/frontend && npx tsc --noEmit`
|
||||
Expected: no type errors (exit code 0). This catches any leftover references
|
||||
to `SelectDropdown`/`SelectOption` or mismatched `UserMarker`/`meta` types.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
cd /Users/pangkywaradjodi/pba-new/frontend
|
||||
git add components/ui/dropdown.tsx
|
||||
git commit -m "$(cat <<'EOF'
|
||||
Remove unused SelectDropdown from ui/dropdown
|
||||
|
||||
marker-edit-dialog.tsx was its only consumer and now uses
|
||||
dropdown-menu.tsx instead.
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Fix the missing `radius_meters` column on the live database
|
||||
|
||||
**Files:** none (infrastructure only — `db-init.js` and `poi.routes.js`
|
||||
already reference `radius_meters`, the running container is just stale)
|
||||
|
||||
- [ ] **Step 1: Confirm the column is currently missing**
|
||||
|
||||
Run: `docker exec pba-new-postgres-1 psql -U sig -d sig -c "\d poi_markers"`
|
||||
Expected (before fix): output does NOT contain a `radius_meters` row.
|
||||
|
||||
- [ ] **Step 2: Rebuild and restart the backend container**
|
||||
|
||||
Run: `cd /Users/pangkywaradjodi/pba-new && docker compose up -d --build backend`
|
||||
Expected: build succeeds, `pba-new-backend-1` recreated and running.
|
||||
|
||||
- [ ] **Step 3: Verify the column now exists**
|
||||
|
||||
Run: `docker exec pba-new-postgres-1 psql -U sig -d sig -c "\d poi_markers"`
|
||||
Expected: output now contains:
|
||||
|
||||
```
|
||||
radius_meters | integer | | | 0
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify the backend is healthy**
|
||||
|
||||
Run: `docker logs pba-new-backend-1 --tail 20`
|
||||
Expected: `Auth server running on port 4000`, no errors.
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Manual end-to-end verification
|
||||
|
||||
**Files:** none (manual QA against the running app)
|
||||
|
||||
- [ ] **Step 1: Start the frontend dev server**
|
||||
|
||||
Use the `/run` skill (or `cd /Users/pangkywaradjodi/pba-new/frontend && npm run dev`)
|
||||
to launch the app, then open the poverty dashboard and switch to the Leaflet
|
||||
map view (satellite or street, doesn't matter).
|
||||
|
||||
- [ ] **Step 2: Create a new religion POI with a radius**
|
||||
|
||||
Click the map to place a new place of worship. In the "Tambah Tempat Ibadah"
|
||||
dialog:
|
||||
- Pick a religion type from the dropdown (confirm it still uses the
|
||||
dropdown-menu styling/icons as before).
|
||||
- Set the radius slider to a non-zero value (e.g. 500m).
|
||||
- Save.
|
||||
|
||||
Expected: the marker appears on the map AND a faint dashed circle of the
|
||||
matching color appears around it with the chosen radius.
|
||||
|
||||
- [ ] **Step 3: Edit the POI and verify the new dropdown + radius field**
|
||||
|
||||
Click the marker to open the edit dialog. Verify:
|
||||
- The "Jenis Tempat Ibadah" field now renders using the dropdown-menu
|
||||
component (same look/feel as the create dialog, with icons), and all 7
|
||||
religion types are selectable.
|
||||
- A "Radius Jangkauan (meter)" slider is shown, pre-filled with the radius
|
||||
set in Step 2.
|
||||
|
||||
Change the religion type to a type not in the old 3-option list (e.g.
|
||||
"Pura (Hindu)" / temple) and change the radius to a different value (e.g.
|
||||
1000m). Save.
|
||||
|
||||
Expected: the marker icon/color updates to match the new type, and the
|
||||
circle on the map resizes to the new radius.
|
||||
|
||||
- [ ] **Step 4: Verify persistence across reload**
|
||||
|
||||
Reload the page (re-fetches markers from `/poi`).
|
||||
|
||||
Expected: the marker still shows with the updated type, and the radius
|
||||
circle still renders at the saved radius — confirming `radius_meters` is
|
||||
now persisted in and read back from the database correctly.
|
||||
|
||||
- [ ] **Step 5: Edit radius down to 0**
|
||||
|
||||
Open the edit dialog again, set the radius slider to 0, save, reload.
|
||||
|
||||
Expected: the marker remains but the circle disappears (per the
|
||||
"0 = tidak tampilkan lingkaran" convention).
|
||||
@@ -0,0 +1,83 @@
|
||||
# Religion Dialog Dropdown + Radius Fix
|
||||
|
||||
## Problem
|
||||
|
||||
1. The religion-edit flow (`marker-edit-dialog.tsx`) uses an older `SelectDropdown`
|
||||
(`@/components/ui/dropdown`) for the religion-type field, while the create
|
||||
flow (`religion-dialog.tsx`) already uses the shared `dropdown-menu.tsx`
|
||||
primitives. The two dialogs look inconsistent.
|
||||
2. The edit dialog only offers 3 religion types (mosque/church/synagogue),
|
||||
while the create dialog offers 7 (+ cathedral, temple, vihara, klenteng).
|
||||
As a side effect, editing a cathedral/temple/vihara/klenteng POI shows no
|
||||
type selector at all (`isReligion` is false for those types).
|
||||
3. The edit dialog has no way to change a religion POI's radius, even though
|
||||
the create dialog lets you set one.
|
||||
4. Radius circles never render on the map. Root cause: the live `poi_markers`
|
||||
table is missing the `radius_meters` column entirely — the running
|
||||
`pba-new-backend-1` container was built before this column was added to
|
||||
`db-init.js` / `poi.routes.js`, so radius data never persists.
|
||||
|
||||
## Design
|
||||
|
||||
### 1. Shared `RadiusSlider` component
|
||||
New file: `frontend/components/poverty/radius-slider.tsx`.
|
||||
|
||||
Extract the slider markup currently inline in `religion-dialog.tsx`
|
||||
(0-2000m range input, step 50, live value badge, "0 = tidak tampilkan
|
||||
lingkaran" hint). Props:
|
||||
|
||||
```ts
|
||||
{ value: number; onChange: (v: number) => void }
|
||||
```
|
||||
|
||||
Both dialogs render it inside their own `<Field label="Radius Jangkauan (meter)">`.
|
||||
|
||||
### 2. `marker-edit-dialog.tsx`
|
||||
- Replace `SelectDropdown` with `DropdownMenu` / `DropdownMenuTrigger` /
|
||||
`DropdownMenuContent` / `DropdownMenuRadioGroup` / `DropdownMenuRadioItem`
|
||||
from `@/components/ui/dropdown-menu`, mirroring the trigger styling and
|
||||
icon rendering used in `religion-dialog.tsx`.
|
||||
- Expand `RELIGION_OPTIONS` to the same 7 entries as `religion-dialog.tsx`
|
||||
(mosque/church/cathedral/temple/vihara/klenteng/synagogue, with Indonesian
|
||||
labels and icons).
|
||||
- Add `radiusMeters` state, populated from `row.radius_meters ?? 0` in the
|
||||
existing POI-loading `useEffect`.
|
||||
- Render `<Field label="Radius Jangkauan (meter)"><RadiusSlider .../></Field>`
|
||||
only when `isReligion`.
|
||||
- On submit, when `isReligion`: include `radius_meters: radiusMeters` in the
|
||||
PUT body, and include `radius: radiusMeters` in the `meta` passed to
|
||||
`onUpdated` so the map redraws the circle immediately.
|
||||
- Remove the now-dead `SelectDropdown` import.
|
||||
|
||||
### 3. `components/ui/dropdown.tsx`
|
||||
Remove the now-unused `SelectDropdown` / `SelectOption` / `dropdownSpring`
|
||||
exports (confirmed unused elsewhere via grep).
|
||||
|
||||
### 4. `religion-dialog.tsx`
|
||||
Replace the inline slider block with `<RadiusSlider value={radiusMeters}
|
||||
onChange={setRadiusMeters} />`. No behavior change, just dedup.
|
||||
|
||||
### 5. Backend DB migration
|
||||
Run `docker compose up -d --build backend` from `/Users/pangkywaradjodi/pba-new`
|
||||
so `initDb()` re-runs `ALTER TABLE poi_markers ADD COLUMN IF NOT EXISTS
|
||||
radius_meters INT DEFAULT 0` against the live database. Verify with
|
||||
`\d poi_markers` afterward.
|
||||
|
||||
## Data flow after the fix
|
||||
|
||||
- **Create** (`religion-dialog.tsx`): POST `/poi` with `radius_meters` → now
|
||||
persists correctly once the column exists.
|
||||
- **Edit** (`marker-edit-dialog.tsx`): GET `/poi/:id` returns `radius_meters`
|
||||
→ loaded into the slider → PUT `/poi/:id` saves it → `onUpdated` updates
|
||||
`userMarkers` → `leaflet-map.tsx` redraws the circle (existing
|
||||
circle-drawing logic is already correct, it just never received real data).
|
||||
- **Page load**: GET `/poi` already maps `radius_meters` → `meta.radius`, so
|
||||
existing POIs with a radius now render circles too.
|
||||
|
||||
## Testing
|
||||
|
||||
- Rebuild backend, confirm `radius_meters` column exists.
|
||||
- Manual flow via dev server: create a religion POI with radius > 0 → circle
|
||||
appears. Edit an existing religion POI (including a cathedral/temple/etc.),
|
||||
change type via the new dropdown, adjust radius, save → circle updates.
|
||||
Reload page → circle persists.
|
||||
Reference in New Issue
Block a user