/* SISTEM KEWANGAN ALFA — Frontend (React UMD + Babel standalone) */ const { useState, useEffect, useMemo } = React; // ---------- IKON (SVG sebaris, gantian lucide-react) ---------- const mkIcon = (paths) => ({ size = 20, className = '' }) => ( ); const Users = mkIcon(''); const Wallet = mkIcon(''); const Receipt = mkIcon(''); const FileText = mkIcon(''); const Bell = mkIcon(''); const ClipboardList = mkIcon(''); const LogOut = mkIcon(''); const CheckCircle = mkIcon(''); const Plus = mkIcon(''); const Upload = mkIcon(''); const XCircle = mkIcon(''); const ArrowRight = mkIcon(''); const Menu = mkIcon(''); const X = mkIcon(''); const LayoutDashboard = mkIcon(''); const Edit = mkIcon(''); const UserPlus = mkIcon(''); const AlertCircle = mkIcon(''); const Printer = mkIcon(''); const Lock = mkIcon(''); const Download = mkIcon(''); // ---------- PEMALAR ---------- const ROLES = { ADMIN: 'admin', FINANCE: 'finance', PURCHASING: 'purchasing' }; const LOCATIONS = ['B1', 'B2', 'SRA', 'HQ', 'Lain-lain']; const BUDGET_STATUS = { PENDING_FINANCE: 'Menunggu Kelulusan Kewangan', REJECTED_BY_FINANCE: 'Ditolak Kewangan', PENDING_ADMIN: 'Menunggu Kelulusan Pengurusan', APPROVED_BY_ADMIN: 'Diluluskan Pengurusan', REJECTED_BY_ADMIN: 'Ditolak Pengurusan', RECEIVED: 'Diterima Kewangan', ASSIGNED: 'Diagih kepada Pembelian', CLOSED: 'Selesai' }; const EXPENSE_STATUS = { PENDING: 'Menunggu Kelulusan', APPROVED: 'Diluluskan', REJECTED: 'Ditolak' }; const roleLabel = (r) => r === 'admin' ? 'Pengurusan' : r === 'finance' ? 'Kewangan' : 'Pembelian'; const fmt = (n) => parseFloat(n || 0).toFixed(2); // ---------- PELAYAN DEMO (fallback bila API tiada — cth. pratonton) ---------- const DemoServer = { seq: 100, me: null, users: [ { id: 1, name: 'Pengurus (Pentadbir)', role: 'admin', username: 'pengurus', email: null, active: 1 }, { id: 2, name: 'Ahmad (Kewangan)', role: 'finance', username: 'ahmad', email: null, active: 1 }, { id: 3, name: 'Siti (Pembelian)', role: 'purchasing', username: 'siti', email: null, active: 1 }, { id: 4, name: 'Raju (Pembelian)', role: 'purchasing', username: 'raju', email: null, active: 1 }, ], budgets: [], notifications: [], audit: [], notify(target, message) { this.notifications.push({ id: this.seq++, target: String(target), message, date: new Date().toISOString(), read: false }); }, logAudit(action, entity, entityId, details) { const u = this.me; this.audit.push({ id: this.seq++, user: u ? u.name : null, role: u ? u.role : null, action, entity, entityId, details: details || '', date: new Date().toISOString() }); }, img(f) { if (!f) return null; const u = URL.createObjectURL(f); return { url: u, view: u, name: f.name }; }, err(m) { const e = new Error(m); throw e; }, today() { return new Date().toISOString().split('T')[0]; }, call(action, p = {}, files = null) { const me = this.me; const need = () => { if (!me) { const e = new Error('Sesi tamat. Sila log masuk semula.'); e.status = 401; throw e; } }; const requireRole = (roles) => { need(); if (!roles.includes(me.role)) { const e = new Error('Anda tiada kebenaran untuk tindakan ini.'); e.status = 403; throw e; } }; switch (action) { case 'ping': return { ok: true }; case 'login': { const u = this.users.find(x => x.username === (p.username || '').toLowerCase() && x.active); if (!u || !(p.password || '').length) this.err('Nama pengguna atau kata laluan salah. (Demo: pengurus / ahmad / siti / raju — sebarang kata laluan)'); this.me = u; return { ok: true }; } case 'logout': this.me = null; return { ok: true }; case 'state': { need(); return JSON.parse(JSON.stringify({ ok: true, me, users: this.users, budgets: this.budgets, audit: (me.role === 'admin' || me.role === 'finance') ? this.audit.slice().reverse() : [], notifications: this.notifications.filter(n => n.target === String(me.id) || n.target === me.role).slice().reverse() })); } case 'create_budget': { requireRole(['finance','purchasing']); const amt = parseFloat(p.amount); this.budgets.unshift({ id: this.seq++, description: p.description, location: p.location, amount: amt, balance: amt, status: me.role === 'purchasing' ? BUDGET_STATUS.PENDING_FINANCE : BUDGET_STATUS.PENDING_ADMIN, dueDate: p.dueDate, notes: p.notes, requestedBy: me.id, date: this.today(), expenses: [], assignments: [] }); this.notify(me.role === 'purchasing' ? 'finance' : 'admin', `Permohonan belanjawan baru: RM ${fmt(amt)}`); return { ok: true }; } case 'finance_review': case 'admin_review': { requireRole(action === 'finance_review' ? ['finance'] : ['admin']); if (!['approve','reject'].includes(p.decision)) this.err('Keputusan tidak sah.'); if (p.decision === 'reject' && (p.reason || '').trim().length < 5) this.err('Sebab penolakan diwajibkan.'); const b = this.budgets.find(x => x.id === p.id); if (!b) this.err('Rekod tidak dijumpai.'); if (p.decision === 'approve') { const amt = p.amount !== undefined && p.amount !== '' ? parseFloat(p.amount) : b.amount; b.amount = amt; b.balance = amt; b.status = action === 'finance_review' ? BUDGET_STATUS.PENDING_ADMIN : BUDGET_STATUS.APPROVED_BY_ADMIN; this.logAudit(action === 'finance_review' ? 'finance_review_approve' : 'admin_review_approve', 'budget', b.id, `amount=${fmt(amt)}`); this.notify(action === 'finance_review' ? 'admin' : 'finance', `Permohonan belanjawan (Ruj: ${b.id}) diproses. Jumlah: RM ${fmt(amt)}`); } else { b.status = action === 'finance_review' ? BUDGET_STATUS.REJECTED_BY_FINANCE : BUDGET_STATUS.REJECTED_BY_ADMIN; this.logAudit(action === 'finance_review' ? 'finance_review_reject' : 'admin_review_reject', 'budget', b.id, `reason=${(p.reason || '').trim()}`); this.notify(action === 'finance_review' ? String(b.requestedBy) : 'finance', `Permohonan belanjawan (Ruj: ${b.id}) telah DITOLAK. Jumlah: RM ${fmt(b.amount)}`); } return { ok: true }; } case 'receive_budget': { requireRole(['finance']); const b = this.budgets.find(x => x.id === p.id); if (b) { b.status = BUDGET_STATUS.RECEIVED; this.notify('admin', `Kewangan telah menerima dana (Ruj: ${b.id}). Jumlah: RM ${fmt(b.amount)}`); } return { ok: true }; } case 'assign_budget': { requireRole(['finance']); const b = this.budgets.find(x => x.id === parseInt(p.budgetId)); const amt = parseFloat(p.amount); if (!b || amt <= 0 || amt > b.balance) this.err('Jumlah agihan tidak sah atau melebihi baki yang ada!'); if (!files || !files.transfer) this.err('Sila muat naik resit pindahan bank!'); b.assignments.push({ id: this.seq++, staffId: parseInt(p.staffId), amount: amt, balance: amt, transferReceipt: this.img(files.transfer), expenses: [] }); b.balance -= amt; b.status = BUDGET_STATUS.ASSIGNED; this.notify(String(p.staffId), `Anda telah menerima agihan bajet baru (RM ${fmt(amt)}) daripada Kewangan (Ruj: ${b.id}).`); return { ok: true }; } case 'add_expense': { requireRole(['finance','purchasing']); const b = this.budgets.find(x => x.id === parseInt(p.budgetId)); if (!b) this.err('Belanjawan tidak dijumpai.'); const amt = parseFloat(p.amount); const images = {}; ['resit1','resit2','resit3','resit4','barang1','barang2','barang3','barang4'].forEach(k => { if (files && files[k]) images[k] = this.img(files[k]); }); if (!images.resit1) this.err('Muat Naik Resit 1 adalah diwajibkan!'); const exp = { id: this.seq++, date: this.today(), amount: amt, description: p.description, images, status: EXPENSE_STATUS.PENDING, created_by: me.id }; if (p.assignmentId) { const a = b.assignments.find(x => x.id === parseInt(p.assignmentId)); if (!a) this.err('Agihan tidak dijumpai.'); if (amt > a.balance) this.err('Jumlah perbelanjaan melebihi baki dana!'); a.expenses.unshift(exp); } else { if (amt > b.balance) this.err('Jumlah perbelanjaan melebihi baki dana!'); b.expenses.unshift(exp); } this.notify('finance', `Perbelanjaan baru menunggu kelulusan. Jumlah: RM ${fmt(amt)}`); this.notify('admin', `Perbelanjaan baru dikemaskini oleh ${me.name}. Jumlah: RM ${fmt(amt)}`); return { ok: true }; } case 'expense_review': { requireRole(['finance']); if (!['approve','reject'].includes(p.decision)) this.err('Keputusan tidak sah.'); if (p.decision === 'reject' && (p.reason || '').trim().length < 5) this.err('Sebab penolakan diwajibkan.'); for (const b of this.budgets) { const pools = [{ list: b.expenses, holder: b }, ...b.assignments.map(a => ({ list: a.expenses, holder: a }))]; for (const { list, holder } of pools) { const e = list.find(x => x.id === p.expenseId); if (e) { if (e.status !== EXPENSE_STATUS.PENDING) this.err('Rekod perbelanjaan tidak sah atau telah diproses.'); if (p.decision === 'approve') { if (holder.balance < e.amount) this.err('Baki dana tidak mencukupi untuk meluluskan perbelanjaan ini.'); e.status = EXPENSE_STATUS.APPROVED; holder.balance -= e.amount; this.logAudit('expense_approve', 'expense', e.id, `amount=${fmt(e.amount)}`); } else { e.status = EXPENSE_STATUS.REJECTED; this.logAudit('expense_reject', 'expense', e.id, `reason=${(p.reason || '').trim()}`); } this.notify(String(e.created_by), `Perbelanjaan (RM ${fmt(e.amount)}) telah ${p.decision === 'approve' ? 'diluluskan' : 'DITOLAK'}.`); return { ok: true }; } } } this.err('Rekod tidak dijumpai.'); } case 'edit_budget': { requireRole(['admin','finance']); const b = this.budgets.find(x => x.id === p.id); if (!b) this.err('Rekod tidak dijumpai.'); const amt = parseFloat(p.amount); const diff = amt - b.amount; b.description = p.description; b.location = p.location; b.amount = amt; b.balance += diff; this.notify('admin', `Rekod belanjawan (Ruj: ${b.id}) telah dikemaskini. Jumlah: RM ${fmt(amt)}`); return { ok: true }; } case 'edit_expense': { requireRole(['admin','finance']); for (const b of this.budgets) { const pools = [{ list: b.expenses, holder: b }, ...b.assignments.map(a => ({ list: a.expenses, holder: a }))]; for (const { list, holder } of pools) { const e = list.find(x => x.id === p.expenseId); if (e) { const amt = parseFloat(p.amount); if (e.status === EXPENSE_STATUS.APPROVED) holder.balance -= (amt - e.amount); e.description = p.description; e.amount = amt; return { ok: true }; } } } this.err('Rekod tidak dijumpai.'); } case 'save_user': { requireRole(['admin','finance']); if (p.id) { const u = this.users.find(x => x.id === p.id); if (!u) this.err('Pengguna tidak dijumpai.'); u.name = p.name; u.role = p.role; u.username = p.username; u.email = p.email || null; } else { if (!p.password || p.password.length < 8) this.err('Kata laluan mestilah sekurang-kurangnya 8 aksara.'); this.users.push({ id: this.seq++, name: p.name, role: p.role, username: p.username, email: p.email || null, active: 1 }); } return { ok: true }; } case 'delete_user': { requireRole(['admin','finance']); const u = this.users.find(x => x.id === p.id); if (u) u.active = 0; return { ok: true }; } case 'change_password': { need(); if (!p.new || p.new.length < 8) this.err('Kata laluan baru mestilah sekurang-kurangnya 8 aksara.'); if (!(p.old || '').length) this.err('Kata laluan semasa salah. (Nota: mod demo tidak menyimpan kata laluan sebenar.)'); return { ok: true }; } case 'mark_read': { need(); this.notifications.forEach(n => { if (n.target === String(me.id) || n.target === me.role) n.read = true; }); return { ok: true }; } default: this.err('Tindakan tidak dikenali.'); } } }; // ---------- KLIEN API ---------- const Api = { base: 'api/api.php', csrf: null, demo: false, async request(action, payload, files) { if (this.demo) return DemoServer.call(action, payload || {}, files || null); const opts = { method: 'POST', headers: {}, credentials: 'same-origin' }; if (this.csrf) opts.headers['X-CSRF-Token'] = this.csrf; if (files) { const fd = new FormData(); Object.entries(payload || {}).forEach(([k, v]) => fd.append(k, v == null ? '' : v)); Object.entries(files).forEach(([k, f]) => { if (f) fd.append(k, f); }); opts.body = fd; } else if (payload) { opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(payload); } const r = await fetch(this.base + '?action=' + action, opts); const j = await r.json().catch(() => ({ ok: false, error: 'Ralat pelayan. Cuba sebentar lagi.' })); if (!j.ok) { const e = new Error(j.error || 'Ralat tidak diketahui.'); e.status = r.status; throw e; } if (j.csrf) this.csrf = j.csrf; return j; } }; async function detectServer() { try { const c = new AbortController(); setTimeout(() => c.abort(), 4000); const r = await fetch('api/api.php?action=ping', { signal: c.signal, credentials: 'same-origin' }); const j = await r.json(); return j && j.server === true; } catch (e) { return false; } } // ---------- APLIKASI UTAMA ---------- function App() { const [phase, setPhase] = useState('boot'); const [demo, setDemo] = useState(false); const [me, setMe] = useState(null); const [users, setUsers] = useState([]); const [budgets, setBudgets] = useState([]); const [notifications, setNotifications] = useState([]); const [audit, setAudit] = useState([]); const [activeTab, setActiveTab] = useState('dashboard'); const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); const [busy, setBusy] = useState(false); const [toast, setToast] = useState(null); const [showPassModal, setShowPassModal] = useState(false); const refresh = async () => { const j = await Api.request('state'); setMe(j.me); setUsers(j.users); setBudgets(j.budgets); setNotifications(j.notifications); setAudit(j.audit || []); }; useEffect(() => { (async () => { const server = await detectServer(); Api.demo = !server; setDemo(!server); try { await refresh(); setPhase('app'); } catch (e) { setPhase('login'); } })(); }, []); useEffect(() => { if (phase !== 'app') return; const t = setInterval(() => refresh().catch(() => {}), 60000); return () => clearInterval(t); }, [phase]); useEffect(() => { if (!toast) return; const t = setTimeout(() => setToast(null), 6000); return () => clearTimeout(t); }, [toast]); const act = async (action, payload, files) => { setBusy(true); try { await Api.request(action, payload, files); await refresh(); return true; } catch (e) { setToast(e.message); if (e.status === 401) { setPhase('login'); setMe(null); } return false; } finally { setBusy(false); } }; const handleLogin = async (username, password) => { setBusy(true); try { await Api.request('login', { username, password }); await refresh(); setActiveTab('dashboard'); setPhase('app'); return null; } catch (e) { return e.message; } finally { setBusy(false); } }; const handleLogout = async () => { try { await Api.request('logout'); } catch (e) {} Api.csrf = null; setMe(null); setPhase('login'); setActiveTab('dashboard'); }; if (phase === 'boot') return (
ALFA

