256 lines
7.2 KiB
TypeScript
256 lines
7.2 KiB
TypeScript
"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 (
|
||
<span className="pointer-events-none absolute left-3 top-1/2 z-10 -translate-y-1/2 text-neutral-400">
|
||
<svg
|
||
xmlns="http://www.w3.org/2000/svg"
|
||
viewBox="0 0 20 20"
|
||
fill="currentColor"
|
||
className="h-4 w-4"
|
||
>
|
||
<path
|
||
fillRule="evenodd"
|
||
d="M5.75 2a.75.75 0 01.75.75V4h7V2.75a.75.75 0 011.5 0V4h.25A2.75 2.75 0 0118 6.75v8.5A2.75 2.75 0 0115.25 18H4.75A2.75 2.75 0 012 15.25v-8.5A2.75 2.75 0 014.75 4H5V2.75A.75.75 0 015.75 2zm-1 5.5c-.69 0-1.25.56-1.25 1.25v6.5c0 .69.56 1.25 1.25 1.25h10.5c.69 0 1.25-.56 1.25-1.25v-6.5c0-.69-.56-1.25-1.25-1.25H4.75z"
|
||
clipRule="evenodd"
|
||
/>
|
||
</svg>
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function ClockIcon() {
|
||
return (
|
||
<span className="pointer-events-none absolute left-3 top-1/2 z-10 -translate-y-1/2 text-neutral-400">
|
||
<svg
|
||
xmlns="http://www.w3.org/2000/svg"
|
||
viewBox="0 0 20 20"
|
||
fill="currentColor"
|
||
className="h-4 w-4"
|
||
>
|
||
<path
|
||
fillRule="evenodd"
|
||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm.75-13a.75.75 0 00-1.5 0v5c0 .284.16.544.415.67l3 1.5a.75.75 0 00.67-1.34L10.75 9.54V5z"
|
||
clipRule="evenodd"
|
||
/>
|
||
</svg>
|
||
</span>
|
||
);
|
||
}
|
||
|
||
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<Date | null>(
|
||
() => (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 (
|
||
<div className="relative">
|
||
<CalendarIcon />
|
||
<ReactDatePicker
|
||
id={id}
|
||
selected={current ?? null}
|
||
onChange={handleChange}
|
||
locale="id"
|
||
dateFormat="dd MMM yyyy"
|
||
minDate={effectiveMin}
|
||
maxDate={maxDate}
|
||
disabled={disabled}
|
||
required={required}
|
||
placeholderText={placeholder}
|
||
isClearable={!required && !disabled}
|
||
showMonthDropdown={withMonthYearDropdown}
|
||
showYearDropdown={withMonthYearDropdown}
|
||
dropdownMode="select"
|
||
className={FIELD_CLS}
|
||
wrapperClassName="w-full"
|
||
/>
|
||
{name && (
|
||
<input
|
||
type="hidden"
|
||
name={name}
|
||
value={current ? formatLocalCalendarYmd(current) : ""}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div className="relative">
|
||
<CalendarIcon />
|
||
<ReactDatePicker
|
||
id={id}
|
||
selectsRange
|
||
startDate={startDate}
|
||
endDate={endDate}
|
||
onChange={(dates) => {
|
||
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"
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div className="relative">
|
||
<ClockIcon />
|
||
<ReactDatePicker
|
||
id={id}
|
||
selected={timeStringToDate(value)}
|
||
onChange={(d: Date | null) => 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"
|
||
/>
|
||
</div>
|
||
);
|
||
}
|