237 lines
8.7 KiB
TypeScript
237 lines
8.7 KiB
TypeScript
import React, { useState } from "react";
|
||
import {
|
||
View,
|
||
Text,
|
||
Modal,
|
||
TouchableOpacity,
|
||
StyleSheet,
|
||
} from "react-native";
|
||
|
||
// ─── Helper ───────────────────────────────────────────────────────────────────
|
||
|
||
const BULAN = [
|
||
"Januari","Februari","Maret","April","Mei","Juni",
|
||
"Juli","Agustus","September","Oktober","November","Desember",
|
||
];
|
||
const HARI = ["Min","Sen","Sel","Rab","Kam","Jum","Sab"];
|
||
|
||
export const toISO = (d: Date) =>
|
||
`${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,"0")}-${String(d.getDate()).padStart(2,"0")}`;
|
||
|
||
export const fromISO = (s: string): Date => {
|
||
const [y,m,d] = s.split("-").map(Number);
|
||
return new Date(y, m-1, d);
|
||
};
|
||
|
||
export const toDisplay = (s: string): string => {
|
||
const d = fromISO(s);
|
||
return `${String(d.getDate()).padStart(2,"0")}/${String(d.getMonth()+1).padStart(2,"0")}/${d.getFullYear()}`;
|
||
};
|
||
|
||
function getDaysInMonth(year: number, month: number) {
|
||
return new Date(year, month + 1, 0).getDate();
|
||
}
|
||
|
||
// ─── Props ────────────────────────────────────────────────────────────────────
|
||
|
||
interface CalendarPickerProps {
|
||
visible: boolean;
|
||
value: string; // "yyyy-mm-dd"
|
||
minDate?: string;
|
||
maxDate?: string;
|
||
title?: string;
|
||
onConfirm: (date: string) => void;
|
||
onClose: () => void;
|
||
}
|
||
|
||
// ─── Komponen ─────────────────────────────────────────────────────────────────
|
||
|
||
export default function CalendarPicker({
|
||
visible, value, minDate, maxDate, title = "Pilih Tanggal",
|
||
onConfirm, onClose,
|
||
}: CalendarPickerProps) {
|
||
|
||
const initDate = fromISO(value);
|
||
const [viewYear, setViewYear] = useState(initDate.getFullYear());
|
||
const [viewMonth, setViewMonth] = useState(initDate.getMonth());
|
||
const [selected, setSelected] = useState(value);
|
||
|
||
const todayISO = toISO(new Date());
|
||
const totalDays = getDaysInMonth(viewYear, viewMonth);
|
||
const firstDay = new Date(viewYear, viewMonth, 1).getDay(); // 0=Minggu
|
||
|
||
// Navigasi bulan
|
||
const prevMonth = () => {
|
||
if (viewMonth === 0) { setViewMonth(11); setViewYear(y => y - 1); }
|
||
else setViewMonth(m => m - 1);
|
||
};
|
||
const nextMonth = () => {
|
||
if (viewMonth === 11) { setViewMonth(0); setViewYear(y => y + 1); }
|
||
else setViewMonth(m => m + 1);
|
||
};
|
||
|
||
const isDisabled = (iso: string) => {
|
||
if (minDate && iso < minDate) return true;
|
||
if (maxDate && iso > maxDate) return true;
|
||
return false;
|
||
};
|
||
|
||
const isToday = (iso: string) => iso === todayISO;
|
||
const isSelected = (iso: string) => iso === selected;
|
||
|
||
// Buat grid hari (null = sel kosong sebelum hari pertama)
|
||
const cells: (number | null)[] = [
|
||
...Array(firstDay).fill(null),
|
||
...Array.from({ length: totalDays }, (_, i) => i + 1),
|
||
];
|
||
// Lengkapi sampai kelipatan 7
|
||
while (cells.length % 7 !== 0) cells.push(null);
|
||
|
||
return (
|
||
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
|
||
<TouchableOpacity style={s.overlay} activeOpacity={1} onPress={onClose}>
|
||
<TouchableOpacity activeOpacity={1} style={s.card} onPress={() => {}}>
|
||
|
||
{/* Judul */}
|
||
<Text style={s.title}>{title}</Text>
|
||
|
||
{/* Nav bulan */}
|
||
<View style={s.navRow}>
|
||
<TouchableOpacity onPress={prevMonth} style={s.navBtn}>
|
||
<Text style={s.navArrow}>‹</Text>
|
||
</TouchableOpacity>
|
||
<Text style={s.navLabel}>
|
||
{BULAN[viewMonth]} {viewYear}
|
||
</Text>
|
||
<TouchableOpacity onPress={nextMonth} style={s.navBtn}>
|
||
<Text style={s.navArrow}>›</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
|
||
{/* Header hari */}
|
||
<View style={s.weekRow}>
|
||
{HARI.map(h => (
|
||
<Text key={h} style={[s.weekCell, h === "Min" && { color: "#dc2626" }]}>{h}</Text>
|
||
))}
|
||
</View>
|
||
|
||
{/* Grid tanggal */}
|
||
<View style={s.grid}>
|
||
{cells.map((day, i) => {
|
||
if (!day) return <View key={`e${i}`} style={s.dayCell} />;
|
||
|
||
const iso = `${viewYear}-${String(viewMonth+1).padStart(2,"0")}-${String(day).padStart(2,"0")}`;
|
||
const disabled = isDisabled(iso);
|
||
const today = isToday(iso);
|
||
const sel = isSelected(iso);
|
||
const isSun = (firstDay + day - 1) % 7 === 0;
|
||
|
||
return (
|
||
<TouchableOpacity
|
||
key={iso}
|
||
disabled={disabled}
|
||
onPress={() => setSelected(iso)}
|
||
style={[
|
||
s.dayCell,
|
||
sel && s.daySel,
|
||
today && !sel && s.dayToday,
|
||
]}
|
||
>
|
||
<Text style={[
|
||
s.dayText,
|
||
isSun && !sel && { color: "#dc2626" },
|
||
today && !sel && { color: "#4f46e5", fontWeight: "700" },
|
||
sel && { color: "#fff", fontWeight: "800" },
|
||
disabled && { color: "#d1d5db" },
|
||
]}>
|
||
{day}
|
||
</Text>
|
||
{today && !sel && <View style={s.todayDot} />}
|
||
</TouchableOpacity>
|
||
);
|
||
})}
|
||
</View>
|
||
|
||
{/* Preview tanggal dipilih */}
|
||
<View style={s.previewRow}>
|
||
<Text style={s.previewText}>
|
||
Dipilih:{" "}
|
||
<Text style={s.previewVal}>{toDisplay(selected)}</Text>
|
||
</Text>
|
||
</View>
|
||
|
||
{/* Tombol aksi */}
|
||
<View style={s.btnRow}>
|
||
<TouchableOpacity onPress={onClose} style={s.btnCancel}>
|
||
<Text style={s.btnCancelText}>Batal</Text>
|
||
</TouchableOpacity>
|
||
<TouchableOpacity onPress={() => { onConfirm(selected); onClose(); }} style={s.btnOk}>
|
||
<Text style={s.btnOkText}>Pilih</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
|
||
</TouchableOpacity>
|
||
</TouchableOpacity>
|
||
</Modal>
|
||
);
|
||
}
|
||
|
||
// ─── Styles ───────────────────────────────────────────────────────────────────
|
||
const CELL_SIZE = 38;
|
||
|
||
const s = StyleSheet.create({
|
||
overlay: {
|
||
flex: 1, backgroundColor: "rgba(0,0,0,0.45)",
|
||
justifyContent: "center", alignItems: "center",
|
||
},
|
||
card: {
|
||
backgroundColor: "#fff", borderRadius: 20, padding: 20,
|
||
width: 320,
|
||
shadowColor: "#000", shadowOffset: { width: 0, height: 8 },
|
||
shadowOpacity: 0.18, shadowRadius: 20, elevation: 16,
|
||
},
|
||
title: {
|
||
fontSize: 16, fontWeight: "800", color: "#0f172a",
|
||
textAlign: "center", marginBottom: 16,
|
||
},
|
||
|
||
// Nav
|
||
navRow: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", marginBottom: 12 },
|
||
navBtn: { width: 36, height: 36, borderRadius: 18, backgroundColor: "#f1f5f9", alignItems: "center", justifyContent: "center" },
|
||
navArrow: { fontSize: 22, color: "#4f46e5", lineHeight: 26 },
|
||
navLabel: { fontSize: 15, fontWeight: "700", color: "#0f172a" },
|
||
|
||
// Header hari
|
||
weekRow: { flexDirection: "row", marginBottom: 6 },
|
||
weekCell: { width: CELL_SIZE, textAlign: "center", fontSize: 11, fontWeight: "600", color: "#94a3b8" },
|
||
|
||
// Grid
|
||
grid: { flexDirection: "row", flexWrap: "wrap" },
|
||
dayCell: {
|
||
width: CELL_SIZE, height: CELL_SIZE,
|
||
alignItems: "center", justifyContent: "center",
|
||
borderRadius: CELL_SIZE / 2, marginBottom: 2,
|
||
},
|
||
daySel: { backgroundColor: "#4f46e5" },
|
||
dayToday: { borderWidth: 1.5, borderColor: "#4f46e5" },
|
||
dayText: { fontSize: 13, color: "#334155" },
|
||
todayDot: {
|
||
position: "absolute", bottom: 4,
|
||
width: 4, height: 4, borderRadius: 2, backgroundColor: "#4f46e5",
|
||
},
|
||
|
||
// Preview
|
||
previewRow: {
|
||
backgroundColor: "#f8fafc", borderRadius: 10, padding: 10,
|
||
marginTop: 12, alignItems: "center",
|
||
},
|
||
previewText: { fontSize: 12, color: "#64748b" },
|
||
previewVal: { fontWeight: "700", color: "#0f172a" },
|
||
|
||
// Buttons
|
||
btnRow: { flexDirection: "row", gap: 10, marginTop: 14 },
|
||
btnCancel: { flex: 1, paddingVertical: 12, borderRadius: 12, backgroundColor: "#f1f5f9", alignItems: "center" },
|
||
btnCancelText:{ fontSize: 14, color: "#64748b", fontWeight: "600" },
|
||
btnOk: { flex: 2, paddingVertical: 12, borderRadius: 12, backgroundColor: "#4f46e5", alignItems: "center" },
|
||
btnOkText: { fontSize: 14, color: "#fff", fontWeight: "800" },
|
||
}); |