Menyambung ke pelayan…

); if (phase === 'login' || !me) return ( {demo && } ); const unread = notifications.filter(n => !n.read).length; return (
{demo && } {busy && } {toast && (
{toast}
)} {/* Header Mudah Alih */}

ALFA Kewangan

{/* Bar Sisi */}

Log masuk sebagai:

{me.name}

{roleLabel(me.role)}
{/* Kandungan Utama */}
{activeTab === 'dashboard' && } {activeTab === 'new_budget' && (me.role === ROLES.FINANCE || me.role === ROLES.PURCHASING) && ( { if (await act('create_budget', form)) setActiveTab('dashboard'); }} /> )} {activeTab === 'reports' && } {activeTab === 'audit' && (me.role === ROLES.ADMIN || me.role === ROLES.FINANCE) && } {activeTab === 'notifications' && ( act('mark_read', {})} /> )}
{showPassModal && setShowPassModal(false)} />}
); } function DemoBanner() { return (
MOD DEMO — pelayan tidak dikesan. Data TIDAK disimpan. (Log masuk: pengurus / ahmad / siti / raju)
); } function BusyOverlay() { return (
Sedang diproses… Sila tunggu.
); } function NavItem({ icon, label, isActive, onClick, badge }) { return ( ); } // ---------- LOG MASUK ---------- function LoginView({ onLogin, busy, demo }) { const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const [error, setError] = useState(null); const submit = async (e) => { e.preventDefault(); setError(null); const err = await onLogin(username.trim(), password); if (err) setError(err); }; return (

