import React, { useMemo, useState } from 'react'; import { createRoot } from 'react-dom/client'; import { CalendarDays, ChevronDown, ChevronLeft, ChevronRight, CircleDollarSign, Landmark, Plus, ReceiptText, Target, Trash2, TrendingDown, WalletCards, Sparkles, } from 'lucide-react'; import './styles.css'; const STORAGE_KEY = 'uang-makan-dashboard-v1'; const DEFAULT_BUDGET = 1600000; const CATEGORIES = ['Makan', 'Minum', 'Transport', 'Jajan', 'Lainnya']; const today = new Date(); const yyyyMmDd = (date) => date.toISOString().slice(0, 10); const currentMonth = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}`; const monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'Mei', 'Jun', 'Jul', 'Agu', 'Sep', 'Okt', 'Nov', 'Des']; const defaultState = { income: DEFAULT_BUDGET, payday: 1, month: currentMonth, incomeDate: yyyyMmDd(today), expenses: [], }; function loadState() { try { const saved = JSON.parse(localStorage.getItem(STORAGE_KEY)); if (!saved) return defaultState; const merged = { ...defaultState, ...saved }; if (!merged.incomeDate) { merged.incomeDate = `${merged.month}-${String(merged.payday).padStart(2, '0')}`; } return merged; } catch { return defaultState; } } function saveState(next) { localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); } function formatRupiah(value) { return new Intl.NumberFormat('id-ID', { style: 'currency', currency: 'IDR', maximumFractionDigits: 0, }).format(Number.isFinite(value) ? value : 0); } function formatDate(value) { return new Intl.DateTimeFormat('id-ID', { day: '2-digit', month: 'short', }).format(new Date(`${value}T00:00:00`)); } function formatLongDate(value) { return new Intl.DateTimeFormat('id-ID', { day: '2-digit', month: '2-digit', year: 'numeric', }).format(new Date(`${value}T00:00:00`)); } function formatMonth(value) { const [year, month] = value.split('-').map(Number); return `${monthNames[month - 1]} ${year}`; } function clampDay(year, monthIndex, day) { return Math.min(day, new Date(year, monthIndex + 1, 0).getDate()); } function getCycleRange(monthValue, payday) { const [year, month] = monthValue.split('-').map(Number); const monthIndex = month - 1; const start = new Date(year, monthIndex, clampDay(year, monthIndex, payday)); const next = new Date(year, monthIndex + 1, clampDay(year, monthIndex + 1, payday)); return { start, next }; } function daysBetween(start, end) { const oneDay = 24 * 60 * 60 * 1000; return Math.max(0, Math.ceil((end - start) / oneDay)); } function App() { const [data, setData] = useState(loadState); const [draft, setDraft] = useState({ date: yyyyMmDd(today), category: 'Makan', note: '', amount: '', }); const [categoryOpen, setCategoryOpen] = useState(false); const [monthOpen, setMonthOpen] = useState(false); const updateData = (patch) => { setData((current) => { const next = typeof patch === 'function' ? patch(current) : { ...current, ...patch }; saveState(next); return next; }); }; const updateIncomeDate = (dateValue) => { const date = new Date(`${dateValue}T00:00:00`); updateData({ incomeDate: dateValue, month: `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`, payday: date.getDate(), }); }; const stats = useMemo(() => { const { start, next } = getCycleRange(data.month, Number(data.payday)); const todayValue = yyyyMmDd(today); const totalExpense = data.expenses.reduce((sum, item) => sum + Number(item.amount || 0), 0); const todayExpense = data.expenses .filter((item) => item.date === todayValue) .reduce((sum, item) => sum + Number(item.amount || 0), 0); const expenseBeforeToday = data.expenses .filter((item) => item.date < todayValue) .reduce((sum, item) => sum + Number(item.amount || 0), 0); const remaining = Number(data.income || 0) - totalExpense; const todayAtStart = new Date(today.getFullYear(), today.getMonth(), today.getDate()); const activeDate = todayAtStart < start ? start : todayAtStart; const daysLeft = Math.max(1, daysBetween(activeDate, next)); const cycleDays = Math.max(1, daysBetween(start, next)); const elapsedDays = Math.min(cycleDays, Math.max(0, daysBetween(start, activeDate))); const balanceBeforeToday = Number(data.income || 0) - expenseBeforeToday; const dailyLimit = balanceBeforeToday / daysLeft - todayExpense; const nextDailyLimit = remaining / daysLeft; const idealSpent = (Number(data.income || 0) / cycleDays) * elapsedDays; const pace = idealSpent - totalExpense; const usedPercent = Math.min(100, Math.max(0, (totalExpense / Number(data.income || 1)) * 100)); return { totalExpense, remaining, daysLeft, dailyLimit, nextDailyLimit, pace, usedPercent }; }, [data]); const sortedExpenses = [...data.expenses].sort((a, b) => b.date.localeCompare(a.date)); const addExpense = (event) => { event.preventDefault(); const amount = Number(draft.amount); if (!amount || amount <= 0) return; updateData((current) => ({ ...current, expenses: [ ...current.expenses, { id: crypto.randomUUID(), date: draft.date, category: draft.category.trim() || 'Lainnya', note: draft.note.trim(), amount, }, ], })); setDraft((current) => ({ ...current, note: '', amount: '' })); }; const deleteExpense = (id) => { updateData((current) => ({ ...current, expenses: current.expenses.filter((item) => item.id !== id), })); }; return (
Monthly allowance OS

Uang Makan

{new Intl.DateTimeFormat('id-ID', { day: '2-digit', month: 'long', year: 'numeric' }).format(today)}
Saldo tersisa
{formatRupiah(stats.remaining)}
{stats.usedPercent.toFixed(0)}% terpakai {formatRupiah(stats.totalExpense)} keluar
Batas aman hari ini
{formatRupiah(stats.dailyLimit)}

{stats.daysLeft} hari sampai cair berikutnya

= 0 ? 'green' : 'red'} />
Tambah Pengeluaran {formatRupiah(Number(draft.amount || 0))}
Riwayat Harian {sortedExpenses.length} transaksi
{sortedExpenses.length === 0 ? (
Belum ada pengeluaran
) : (
{sortedExpenses.map((item) => (
{formatDate(item.date)}
{item.category} {item.note || 'Tanpa catatan'}
{formatRupiah(Number(item.amount))}
))}
)}
); } function Metric({ icon: Icon, label, value, accent = 'default' }) { return (
{label} {value}
); } function DatePicker({ value, onChange }) { const selected = new Date(`${value}T00:00:00`); const [open, setOpen] = useState(false); const [viewDate, setViewDate] = useState(new Date(selected.getFullYear(), selected.getMonth(), 1)); const year = viewDate.getFullYear(); const month = viewDate.getMonth(); const firstDay = new Date(year, month, 1).getDay(); const daysInMonth = new Date(year, month + 1, 0).getDate(); const prevDays = new Date(year, month, 0).getDate(); const cells = Array.from({ length: 42 }, (_, index) => { const dayNumber = index - firstDay + 1; if (dayNumber < 1) return { day: prevDays + dayNumber, muted: true, date: new Date(year, month - 1, prevDays + dayNumber) }; if (dayNumber > daysInMonth) return { day: dayNumber - daysInMonth, muted: true, date: new Date(year, month + 1, dayNumber - daysInMonth) }; return { day: dayNumber, muted: false, date: new Date(year, month, dayNumber) }; }); return (
{open ? (
{formatMonth(`${year}-${String(month + 1).padStart(2, '0')}`)}
{['Min', 'Sen', 'Sel', 'Rab', 'Kam', 'Jum', 'Sab'].map((day) => {day})}
{cells.map((cell) => { const cellValue = yyyyMmDd(cell.date); return ( ); })}
) : null}
); } function MonthPicker({ value, open, onOpenChange, onChange }) { const [year, setYear] = useState(Number(value.split('-')[0])); const selectedMonth = Number(value.split('-')[1]); return (
{open ? (
{year}
{monthNames.map((month, index) => { const monthValue = `${year}-${String(index + 1).padStart(2, '0')}`; return ( ); })}
) : null}
); } createRoot(document.getElementById('root')).render();