Uang Makan
{stats.daysLeft} hari sampai cair berikutnya
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 (
{stats.daysLeft} hari sampai cair berikutnyaUang Makan