SISTEM KEWANGAN ALFA

Sila log masuk untuk meneruskan

{error && (
{error}
)}
setUsername(e.target.value)} className="w-full border border-gray-300 rounded-lg px-4 py-3 focus:ring-2 focus:ring-blue-500 focus:outline-none" />
setPassword(e.target.value)} className="w-full border border-gray-300 rounded-lg px-4 py-3 focus:ring-2 focus:ring-blue-500 focus:outline-none" />

Lupa kata laluan? Sila hubungi Pentadbir sistem.

{demo &&

Mod demo: pengguna "pengurus", "ahmad", "siti" atau "raju" — sebarang kata laluan.

}
); } // ---------- PAPAN PEMUKA ---------- function Dashboard({ me, users, budgets, act }) { if (me.role === ROLES.ADMIN) return ; if (me.role === ROLES.FINANCE) return ; if (me.role === ROLES.PURCHASING) return ; return null; } // PAPAN PEMUKA PENGURUSAN function AdminDashboard({ me, users, budgets, act }) { const pendingBudgets = budgets.filter(b => b.status === BUDGET_STATUS.PENDING_ADMIN); const [editingBudget, setEditingBudget] = useState(null); const [editAmount, setEditAmount] = useState(''); const [editingRecord, setEditingRecord] = useState(null); const [isUserModalOpen, setIsUserModalOpen] = useState(false); const handleApprove = async (id, newAmount = null) => { await act('admin_review', { id, decision: 'approve', amount: newAmount !== null ? parseFloat(newAmount) : undefined }); setEditingBudget(null); }; const handleReject = (id) => { const reason = window.prompt('Sila nyatakan sebab penolakan (min 5 aksara):'); if (reason === null) return; if (reason.trim().length < 5) { alert('Sebab penolakan diwajibkan (min 5 aksara).'); return; } return act('admin_review', { id, decision: 'reject', reason: reason.trim() }); }; const handleSaveRecord = async (d) => { await act('edit_budget', { id: d.id, description: d.description, location: d.location, amount: parseFloat(d.amount) }); setEditingRecord(null); }; return (

