/* 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 && (
)}
{/* Header Mudah Alih */}
ALFA Kewangan
setIsMobileMenuOpen(!isMobileMenuOpen)} className="p-2 -m-2">
{isMobileMenuOpen ? : }
{/* Bar Sisi */}
ALFASISTEM KEWANGAN
Log masuk sebagai:
{me.name}
{roleLabel(me.role)}
} label="Papan Pemuka" isActive={activeTab === 'dashboard'} onClick={() => { setActiveTab('dashboard'); setIsMobileMenuOpen(false); }} />
{(me.role === ROLES.FINANCE || me.role === ROLES.PURCHASING) && (
} label="Mohon Belanjawan" isActive={activeTab === 'new_budget'} onClick={() => { setActiveTab('new_budget'); setIsMobileMenuOpen(false); }} />
)}
} label="Laporan & Rekod" isActive={activeTab === 'reports'} onClick={() => { setActiveTab('reports'); setIsMobileMenuOpen(false); }} />
{(me.role === ROLES.ADMIN || me.role === ROLES.FINANCE) && (
} label="Log Audit" isActive={activeTab === 'audit'} onClick={() => { setActiveTab('audit'); setIsMobileMenuOpen(false); }} />
)}
} label="Pemberitahuan" isActive={activeTab === 'notifications'} onClick={() => { setActiveTab('notifications'); setIsMobileMenuOpen(false); }} badge={unread} />
{ setShowPassModal(true); setIsMobileMenuOpen(false); }}
className="flex items-center gap-3 px-4 py-3 mt-6 text-blue-300 hover:bg-blue-800 hover:text-white rounded-lg transition-colors text-left">
Tukar Kata Laluan
Log Keluar
{/* 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 (
{icon}{label}
{badge > 0 && {badge} }
);
}
// ---------- 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 && (
)}
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
setIsUserModalOpen(true)}
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2.5 rounded-xl font-semibold transition-all flex items-center gap-2 shadow-sm">
Urus Pengguna (Kewangan / Pembelian)
} 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)}
handleApprove(b.id)} className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg font-medium transition-colors flex items-center gap-2">
Lulus Penuh
{ setEditingBudget(b); setEditAmount(b.amount); }} className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg font-medium transition-colors flex items-center gap-2">
Ubah & Lulus
handleReject(b.id)} className="bg-red-100 text-red-700 hover:bg-red-200 px-4 py-2 rounded-lg font-medium transition-colors flex items-center gap-2">
Tolak
))}
)}
{editingBudget && (
Ubah Kelulusan Belanjawan
setEditingBudget(null)} className="text-blue-500 hover:text-blue-700">
)}
Sejarah Rekod Belanjawan
Tarikh Keterangan Lokasi Jumlah (RM) Status Tindakan
{budgets.length === 0 ? (
Tiada rekod belanjawan dijumpai.
) : budgets.map(b => (
{b.date}
{b.description}
{b.location}
{fmt(b.amount)}
setEditingRecord(b)} className="text-blue-600 hover:bg-blue-50 p-2 rounded-lg transition-colors inline-flex items-center gap-1" title="Ubah Maklumat">
Ubah
))}
{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
setIsUserModalOpen(true)}
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2.5 rounded-xl font-semibold transition-all flex items-center gap-2 shadow-sm">
Urus Staf Pembelian
{errorFeedback && (
Sila ambil perhatian
{errorFeedback}
setErrorFeedback(null)} className="ml-auto text-red-500 hover:text-red-700">
)}
{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)}
handleApproveStaffRequest(b.id)} className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg font-medium transition-colors flex items-center gap-2">
Lulus & Ke Pengurusan
{ setEditingStaffBudget(b); setEditStaffAmount(b.amount); }} className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg font-medium transition-colors flex items-center gap-2">
Ubah & Lulus
handleRejectStaffRequest(b.id)} className="bg-red-100 text-red-700 hover:bg-red-200 px-4 py-2 rounded-lg font-medium transition-colors flex items-center gap-2">
Tolak
))}
)}
{editingStaffBudget && (
Ubah Permohonan Belanjawan (Staf)
setEditingStaffBudget(null)} className="text-blue-500 hover:text-blue-700">
)}
{selectedBudget && (
Masukkan Perbelanjaan (Kewangan)
setSelectedBudgetId(null)} className="text-gray-500 hover:text-gray-700">
{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 && (
handleReceiveBudget(b.id)} className="bg-green-600 hover:bg-green-700 text-white px-5 py-2.5 rounded-lg font-medium transition-colors flex items-center justify-center gap-2 shadow-sm">
Terima (Masuk Dana)
)}
{(b.status === BUDGET_STATUS.RECEIVED || b.status === BUDGET_STATUS.ASSIGNED) && b.balance > 0 && (
{isPurchaser ? (
{reqUser?.name}
) : (
Pilih Staf Pembelian...
{purchasers.map(p => {p.name} )}
)}
{
const selId = isPurchaser ? b.requestedBy : parseInt(document.getElementById(`assign-${b.id}`)?.value || 0);
if (selId) setTransferModal({ isOpen: true, budgetId: b.id, staffId: selId, assignAmount: b.balance, receiptFile: null, maxAmount: b.balance });
else setErrorFeedback('Sila pilih staf terlebih dahulu sebelum membuat agihan bajet!');
}} className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg font-medium transition-colors shadow-sm">
Agihan Bajet
)}
{(b.status === BUDGET_STATUS.RECEIVED || b.status === BUDGET_STATUS.ASSIGNED) && (
setSelectedBudgetId(b.id)} className="bg-purple-50 text-purple-700 border border-purple-200 hover:bg-purple-100 hover:border-purple-300 px-4 py-2 rounded-lg font-medium transition-colors shadow-sm mt-1">
Urus Perbelanjaan (Kewangan)
)}
);
})}
)}
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}
setSelectedExpenseImages(e.images)} className="text-xs bg-blue-50 text-blue-700 hover:bg-blue-100 px-3 py-1.5 rounded-lg flex items-center gap-1 font-medium transition-colors">
Lihat Resit & Barang
RM {fmt(e.amount)}
handleApproveExpense(e.id)} className="bg-green-600 hover:bg-green-700 text-white px-3 py-1.5 rounded text-sm font-medium flex items-center gap-1">
Lulus & Tolak Dana
handleRejectExpense(e.id)} className="bg-red-100 text-red-700 hover:bg-red-200 px-3 py-1.5 rounded text-sm font-medium flex items-center gap-1">
Tolak
))}
)}
Semua Rekod Belanjawan Saya
Tarikh Keterangan Lokasi Jumlah (RM) Status Tindakan
{budgets.filter(b => b.requestedBy === me.id || (b.assignments || []).some(a => a.staffId === me.id)).length === 0 ? (
Tiada rekod belanjawan dijumpai.
) : budgets.filter(b => b.requestedBy === me.id || (b.assignments || []).some(a => a.staffId === me.id)).map(b => (
{b.date}
{b.description}
{b.location}
{fmt(b.amount)}
setEditingRecord(b)} className="text-blue-600 hover:bg-blue-50 p-2 rounded-lg transition-colors inline-flex items-center gap-1" title="Ubah Maklumat">
Ubah
))}
{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)}
Jumlah Agihan (RM) *
setTransferModal({ ...transferModal, assignAmount: e.target.value })} />
Resit Pindahan Bank *
setTransferModal({ ...transferModal, receiptFile: e.target.files[0] })} icon={Receipt} />
Batal
Sahkan & Agih
)}
setSelectedExpenseImages(null)} />
{isUserModalOpen && setIsUserModalOpen(false)} />}
);
}
// BORANG PERBELANJAAN (dikongsi Kewangan & Pembelian)
function ExpenseForm({ form, setForm, onSubmit, disabled, idPrefix }) {
return (
);
}
// 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 && (
setErrorFeedback(null)} className="ml-auto text-red-500 hover:text-red-700">
)}
{selectedAssignment ? (
Masukkan Perbelanjaan
setSelectedKey(null)} className="text-gray-500 hover:text-gray-700">
{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)}
setSelectedKey({ budgetId: a.budgetId, assignmentId: a.id })}
disabled={a.budgetStatus === BUDGET_STATUS.CLOSED}
className="w-full bg-blue-50 text-blue-700 hover:bg-blue-100 disabled:opacity-50 disabled:cursor-not-allowed py-2 rounded-lg font-medium transition-colors mt-2">
{a.budgetStatus === BUDGET_STATUS.CLOSED ? 'Dana Selesai' : 'Urus Perbelanjaan'}
))}
)}
{!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
Tarikh Keterangan (Belanjawan) Lokasi Status
{budgets.filter(b => b.requestedBy === me.id || (b.assignments || []).some(a => a.staffId === me.id)).length === 0 ? (
Tiada rekod dijumpai.
) : budgets.filter(b => b.requestedBy === me.id || (b.assignments || []).some(a => a.staffId === me.id)).map(b => (
{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 && (
)}
{isEditing ? 'Ubah Pengguna Sedia Ada' : 'Daftar Pengguna Baru'}
{isEditing && (
{ setForm(empty); setIsEditing(false); setError(null); }}
className="px-4 py-2 text-sm bg-gray-200 text-gray-700 hover:bg-gray-300 rounded-lg font-medium transition-colors">
Batal Ubah
)}
{isEditing ? : }
{isEditing ? 'Simpan Perubahan' : 'Daftar Pengguna'}
Senarai Pengguna Aktif ({filteredUsers.length})
Nama Peranan Tindakan
{filteredUsers.length === 0 ? (
Tiada pengguna berdaftar dalam kategori ini.
) : filteredUsers.map(u => (
{u.name}
{u.role === ROLES.FINANCE ? 'Kewangan' : 'Pembelian'}
startEdit(u)} className="text-blue-600 hover:text-blue-800 font-medium inline-flex items-center gap-1 text-xs">
Ubah
handleDelete(u.id)} className="text-red-600 hover:text-red-800 font-medium inline-flex items-center gap-1 text-xs ml-3">
Padam
))}
Tutup
);
}
// 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
Jumlah Diminta (RM) *
setForm({ ...form, amount: e.target.value })} />
Lokasi Projek/Cawangan *
setForm({ ...form, location: e.target.value })}>
{LOCATIONS.map(l => {l} )}
Keterangan / Deskripsi *
setForm({ ...form, description: e.target.value })}>
Nota Tambahan
setForm({ ...form, notes: 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.'}
Hantar Permohonan
);
}
// 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 (
Tarikh & Masa
Pengguna
Tindakan
Rujukan
Butiran
{filtered.length === 0 ? (
Tiada rekod kelulusan setakat ini.
) : filtered.map(r => (
{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
Tapis Lokasi:
setFilterLoc(e.target.value)}>
Semua Lokasi
{LOCATIONS.map(l => {l} )}
{me?.role !== ROLES.PURCHASING && (
Tapis Staf:
setFilterStaff(e.target.value)}>
Semua Staf
{staffList.map(s => {s} )}
)}
window.print()} className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium flex items-center gap-2">
Cetak / PDF
CSV
ID Ruj Tarikh Lokasi Staf Keterangan Jumlah (RM) Status Tindakan
{allExpenses.length === 0 ? (
Tiada rekod perbelanjaan ditemui untuk tapisan ini.
) : allExpenses.map((exp, idx) => (
{String(exp.id).slice(-6)}
{exp.date}
{exp.location}
{exp.purchaserName}
{exp.description}
{fmt(exp.amount)}
{exp.status}
setSelectedExpenseImages(exp.images)} className="text-xs bg-blue-50 text-blue-700 hover:bg-blue-100 px-3 py-1.5 rounded-lg flex items-center gap-1 font-medium transition-colors whitespace-nowrap">
Resit & Barang
{isPrivileged && (
setEditingExpense(exp)} className="text-xs bg-yellow-50 text-yellow-700 hover:bg-yellow-100 px-3 py-1.5 rounded-lg flex items-center gap-1 font-medium transition-colors whitespace-nowrap">
Ubah
)}
))}
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) => (
))}
# Tarikh Lokasi Staf Keterangan Jumlah (RM) Status
{expenses.map((e, i) => (
{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
Tanda Semua Dibaca
{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 }) => (
Individu / Pemegang Belanjawan Keterangan Dimasukkan oleh Tarikh Jumlah (RM)
{list.length === 0 ? (
Tiada rekod.
) : list.map((r, i) => (
{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 (
setOpenCard(active ? null : c.key)}
className={`text-left bg-white p-5 rounded-xl shadow-sm border border-gray-100 transition-all hover:shadow-md ${active ? `ring-2 ${C[c.color].ring}` : ''}`}>
{c.title}
RM {fmt(c.value)}
{c.sub}
);
})}
{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' && (
Individu / Pemegang Diambil (RM) Dibelanja (RM) Menunggu (RM) Baki (RM)
{data.perPerson.length === 0 ? (
Tiada rekod.
) : data.perPerson.map((p, i) => (
{p.name}
{fmt(p.took)}
{fmt(p.spent)}
{fmt(p.pending)}
{fmt(p.remaining)}
))}
)}
)}
);
}
function StatCard({ title, value, icon, color }) {
return (
);
}
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 (
{file ? 'Telah Dimuatnaik' : label}
{isRequired && !file && * }
);
}
function ExpenseImageModal({ images, onClose }) {
if (!images) return null;
const cell = (img, 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
Keterangan
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" />
Lokasi
setForm({ ...form, location: e.target.value })}>
{LOCATIONS.map(l => {l} )}
Batal
onSave({ ...budget, ...form })} className="px-4 py-2 bg-yellow-600 hover:bg-yellow-700 text-white rounded-lg font-medium">Simpan Perubahan
);
}
function EditExpenseRecordModal({ expense, onClose, onSave }) {
const [form, setForm] = useState({ description: expense.description, amount: expense.amount });
return (
Ubah Rekod Perbelanjaan
Keterangan Barang/Perkhidmatan
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" />
Jumlah Perbelanjaan (RM)
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" />
{expense.status === EXPENSE_STATUS.APPROVED && (
Status rekod ini adalah "Diluluskan". Mengubah jumlah akan menjejaskan baki dana yang berkaitan.
)}
Batal
onSave({ ...expense, ...form })} className="px-4 py-2 bg-yellow-600 hover:bg-yellow-700 text-white rounded-lg font-medium">Simpan Perubahan
);
}
function ChangePasswordModal({ act, onClose }) {
const [form, setForm] = useState({ old: '', neu: '', confirm: '' });
const [error, setError] = useState(null);
const submit = async (e) => {
e.preventDefault();
if (form.neu.length < 8) { setError('Kata laluan baru mestilah sekurang-kurangnya 8 aksara.'); return; }
if (form.neu !== form.confirm) { setError('Pengesahan kata laluan tidak sepadan.'); return; }
const ok = await act('change_password', { old: form.old, new: form.neu });
if (ok) onClose();
};
return (
);
}
ReactDOM.createRoot(document.getElementById('root')).render( );