diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 0e65d89..6fb85bb 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -10,7 +10,8 @@ "PowerShell(npx prisma generate 2>&1)", "PowerShell(npx tsc --noEmit 2>&1)", "PowerShell(npx eslint server/services/refund.service.ts server/repositories/refund.repo.ts features/refund app/admin/refunds 2>&1)", - "PowerShell(npx eslint server lib features app 2>&1)" + "PowerShell(npx eslint server lib features app 2>&1)", + "Bash(npx eslint *)" ] } } diff --git a/app/globals.css b/app/globals.css index 72c65b5..14d2eb3 100644 --- a/app/globals.css +++ b/app/globals.css @@ -64,6 +64,16 @@ select:focus { box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1) !important; } +/* Input wrapper isi penuh lebar parent + popper di atas konten lain */ +.react-datepicker-wrapper, +.react-datepicker__input-container { + width: 100% !important; +} + +.react-datepicker-popper { + z-index: 50 !important; +} + .react-datepicker__header { background: #f9fafb !important; border-bottom: 1px solid #e5e7eb !important; @@ -132,3 +142,46 @@ select:focus { .react-datepicker__close-icon:hover::after { background-color: #16a34a !important; } + +/* Dropdown bulan / tahun (mode select) */ +.react-datepicker__month-select, +.react-datepicker__year-select { + border: 1px solid #e5e7eb !important; + border-radius: 0.5rem !important; + padding: 2px 4px !important; + font-size: 0.8125rem !important; + background: #fff !important; +} + +/* Pemilih jam (showTimeSelectOnly) */ +.react-datepicker__time-container { + width: 96px !important; +} + +.react-datepicker__time-container + .react-datepicker__time + .react-datepicker__time-box { + width: 96px !important; +} + +.react-datepicker-time__header { + font-weight: 700 !important; + color: #1f2937 !important; + font-size: 0.8125rem !important; +} + +.react-datepicker__time-list-item { + font-size: 0.8125rem !important; + color: #1f2937 !important; +} + +.react-datepicker__time-list-item:hover { + background: #dcfce7 !important; + color: #15803d !important; +} + +.react-datepicker__time-list-item--selected { + background: #16a34a !important; + color: #fff !important; + font-weight: 700 !important; +} diff --git a/components/shared/date-picker.tsx b/components/shared/date-picker.tsx new file mode 100644 index 0000000..2ebca81 --- /dev/null +++ b/components/shared/date-picker.tsx @@ -0,0 +1,255 @@ +"use client"; + +/** + * Komponen pemilih tanggal & jam bersama — satu-satunya tempat aplikasi + * memakai `react-datepicker`. Semua field tanggal/jam (form & filter) harus + * lewat sini supaya tampilan + locale konsisten. + * + * - `DateField` → satu tanggal (controlled atau uncontrolled untuk form). + * - `DateRangeField` → rentang tanggal (berangkat–pulang, filter). + * - `TimeField` → jam "HH:mm" (itinerary). + * + * Tema visual di-override di `app/globals.css` (blok `.react-datepicker`). + */ + +import { useState } from "react"; +import ReactDatePicker, { registerLocale } from "react-datepicker"; +import { id as idLocale } from "date-fns/locale"; +import "react-datepicker/dist/react-datepicker.css"; +import { + formatLocalCalendarYmd, + localCalendarDateFromYmd, +} from "@/lib/trip-dates"; +import { isValidTimeFormat } from "@/lib/itinerary"; + +registerLocale("id", idLocale); + +const FIELD_CLS = + "w-full rounded-xl border border-neutral-200 bg-neutral-50 py-2.5 pl-9 pr-3 text-sm text-neutral-800 placeholder:text-neutral-400 focus:border-primary-500 focus:bg-white"; + +function CalendarIcon() { + return ( + + + + + + ); +} + +function ClockIcon() { + return ( + + + + + + ); +} + +interface DateFieldProps { + /** Mode controlled. Kalau `undefined`, komponen jalan uncontrolled. */ + value?: Date | null; + /** Nilai awal untuk mode uncontrolled (mis. di dalam form GET/POST biasa). */ + defaultValue?: Date | null; + /** + * Nilai awal uncontrolled berupa `YYYY-MM-DD`. Dipakai saat parent adalah + * Server Component (mis. filter admin) — string di-parse di browser supaya + * tidak ada pergeseran timezone server↔klien. + */ + defaultValueYmd?: string; + onChange?: (date: Date | null) => void; + /** Kalau diisi, render hidden input `YYYY-MM-DD` supaya ikut ter-submit form. */ + name?: string; + id?: string; + minDate?: Date; + maxDate?: Date; + placeholder?: string; + disabled?: boolean; + required?: boolean; + /** Dropdown bulan + tahun — cocok untuk tanggal lahir. */ + withMonthYearDropdown?: boolean; +} + +/** Pemilih satu tanggal. */ +export function DateField({ + value, + defaultValue = null, + defaultValueYmd, + onChange, + name, + id, + minDate, + maxDate, + placeholder = "Pilih tanggal...", + disabled = false, + required = false, + withMonthYearDropdown = false, +}: DateFieldProps) { + const isControlled = value !== undefined; + const [internal, setInternal] = useState( + () => (defaultValueYmd ? localCalendarDateFromYmd(defaultValueYmd) : defaultValue) + ); + const current = isControlled ? value : internal; + + function handleChange(date: Date | null) { + if (!isControlled) setInternal(date); + onChange?.(date); + } + + // Default rentang masuk akal untuk picker bulan/tahun (mis. tanggal lahir). + const effectiveMin = + minDate ?? + (withMonthYearDropdown + ? new Date(new Date().getFullYear() - 120, 0, 1) + : undefined); + + return ( +
+ + + {name && ( + + )} +
+ ); +} + +interface DateRangeFieldProps { + startDate: Date | null; + endDate: Date | null; + onChange: (start: Date | null, end: Date | null) => void; + minDate?: Date; + placeholder?: string; + id?: string; +} + +/** Pemilih rentang tanggal (berangkat–pulang, filter). */ +export function DateRangeField({ + startDate, + endDate, + onChange, + minDate, + placeholder = "Pilih tanggal...", + id, +}: DateRangeFieldProps) { + return ( +
+ + { + const [start, end] = dates as [Date | null, Date | null]; + onChange(start, end); + }} + locale="id" + dateFormat="dd MMM yyyy" + minDate={minDate} + isClearable + placeholderText={placeholder} + className={FIELD_CLS} + wrapperClassName="w-full" + /> +
+ ); +} + +function timeStringToDate(value: string): Date | null { + if (!isValidTimeFormat(value)) return null; + const [h, m] = value.split(":").map(Number); + const d = new Date(); + d.setHours(h, m, 0, 0); + return d; +} + +function dateToTimeString(d: Date): string { + const h = String(d.getHours()).padStart(2, "0"); + const m = String(d.getMinutes()).padStart(2, "0"); + return `${h}:${m}`; +} + +interface TimeFieldProps { + /** Jam dalam format "HH:mm", atau "" kalau kosong. */ + value: string; + onChange: (value: string) => void; + id?: string; + placeholder?: string; + disabled?: boolean; + /** Tampilkan tombol clear (untuk jam opsional, mis. jam selesai). */ + clearable?: boolean; +} + +/** Pemilih jam "HH:mm" 24-jam dengan interval 15 menit. */ +export function TimeField({ + value, + onChange, + id, + placeholder = "--:--", + disabled = false, + clearable = false, +}: TimeFieldProps) { + return ( +
+ + onChange(d ? dateToTimeString(d) : "")} + showTimeSelect + showTimeSelectOnly + timeIntervals={15} + timeCaption="Jam" + dateFormat="HH:mm" + timeFormat="HH:mm" + locale="id" + disabled={disabled} + isClearable={clearable && !disabled} + placeholderText={placeholder} + className={FIELD_CLS} + wrapperClassName="w-full" + /> +
+ ); +} diff --git a/features/admin/components/admin-filter-bar.tsx b/features/admin/components/admin-filter-bar.tsx index 95f7163..f50754e 100644 --- a/features/admin/components/admin-filter-bar.tsx +++ b/features/admin/components/admin-filter-bar.tsx @@ -1,3 +1,5 @@ +import { DateField } from "@/components/shared/date-picker"; + interface AdminFilterBarProps { /** URL base (mis. `/admin/refunds`) yang menerima query params. */ action: string; @@ -45,12 +47,11 @@ export function AdminFilterBar({ > Dari tanggal - @@ -61,12 +62,11 @@ export function AdminFilterBar({ > Sampai tanggal - diff --git a/features/organizer/components/verify-form.tsx b/features/organizer/components/verify-form.tsx index a92d7df..c6729ee 100644 --- a/features/organizer/components/verify-form.tsx +++ b/features/organizer/components/verify-form.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; import { submitVerificationAction } from "@/features/organizer/actions"; +import { DateField } from "@/components/shared/date-picker"; type Initial = { fullName: string; @@ -21,23 +22,31 @@ type UploadKind = "ktp" | "liveness"; const ACCEPT_MIME = "image/jpeg,image/png,image/webp"; const MAX_BYTES = 5 * 1024 * 1024; -function toYmd(d: Date): string { - const y = d.getUTCFullYear(); - const m = String(d.getUTCMonth() + 1).padStart(2, "0"); - const day = String(d.getUTCDate()).padStart(2, "0"); - return `${y}-${m}-${day}`; -} - export function VerifyForm({ initial }: { initial: Initial }) { const router = useRouter(); const [error, setError] = useState(""); const [loading, setLoading] = useState(false); const [ktpKey, setKtpKey] = useState(initial?.ktpImageKey ?? ""); const [livenessKey, setLivenessKey] = useState(initial?.livenessKey ?? ""); + // `birthDate` dari DB tersimpan sebagai tengah malam UTC — baca pakai getter + // UTC supaya hari kalender yang tampil di picker tidak bergeser. + const [birthDate, setBirthDate] = useState( + initial + ? new Date( + initial.birthDate.getUTCFullYear(), + initial.birthDate.getUTCMonth(), + initial.birthDate.getUTCDate() + ) + : null + ); async function handleSubmit(e: React.FormEvent) { e.preventDefault(); setError(""); + if (!birthDate) { + setError("Tanggal lahir wajib diisi"); + return; + } if (!ktpKey || !livenessKey) { setError("Foto KTP dan foto memegang kertas SETRIP wajib diunggah"); return; @@ -102,15 +111,21 @@ export function VerifyForm({ initial }: { initial: Initial }) { />
-
diff --git a/features/trip/components/create-trip-form.tsx b/features/trip/components/create-trip-form.tsx index 86efe2c..47a7c61 100644 --- a/features/trip/components/create-trip-form.tsx +++ b/features/trip/components/create-trip-form.tsx @@ -2,8 +2,7 @@ import { useMemo, useState } from "react"; import { useRouter } from "next/navigation"; -import DatePicker from "react-datepicker"; -import "react-datepicker/dist/react-datepicker.css"; +import { DateRangeField, TimeField } from "@/components/shared/date-picker"; import { createTripAction } from "@/features/trip/actions"; import { ImageUrlInput } from "@/features/trip/components/image-url-input"; import { formatLocalCalendarYmd } from "@/lib/trip-dates"; @@ -885,40 +884,35 @@ function ItineraryBuilder({ >
-
-
diff --git a/features/trip/components/trip-filter.tsx b/features/trip/components/trip-filter.tsx index 3c5f9ff..286f70d 100644 --- a/features/trip/components/trip-filter.tsx +++ b/features/trip/components/trip-filter.tsx @@ -2,9 +2,11 @@ import { useRouter, useSearchParams } from "next/navigation"; import { useState } from "react"; -import DatePicker from "react-datepicker"; -import "react-datepicker/dist/react-datepicker.css"; -import { formatLocalCalendarYmd } from "@/lib/trip-dates"; +import { DateRangeField } from "@/components/shared/date-picker"; +import { + formatLocalCalendarYmd, + localCalendarDateFromYmd, +} from "@/lib/trip-dates"; import { ACTIVITY_CATEGORIES, categoryMeta, @@ -43,12 +45,14 @@ export function TripFilter() { isGroupSize(initialGroup) ? initialGroup : null ); const [query, setQuery] = useState(searchParams.get("q") ?? ""); - const [startDate, setStartDate] = useState( - searchParams.get("from") ? new Date(searchParams.get("from")!) : null - ); - const [endDate, setEndDate] = useState( - searchParams.get("to") ? new Date(searchParams.get("to")!) : null - ); + const [startDate, setStartDate] = useState(() => { + const from = searchParams.get("from"); + return from ? localCalendarDateFromYmd(from) : null; + }); + const [endDate, setEndDate] = useState(() => { + const to = searchParams.get("to"); + return to ? localCalendarDateFromYmd(to) : null; + }); function buildParams(overrides?: { category?: ActivityCategory | null; @@ -287,33 +291,12 @@ export function TripFilter() { -
- - - - - - -
+ handleDateChange([start, end])} + minDate={new Date()} + />
{/* Buttons */} diff --git a/lib/trip-dates.ts b/lib/trip-dates.ts index 927b482..a5b1839 100644 --- a/lib/trip-dates.ts +++ b/lib/trip-dates.ts @@ -40,6 +40,20 @@ export function formatLocalCalendarYmd(d: Date): string { return `${y}-${m}-${day}`; } +/** + * Kebalikan `formatLocalCalendarYmd`: `YYYY-MM-DD` → `Date` di tengah malam + * **lokal** browser. Dipakai untuk seed nilai awal date picker dari query + * string / DB tanpa pergeseran timezone. Return null kalau format tidak valid. + */ +export function localCalendarDateFromYmd(ymd: string): Date | null { + const parts = ymd.trim().slice(0, 10).split("-").map(Number); + const y = parts[0]; + const m = parts[1]; + const d = parts[2]; + if (!y || !m || !d) return null; + return new Date(y, m - 1, d); +} + /** * Simpan `trip.date` / `trip.endDate`: string `YYYY-MM-DD` diartikan sebagai * **hari kalender UTC** yang sama (selaras dengan filter Open Trip).