Ringkasan Pengurusan

} color="bg-blue-500" /> } color="bg-yellow-500" />
{pendingBudgets.length > 0 && (

Permohonan Belanjawan Perlu Kelulusan

{pendingBudgets.map(b => (

{b.description} ({b.location})

Dipohon oleh: {users.find(u => u.id === b.requestedBy)?.name || 'Kewangan'} | Tarikh Akhir: {b.dueDate}

RM {fmt(b.amount)}

))}
)} {editingBudget && (

Ubah Kelulusan Belanjawan

setEditAmount(e.target.value)} className="w-full border border-blue-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500 outline-none text-lg font-bold" />
)}

Sejarah Rekod Belanjawan

{budgets.length === 0 ? ( ) : budgets.map(b => ( ))}
TarikhKeteranganLokasiJumlah (RM)StatusTindakan
Tiada rekod belanjawan dijumpai.
{b.date} {b.description} {b.location} {fmt(b.amount)}
{editingRecord && setEditingRecord(null)} onSave={handleSaveRecord} />} {isUserModalOpen && setIsUserModalOpen(false)} />}
); } // PAPAN PEMUKA KEWANGAN function FinanceDashboard({ me, users, budgets, act }) { const [selectedBudgetId, setSelectedBudgetId] = useState(null); const emptyExpenseForm = { amount: '', description: '', resit1: null, resit2: null, resit3: null, resit4: null, barang1: null, barang2: null, barang3: null, barang4: null }; const [expenseForm, setExpenseForm] = useState(emptyExpenseForm); const [editingStaffBudget, setEditingStaffBudget] = useState(null); const [editStaffAmount, setEditStaffAmount] = useState(''); const [transferModal, setTransferModal] = useState({ isOpen: false, budgetId: null, staffId: null, assignAmount: '', receiptFile: null, maxAmount: 0 }); const [selectedExpenseImages, setSelectedExpenseImages] = useState(null); const [editingRecord, setEditingRecord] = useState(null); const [errorFeedback, setErrorFeedback] = useState(null); const [isUserModalOpen, setIsUserModalOpen] = useState(false); const selectedBudget = budgets.find(b => b.id === selectedBudgetId) || null; const closeTransfer = () => setTransferModal({ isOpen: false, budgetId: null, staffId: null, assignAmount: '', receiptFile: null, maxAmount: 0 }); const handleReceiveBudget = (id) => act('receive_budget', { id }); const confirmTransfer = async () => { if (!transferModal.receiptFile || !transferModal.assignAmount) { setErrorFeedback('Sila masukkan jumlah agihan dan muat naik resit pindahan bank!'); return; } const amt = parseFloat(transferModal.assignAmount); if (!(amt > 0) || amt > transferModal.maxAmount) { setErrorFeedback('Jumlah agihan tidak sah atau melebihi baki yang ada!'); return; } const ok = await act('assign_budget', { budgetId: transferModal.budgetId, staffId: transferModal.staffId, amount: amt }, { transfer: transferModal.receiptFile }); if (ok) { closeTransfer(); setErrorFeedback(null); } }; const handleApproveExpense = (expenseId) => act('expense_review', { expenseId, decision: 'approve' }); const handleRejectExpense = (expenseId) => { const reason = window.prompt('Sila nyatakan sebab penolakan (min 5 aksara):'); if (reason === null) return; if (reason.trim().length < 5) { alert('Sebab penolakan diwajibkan (min 5 aksara).'); return; } return act('expense_review', { expenseId, decision: 'reject', reason: reason.trim() }); }; const handleSubmitExpense = async (e) => { e.preventDefault(); if (!expenseForm.amount || !expenseForm.description || !expenseForm.resit1) { setErrorFeedback('Keterangan, Jumlah dan Muat Naik Resit 1 adalah diwajibkan!'); return; } if (parseFloat(expenseForm.amount) > selectedBudget.balance) { setErrorFeedback('Jumlah perbelanjaan melebihi baki dana!'); return; } const ok = await act('add_expense', { budgetId: selectedBudget.id, amount: parseFloat(expenseForm.amount), description: expenseForm.description }, { resit1: expenseForm.resit1, resit2: expenseForm.resit2, resit3: expenseForm.resit3, resit4: expenseForm.resit4, barang1: expenseForm.barang1, barang2: expenseForm.barang2, barang3: expenseForm.barang3, barang4: expenseForm.barang4 }); if (ok) { setExpenseForm(emptyExpenseForm); setSelectedBudgetId(null); setErrorFeedback(null); } }; const pendingStaffRequests = budgets.filter(b => b.status === BUDGET_STATUS.PENDING_FINANCE); const handleApproveStaffRequest = async (id, newAmount = null) => { await act('finance_review', { id, decision: 'approve', amount: newAmount !== null ? parseFloat(newAmount) : undefined }); setEditingStaffBudget(null); }; const handleRejectStaffRequest = (id) => { const reason = window.prompt('Sila nyatakan sebab penolakan (min 5 aksara):'); if (reason === null) return; if (reason.trim().length < 5) { alert('Sebab penolakan diwajibkan (min 5 aksara).'); return; } return act('finance_review', { id, decision: 'reject', reason: reason.trim() }); }; const handleSaveRecord = async (d) => { await act('edit_budget', { id: d.id, description: d.description, location: d.location, amount: parseFloat(d.amount) }); setEditingRecord(null); }; const purchasers = users.filter(u => u.role === ROLES.PURCHASING && u.active); const pendingExpenses = budgets.flatMap(b => [ ...(b.expenses || []).filter(e => e.status === EXPENSE_STATUS.PENDING).map(e => ({ ...e, budgetId: b.id, budgetDesc: b.description, assignedToName: 'Kewangan' })), ...(b.assignments || []).flatMap(a => (a.expenses || []).filter(e => e.status === EXPENSE_STATUS.PENDING).map(e => ({ ...e, budgetId: b.id, budgetDesc: b.description, assignedToName: users.find(u => u.id === a.staffId)?.name || 'Tidak diketahui' }))) ]); return (

Papan Pemuka Kewangan

{errorFeedback && (

Sila ambil perhatian

{errorFeedback}

)} {pendingStaffRequests.length > 0 && (

Permohonan Belanjawan dari Staf (Menunggu Kelulusan Anda)

{pendingStaffRequests.map(b => (

{b.description} ({b.location})

Dipohon oleh: {users.find(u => u.id === b.requestedBy)?.name} | Tarikh Akhir: {b.dueDate}

RM {fmt(b.amount)}

))}
)} {editingStaffBudget && (

Ubah Permohonan Belanjawan (Staf)

setEditStaffAmount(e.target.value)} className="w-full border border-blue-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500 outline-none text-lg font-bold" />
)} {selectedBudget && (

Masukkan Perbelanjaan (Kewangan)

{selectedBudget.description}

Jumlah Asal (Belanjawan)

RM {fmt(selectedBudget.amount)}

Baki Di Tangan Kewangan

RM {fmt(selectedBudget.balance)}

)}

Status Permohonan Anda

{budgets.filter(b => b.status === BUDGET_STATUS.PENDING_ADMIN || b.status === BUDGET_STATUS.REJECTED_BY_ADMIN).length === 0 ? (

Tiada permohonan sedang menunggu kelulusan Pengurusan.

) : (
{budgets.filter(b => b.status === BUDGET_STATUS.PENDING_ADMIN || b.status === BUDGET_STATUS.REJECTED_BY_ADMIN).map(b => (

{b.description}

Diminta: RM {fmt(b.amount)}

))}
)}

Penerimaan Dana & Agihan Bajet kepada Pembelian

{budgets.filter(b => [BUDGET_STATUS.APPROVED_BY_ADMIN, BUDGET_STATUS.RECEIVED, BUDGET_STATUS.ASSIGNED].includes(b.status)).length === 0 ? (

Tiada dana baru sedia untuk diproses.

) : (
{budgets.filter(b => [BUDGET_STATUS.APPROVED_BY_ADMIN, BUDGET_STATUS.RECEIVED, BUDGET_STATUS.ASSIGNED].includes(b.status)).map(b => { const isPurchaser = purchasers.some(p => p.id === b.requestedBy); const reqUser = users.find(u => u.id === b.requestedBy); return (

{b.description} ({b.location})

Jumlah Asal

RM {fmt(b.amount)}

Baki Belum Diagih

RM {fmt(b.balance)}

{b.assignments && b.assignments.length > 0 && (

Rekod Agihan Bajet:

{b.assignments.map(a => (
{users.find(u => u.id === a.staffId)?.name} RM {fmt(a.amount)} (Baki Semasa: RM {fmt(a.balance)})
))}
)}
{b.status === BUDGET_STATUS.APPROVED_BY_ADMIN && ( )} {(b.status === BUDGET_STATUS.RECEIVED || b.status === BUDGET_STATUS.ASSIGNED) && b.balance > 0 && (
{isPurchaser ? ( {reqUser?.name} ) : ( )}
)} {(b.status === BUDGET_STATUS.RECEIVED || b.status === BUDGET_STATUS.ASSIGNED) && ( )}
); })}
)}

Kelulusan Perbelanjaan (Staf & Kewangan)

{pendingExpenses.length === 0 ? (

Tiada perbelanjaan menunggu kelulusan.

) : (
{pendingExpenses.map((e, index) => (
Menunggu Dana: {e.budgetDesc}

{e.description}

Staf: {e.assignedToName} | Tarikh: {e.date}

RM {fmt(e.amount)}

))}
)}

Semua Rekod Belanjawan Saya

{budgets.filter(b => b.requestedBy === me.id || (b.assignments || []).some(a => a.staffId === me.id)).length === 0 ? ( ) : budgets.filter(b => b.requestedBy === me.id || (b.assignments || []).some(a => a.staffId === me.id)).map(b => ( ))}
TarikhKeteranganLokasiJumlah (RM)StatusTindakan
Tiada rekod belanjawan dijumpai.
{b.date} {b.description} {b.location} {fmt(b.amount)}
{editingRecord && setEditingRecord(null)} onSave={handleSaveRecord} />} {transferModal.isOpen && (

Butiran Agihan Bajet

Anda sedang membuat agihan bajet kepada: {users.find(u => u.id === transferModal.staffId)?.name}

Baki maksimum yang boleh diagih: RM {fmt(transferModal.maxAmount)}

setTransferModal({ ...transferModal, assignAmount: e.target.value })} />
setTransferModal({ ...transferModal, receiptFile: e.target.files[0] })} icon={Receipt} />
)} setSelectedExpenseImages(null)} /> {isUserModalOpen && setIsUserModalOpen(false)} />}
); } // BORANG PERBELANJAAN (dikongsi Kewangan & Pembelian) function ExpenseForm({ form, setForm, onSubmit, disabled, idPrefix }) { return (
setForm({ ...form, amount: e.target.value })} />
{[1, 2, 3, 4].map(n => ( setForm({ ...form, [`resit${n}`]: e.target.files[0] })} icon={Receipt} /> ))}
{[1, 2, 3, 4].map(n => ( setForm({ ...form, [`barang${n}`]: e.target.files[0] })} icon={Upload} /> ))}

* Perbelanjaan akan ditolak dari dana selepas diluluskan oleh Kewangan.

); } // PAPAN PEMUKA PEMBELIAN function PurchasingDashboard({ me, users, budgets, act }) { const [selectedKey, setSelectedKey] = useState(null); // {budgetId, assignmentId} const emptyExpenseForm = { amount: '', description: '', resit1: null, resit2: null, resit3: null, resit4: null, barang1: null, barang2: null, barang3: null, barang4: null }; const [expenseForm, setExpenseForm] = useState(emptyExpenseForm); const [errorFeedback, setErrorFeedback] = useState(null); const myAssignments = budgets.flatMap(b => (b.assignments || []).filter(a => a.staffId === me.id).map(a => ({ ...a, budgetId: b.id, budgetDesc: b.description, location: b.location, budgetStatus: b.status })) ); // Baki sifar → keluar dari "Agihan Bajet Saya" (aktif), pindah ke "Transaksi Sebelumnya". const activeAssignments = myAssignments.filter(a => a.balance > 0); const pastAssignments = myAssignments.filter(a => a.balance <= 0); const selectedAssignment = selectedKey ? myAssignments.find(a => a.id === selectedKey.assignmentId && a.budgetId === selectedKey.budgetId) : null; const handleSubmitExpense = async (e) => { e.preventDefault(); if (!expenseForm.amount || !expenseForm.description || !expenseForm.resit1) { setErrorFeedback('Keterangan, Jumlah dan Muat Naik Resit 1 adalah diwajibkan!'); return; } if (parseFloat(expenseForm.amount) > selectedAssignment.balance) { setErrorFeedback('Jumlah perbelanjaan melebihi baki dana!'); return; } const ok = await act('add_expense', { budgetId: selectedAssignment.budgetId, assignmentId: selectedAssignment.id, amount: parseFloat(expenseForm.amount), description: expenseForm.description }, { resit1: expenseForm.resit1, resit2: expenseForm.resit2, resit3: expenseForm.resit3, resit4: expenseForm.resit4, barang1: expenseForm.barang1, barang2: expenseForm.barang2, barang3: expenseForm.barang3, barang4: expenseForm.barang4 }); if (ok) { setExpenseForm(emptyExpenseForm); setSelectedKey(null); setErrorFeedback(null); } }; return (

Agihan Bajet Saya

{errorFeedback && (

Perhatian

{errorFeedback}

)} {selectedAssignment ? (

Masukkan Perbelanjaan

{selectedAssignment.budgetDesc} ({selectedAssignment.location})

Jumlah Asal (Agihan Bajet Ini)

RM {fmt(selectedAssignment.amount)}

Baki Terkini

RM {fmt(selectedAssignment.balance)}

{selectedAssignment.balance <= 0 && (

Dana telah habis. Lebihan akan diserahkan kembali kepada Kewangan.

)}
) : (
{activeAssignments.length === 0 ? (
Tiada agihan bajet aktif ditugaskan kepada anda setakat ini.
) : activeAssignments.map(a => (
{a.location}

{a.budgetDesc}

Baki Dana

RM {fmt(a.balance)}

Jumlah Diagih

RM {fmt(a.amount)}

))}
)} {!selectedAssignment && pastAssignments.length > 0 && (

Transaksi Sebelumnya

Agihan bajet yang dananya telah habis (baki RM 0.00).

{pastAssignments.map(a => (
{a.location}

{a.budgetDesc}

Baki Dana

RM {fmt(a.balance)}

Jumlah Diagih

RM {fmt(a.amount)}

))}
)} {!selectedAssignment && (

Semua Rekod Permohonan & Agihan Saya

{budgets.filter(b => b.requestedBy === me.id || (b.assignments || []).some(a => a.staffId === me.id)).length === 0 ? ( ) : budgets.filter(b => b.requestedBy === me.id || (b.assignments || []).some(a => a.staffId === me.id)).map(b => ( ))}
TarikhKeterangan (Belanjawan)LokasiStatus
Tiada rekod dijumpai.
{b.date} {b.description} {b.location}
)}
); } // PENGURUSAN PENGGUNA (dengan username & kata laluan) function UserManagementModal({ me, users, act, allowedRoles, onClose }) { const empty = { id: 0, name: '', role: allowedRoles[0], username: '', email: '', password: '' }; const [form, setForm] = useState(empty); const [isEditing, setIsEditing] = useState(false); const [error, setError] = useState(null); const filteredUsers = users.filter(u => allowedRoles.includes(u.role) && u.active); const handleSave = async (e) => { e.preventDefault(); if (!form.name.trim()) { setError('Nama tidak boleh dibiarkan kosong.'); return; } if (!/^[a-z0-9_.]{3,30}$/.test(form.username)) { setError('Nama pengguna: 3-30 aksara (huruf kecil, nombor, _ .).'); return; } if (!isEditing && form.password.length < 8) { setError('Kata laluan mestilah sekurang-kurangnya 8 aksara.'); return; } const ok = await act('save_user', { id: form.id || undefined, name: form.name, role: form.role, username: form.username, email: form.email, password: form.password }); if (ok) { setForm(empty); setIsEditing(false); setError(null); } }; const startEdit = (u) => { setForm({ id: u.id, name: u.name, role: u.role, username: u.username || '', email: u.email || '', password: '' }); setIsEditing(true); setError(null); }; const handleDelete = async (id) => { if (!window.confirm('Nyahaktifkan pengguna ini? Rekod lama akan kekal.')) return; await act('delete_user', { id }); if (form.id === id) { setForm(empty); setIsEditing(false); } }; return (

Pengurusan Pengguna ({allowedRoles.map(r => r === 'finance' ? 'Kewangan' : 'Pembelian').join(' / ')})

{error && (
{error}
)}

{isEditing ? 'Ubah Pengguna Sedia Ada' : 'Daftar Pengguna Baru'}

setForm({ ...form, name: e.target.value })} className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" />
setForm({ ...form, username: e.target.value.toLowerCase() })} className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" />
setForm({ ...form, email: e.target.value })} className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" />
setForm({ ...form, password: e.target.value })} className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" />
{isEditing && ( )}

Senarai Pengguna Aktif ({filteredUsers.length})

{filteredUsers.length === 0 ? ( ) : filteredUsers.map(u => ( ))}
NamaPerananTindakan
Tiada pengguna berdaftar dalam kategori ini.
{u.name} {u.role === ROLES.FINANCE ? 'Kewangan' : 'Pembelian'}
); } // BORANG PERMOHONAN BELANJAWAN function NewBudgetForm({ onSubmit, me }) { const [form, setForm] = useState({ amount: '', description: '', location: LOCATIONS[0], dueDate: '', notes: '' }); const handleSubmit = (e) => { e.preventDefault(); onSubmit(form); }; return (

Borang Permohonan Belanjawan Baru

Dijana secara automatik

setForm({ ...form, dueDate: e.target.value })} />
setForm({ ...form, amount: e.target.value })} />

{me?.role === ROLES.PURCHASING ? 'Permohonan akan dihantar ke Kewangan untuk disemak sebelum ke Pengurusan.' : 'Permohonan akan dihantar ke Pengurusan untuk disemak dan diluluskan.'}

); } // LOG AUDIT — jejak kelulusan & penolakan (admin & kewangan) function AuditLogView({ audit }) { const [q, setQ] = useState(''); const ACTIONS = { finance_review_approve: { label: 'Lulus (Kewangan)', cls: 'bg-green-100 text-green-700' }, finance_review_reject: { label: 'Tolak (Kewangan)', cls: 'bg-red-100 text-red-700' }, admin_review_approve: { label: 'Lulus (Pengurusan)', cls: 'bg-green-100 text-green-700' }, admin_review_reject: { label: 'Tolak (Pengurusan)', cls: 'bg-red-100 text-red-700' }, expense_approve: { label: 'Lulus Perbelanjaan', cls: 'bg-green-100 text-green-700' }, expense_reject: { label: 'Tolak Perbelanjaan', cls: 'bg-red-100 text-red-700' }, }; const rows = (audit || []).filter(r => ACTIONS[r.action]); const filtered = rows.filter(r => { const s = q.trim().toLowerCase(); if (!s) return true; return [r.user, ACTIONS[r.action]?.label, r.entity, String(r.entityId), r.details].join(' ').toLowerCase().includes(s); }); const fmtDate = (d) => { try { return new Date(d).toLocaleString('ms-MY', { dateStyle: 'medium', timeStyle: 'short' }); } catch (e) { return d; } }; return (

Log Audit Kelulusan

Rekod semua kelulusan dan penolakan belanjawan & perbelanjaan.

setQ(e.target.value)} placeholder="Cari (nama, rujukan, tindakan)…" className="w-full sm:w-72 border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500 outline-none text-sm" />
{filtered.length === 0 ? ( ) : filtered.map(r => ( ))}
Tarikh & Masa Pengguna Tindakan Rujukan Butiran
Tiada rekod kelulusan setakat ini.
{fmtDate(r.date)} {r.user || '—'} {ACTIONS[r.action].label} {r.entity === 'budget' ? 'Belanjawan' : 'Perbelanjaan'} #{r.entityId} {r.details || '—'}
); } // LAPORAN & REKOD (+ Cetak & CSV) function ReportsView({ me, users, budgets, act }) { const [filterLoc, setFilterLoc] = useState('All'); const [filterStaff, setFilterStaff] = useState('All'); const [selectedExpenseImages, setSelectedExpenseImages] = useState(null); const [editingExpense, setEditingExpense] = useState(null); const allExpenses = useMemo(() => { let exps = budgets.flatMap(b => [ ...(b.expenses || []).map(e => ({ ...e, budgetId: b.id, budgetDesc: b.description, location: b.location, purchaserId: e.created_by, purchaserName: 'Kewangan' })), ...(b.assignments || []).flatMap(a => (a.expenses || []).map(e => ({ ...e, budgetId: b.id, budgetDesc: b.description, location: b.location, purchaserId: a.staffId, purchaserName: users.find(u => u.id === a.staffId)?.name || 'Tidak diketahui' }))) ]); if (me?.role === ROLES.PURCHASING) exps = exps.filter(e => e.purchaserId === me.id); return exps.filter(e => (filterLoc === 'All' || e.location === filterLoc) && (filterStaff === 'All' || e.purchaserName === filterStaff)); }, [budgets, filterLoc, filterStaff, me, users]); const handleSaveExpense = async (d) => { await act('edit_expense', { expenseId: d.id, description: d.description, amount: parseFloat(d.amount) }); setEditingExpense(null); }; const staffList = ['Kewangan', ...users.filter(u => u.role === ROLES.PURCHASING && u.active).map(u => u.name)]; const isPrivileged = me?.role === ROLES.ADMIN || me?.role === ROLES.FINANCE; const downloadCsv = () => { const safeCell = (v) => { const t = String(v ?? ''); const safe = /^[=+\-@]/.test(t) ? "'" + t : t; return '"' + safe.replace(/"/g, '""') + '"'; }; const head = ['ID Ruj', 'Tarikh', 'Lokasi', 'Staf', 'Keterangan', 'Jumlah (RM)', 'Status']; const rows = allExpenses.map(e => [e.id, e.date, e.location, e.purchaserName, e.description, fmt(e.amount), e.status].map(safeCell)); const csv = '\uFEFF' + [head.map(safeCell).join(','), ...rows.map(r => r.join(','))].join('\r\n'); const a = document.createElement('a'); a.href = URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8' })); a.download = `laporan-perbelanjaan-${new Date().toISOString().split('T')[0]}.csv`; a.click(); }; return (

Laporan & Rekod

{me?.role !== ROLES.PURCHASING && (
)}
{allExpenses.length === 0 ? ( ) : allExpenses.map((exp, idx) => ( ))}
ID RujTarikhLokasiStafKeteranganJumlah (RM)StatusTindakan
Tiada rekod perbelanjaan ditemui untuk tapisan ini.
{String(exp.id).slice(-6)} {exp.date} {exp.location} {exp.purchaserName} {exp.description} {fmt(exp.amount)} {exp.status} {isPrivileged && ( )}
setSelectedExpenseImages(null)} /> {editingExpense && setEditingExpense(null)} onSave={handleSaveExpense} />}
); } // LAPORAN BERCETAK (dipaparkan hanya semasa cetak) function PrintReport({ expenses, filterLoc, filterStaff, me }) { const root = document.getElementById('print-root'); if (!root) return null; const total = expenses.reduce((s, e) => s + parseFloat(e.amount || 0), 0); const approved = expenses.filter(e => e.status === EXPENSE_STATUS.APPROVED).reduce((s, e) => s + parseFloat(e.amount || 0), 0); const pending = expenses.filter(e => e.status === EXPENSE_STATUS.PENDING).reduce((s, e) => s + parseFloat(e.amount || 0), 0); const now = new Date(); const th = { border: '1px solid #cbd5e1', padding: '7px 9px', background: '#1e3a8a', color: '#fff', fontSize: '11px', textAlign: 'left' }; const td = { border: '1px solid #cbd5e1', padding: '6px 9px', fontSize: '11px' }; return ReactDOM.createPortal(
ALFA
SISTEM KEWANGAN — LAPORAN PERBELANJAAN
Tarikh cetakan: {now.toLocaleDateString('ms-MY')} {now.toLocaleTimeString('ms-MY')}
Dicetak oleh: {me?.name}
Tapisan: Lokasi — {filterLoc === 'All' ? 'Semua' : filterLoc} · Staf — {filterStaff === 'All' ? 'Semua' : filterStaff}
{[['Jumlah Rekod', expenses.length], ['Jumlah Keseluruhan', 'RM ' + fmt(total)], ['Diluluskan', 'RM ' + fmt(approved)], ['Menunggu', 'RM ' + fmt(pending)]].map(([label, val], i) => (
{label}
{val}
))}
{expenses.map((e, i) => ( ))}
#TarikhLokasiStafKeteranganJumlah (RM)Status
{i + 1}{e.date}{e.location}{e.purchaserName} {e.description}{fmt(e.amount)}{e.status}
JUMLAH KESELURUHAN {fmt(total)}
{['Disediakan oleh (Kewangan)', 'Disahkan oleh (Pengurusan)'].map((label, i) => (
{label}
Nama: ____________________   Tarikh: ____________
))}
, root); } // PEMBERITAHUAN function NotificationsView({ notifications, onMarkAllRead }) { return (

Pemberitahuan / Notifikasi

{notifications.length === 0 ? (
Tiada pemberitahuan baru.
) : (
    {notifications.map(n => (
  • {n.message}

    {new Date((n.date || '').replace(' ', 'T')).toLocaleString('ms-MY')}

  • ))}
)}
); } // PEMBANTU UI & MODAL // LOAN/PENGGUNAAN DANA — lawan pemuka data (Pengurusan & Kewangan sahaja) function FinancialOverview({ users, budgets }) { const [openCard, setOpenCard] = useState(null); // 'spent' | 'pending' | 'remaining' const userName = (id) => users.find(u => u.id === id)?.name || 'Tidak diketahui'; const FINANCE_HOLDER = 'Kewangan (Pusat)'; const data = useMemo(() => { const rows = []; let spent = 0, pending = 0, remaining = 0; const perPerson = {}; const ensure = (name) => (perPerson[name] || (perPerson[name] = { name, took: 0, spent: 0, pending: 0, remaining: 0 })); const RECEIVED = [BUDGET_STATUS.RECEIVED, BUDGET_STATUS.ASSIGNED, BUDGET_STATUS.CLOSED]; budgets.forEach(b => { if (RECEIVED.includes(b.status)) { ensure(FINANCE_HOLDER).took += b.amount; ensure(FINANCE_HOLDER).remaining += b.balance; remaining += b.balance; } const addExp = (holderName, e) => { const row = { holderName, budgetDesc: b.description, location: b.location, description: e.description, amount: e.amount, status: e.status, date: e.date, spender: userName(e.created_by) }; if (e.status === EXPENSE_STATUS.APPROVED) { spent += e.amount; ensure(holderName).spent += e.amount; rows.push(row); } else if (e.status === EXPENSE_STATUS.PENDING) { pending += e.amount; ensure(holderName).pending += e.amount; rows.push(row); } }; (b.expenses || []).forEach(e => addExp(FINANCE_HOLDER, e)); (b.assignments || []).forEach(a => { const holderName = userName(a.staffId); ensure(holderName).took += a.amount; ensure(holderName).remaining += a.balance; remaining += a.balance; (a.expenses || []).forEach(e => addExp(holderName, e)); }); }); return { spent, pending, remaining, rows, perPerson: Object.values(perPerson).filter(p => p.took || p.spent || p.pending || p.remaining) }; }, [budgets, users]); const cards = [ { key: 'spent', title: 'Jumlah Perbelanjaan', value: data.spent, sub: 'Perbelanjaan yang telah diluluskan', color: 'rose', icon: }, { key: 'pending', title: 'Menunggu Kelulusan', value: data.pending, sub: 'Amaun perbelanjaan belum diluluskan', color: 'amber', icon: }, { key: 'remaining', title: 'Baki Belum Dibelanja', value: data.remaining, sub: 'Dana dipegang tetapi belum dibelanja', color: 'emerald', icon: }, ]; const C = { rose: { ring: 'ring-rose-500', bar: 'bg-rose-500', text: 'text-rose-600', soft: 'bg-rose-50' }, amber: { ring: 'ring-amber-500', bar: 'bg-amber-500', text: 'text-amber-600', soft: 'bg-amber-50' }, emerald: { ring: 'ring-emerald-500', bar: 'bg-emerald-500', text: 'text-emerald-600', soft: 'bg-emerald-50' }, }; const approvedRows = data.rows.filter(r => r.status === EXPENSE_STATUS.APPROVED); const pendingRows = data.rows.filter(r => r.status === EXPENSE_STATUS.PENDING); const ExpTable = ({ list, accent }) => (
{list.length === 0 ? ( ) : list.map((r, i) => ( ))}
Individu / PemegangBelanjawanKeteranganDimasukkan olehTarikhJumlah (RM)
Tiada rekod.
{r.holderName} {r.budgetDesc} ({r.location}) {r.description} {r.spender} {r.date} {fmt(r.amount)}
); return (

Papan Data Kewangan

Klik mana-mana kad untuk melihat butiran

{cards.map(c => { const active = openCard === c.key; return ( ); })}
{openCard && (
c.key === openCard).color].soft}`}>

{openCard === 'spent' && 'Butiran Perbelanjaan Diluluskan'} {openCard === 'pending' && 'Butiran Perbelanjaan Menunggu Kelulusan'} {openCard === 'remaining' && 'Baki Dana Mengikut Individu (Diambil / Dibelanja / Baki)'}

{openCard === 'spent' && } {openCard === 'pending' && } {openCard === 'remaining' && (
{data.perPerson.length === 0 ? ( ) : data.perPerson.map((p, i) => ( ))}
Individu / PemegangDiambil (RM)Dibelanja (RM)Menunggu (RM)Baki (RM)
Tiada rekod.
{p.name} {fmt(p.took)} {fmt(p.spent)} {fmt(p.pending)} {fmt(p.remaining)}
)}
)}
); } function StatCard({ title, value, icon, color }) { return (
{icon}

{title}

{value}

); } function StatusBadge({ status }) { let color = 'bg-gray-100 text-gray-800'; if (status === BUDGET_STATUS.PENDING_FINANCE) color = 'bg-orange-100 text-orange-800'; if (status === BUDGET_STATUS.REJECTED_BY_FINANCE) color = 'bg-red-100 text-red-800'; if (status === BUDGET_STATUS.PENDING_ADMIN) color = 'bg-yellow-100 text-yellow-800'; if (status === BUDGET_STATUS.APPROVED_BY_ADMIN) color = 'bg-green-100 text-green-800'; if (status === BUDGET_STATUS.REJECTED_BY_ADMIN) color = 'bg-red-100 text-red-800'; if (status === BUDGET_STATUS.RECEIVED) color = 'bg-blue-100 text-blue-800'; if (status === BUDGET_STATUS.ASSIGNED) color = 'bg-purple-100 text-purple-800'; if (status === BUDGET_STATUS.CLOSED) color = 'bg-gray-200 text-gray-600'; return {status}; } function ImageUploadBox({ id, label, isRequired, file, onChange, icon: Icon }) { return (
); } function ExpenseImageModal({ images, onClose }) { if (!images) return null; const cell = (img, emptyLabel) => (
{img ? ( {img.name} Lihat penuh ) : ( {emptyLabel} )}
); return (

Lampiran Resit & Barang

Gambar Resit

{[1, 2, 3, 4].map(n => {cell(images[`resit${n}`], `Tiada Resit ${n}`)})}

Gambar Barang

{[1, 2, 3, 4].map(n => {cell(images[`barang${n}`], `Tiada Barang ${n}`)})}
); } function EditBudgetRecordModal({ budget, onClose, onSave }) { const [form, setForm] = useState({ description: budget.description, location: budget.location, amount: budget.amount }); return (

Ubah Rekod Belanjawan

setForm({ ...form, description: e.target.value })} className="w-full border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500 outline-none" />
setForm({ ...form, amount: e.target.value })} className="w-full border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500 outline-none font-bold text-lg" />

Mengubah jumlah ini akan mengemaskini baki keseluruhan secara automatik.

); } function EditExpenseRecordModal({ expense, onClose, onSave }) { const [form, setForm] = useState({ description: expense.description, amount: expense.amount }); return (

Ubah Rekod Perbelanjaan