// Main app: dashboard for Ciga elt ลาว const { useState, useEffect, useMemo, useRef, useContext } = React; // ===== Aggregation helpers ===== function inRange(date, range) { return date >= range.start && date <= range.end; } function getRange(period, end) { const e = new Date(end); e.setHours(23, 59, 59, 999); const s = new Date(end); if (period === 'day') s.setDate(s.getDate() - 0); else if (period === 'week') s.setDate(s.getDate() - 6); else if (period === 'month') s.setDate(s.getDate() - 29); else if (period === 'quarter') s.setDate(s.getDate() - 89); else if (period === 'year') s.setDate(s.getDate() - 364); s.setHours(0, 0, 0, 0); return { start: s, end: e }; } function summarize(txs, range, prevRange) { let revenue = 0, cogs = 0, opex = 0, restockCost = 0; let unitsSold = { 'pilot-m': 0, 'pilot-g': 0 }; let revenueByProd = { 'pilot-m': 0, 'pilot-g': 0 }; let txInRange = []; for (const t of txs) { if (!inRange(t.date, range)) continue; txInRange.push(t); if (t.type === 'out' && t.kind === 'sale') { revenue += t.amount; unitsSold[t.productId] += t.qty; revenueByProd[t.productId] += t.amount; const p = window.CIGA.PRODUCTS.find(x => x.id === t.productId); cogs += t.qty * p.cost; } else if (t.type === 'in' && t.kind === 'restock') { restockCost += t.amount; } else if (t.type === 'expense') { opex += t.amount; } } const grossProfit = revenue - cogs; const netProfit = grossProfit - opex; let prev = null; if (prevRange) prev = summarize(txs, prevRange, null); return { revenue, cogs, opex, restockCost, grossProfit, netProfit, unitsSold, revenueByProd, txInRange, prev }; } function dailyBuckets(txs, range, fn = (t) => t.amount) { const days = []; const start = new Date(range.start); start.setHours(0, 0, 0, 0); const end = new Date(range.end); end.setHours(0, 0, 0, 0); const cur = new Date(start); while (cur <= end) { days.push({ date: new Date(cur), value: 0, label: String(cur.getDate()).padStart(2, '0') + '/' + String(cur.getMonth() + 1).padStart(2, '0') }); cur.setDate(cur.getDate() + 1); } for (const t of txs) { if (!inRange(t.date, range)) continue; const d = new Date(t.date); d.setHours(0, 0, 0, 0); const idx = Math.floor((d - start) / 86400000); if (idx >= 0 && idx < days.length) days[idx].value += fn(t) || 0; } return days; } function computeStockState(txs) { // running stock + average cost const state = {}; for (const p of window.CIGA.PRODUCTS) state[p.id] = { qty: 0, totalCost: 0, totalIn: 0, totalOut: 0 }; // process oldest first const sorted = [...txs].sort((a, b) => a.date - b.date); for (const t of sorted) { if (!t.productId) continue; const s = state[t.productId]; if (t.type === 'in') { s.qty += t.qty; s.totalCost += t.amount; s.totalIn += t.qty; } else if (t.type === 'out') { s.qty -= t.qty; s.totalOut += t.qty; } } return state; } // ===== Top bar ===== function TopBar({ period, setPeriod, onOpenAdd, onOpenSettings }) { const periods = [ { id: 'day', label: 'วันนี้' }, { id: 'week', label: '7 วัน' }, { id: 'month', label: '30 วัน' }, { id: 'quarter', label: '90 วัน' }, { id: 'year', label: '1 ปี' }, ]; return (
CIGA · ELT
ระบบสรุปบัญชี ลาว
{periods.map(p => ( setPeriod(p.id)}>{p.label} ))}
SP
); } // ===== Sidebar ===== function Sidebar({ active, setActive }) { const items = [ { id: 'overview', label: 'ภาพรวม', icon: 'dashboard' }, { id: 'invest', label: 'เงินลงทุน', icon: 'invest' }, { id: 'pl', label: 'รายรับ-จ่าย', icon: 'wallet' }, { id: 'shareholders', label: 'แบ่งหุ้น', icon: 'users' }, { id: 'stock', label: 'สต็อก', icon: 'box' }, { id: 'flow', label: 'รับเข้า-จ่ายออก', icon: 'arrows' }, { id: 'report', label: 'รายงาน', icon: 'chart' }, ]; return ( ); } // ===== KPI Card ===== function KPI({ label, value, prev, delta, deltaLabel, sparkPoints, color, accent, suffix, prefix, format, big }) { const up = delta >= 0; const fmt = format || (v => Math.round(v).toLocaleString('en-US')); return (
{label}
{delta !== undefined && delta !== null && ( {Math.abs(delta).toFixed(1)}% )}
{prefix} {suffix && {suffix}}
{deltaLabel}
{sparkPoints && }
); } // ===== Main app ===== function App() { const [period, setPeriod] = useState('month'); const [active, setActive] = useState('overview'); const [addOpen, setAddOpen] = useState(false); const [detailTx, setDetailTx] = useState(null); const [txs, setTxs] = useState(() => window.CIGA.transactions); const [invs, setInvs] = useState(() => window.CIGA.investments); const [investModal, setInvestModal] = useState(null); // null | { mode: 'add' } | { mode: 'edit', inv } const [currency, setCurrencyState] = useState(() => { try { return localStorage.getItem('ciga-ccy') || 'LAK'; } catch (e) { return 'LAK'; } }); const setCurrency = (c) => { setCurrencyState(c); try { localStorage.setItem('ciga-ccy', c); } catch (e) {} }; const [rate, setRateState] = useState(() => loadRate()); const setRate = (r) => { const v = Number(r); if (!Number.isFinite(v) || v <= 0) return; setRateState(v); try { localStorage.setItem('ciga-rate', String(v)); } catch (e) {} }; const [settingsOpen, setSettingsOpen] = useState(false); // ---- Shareholders & profit split ---- const [mgmtFeePct, setMgmtFeePctState] = useState(() => { try { const v = parseFloat(localStorage.getItem('ciga-mgmt')); return Number.isFinite(v) ? v : 40; } catch (e) { return 40; } }); const setMgmtFeePct = (v) => { const n = Math.max(0, Math.min(100, Number(v) || 0)); setMgmtFeePctState(n); try { localStorage.setItem('ciga-mgmt', String(n)); } catch (e) {} }; const DEFAULT_SHAREHOLDERS = [ { id: 'sh-1', name: 'Winner', pct: 20 }, { id: 'sh-2', name: 'Arty', pct: 20 }, { id: 'sh-3', name: 'เสี่ยแบงค์', pct: 20 }, { id: 'sh-4', name: 'พี่ป๊อป', pct: 20 }, { id: 'sh-5', name: 'พี่แคน', pct: 10 }, { id: 'sh-6', name: 'เซอร์ใบ', pct: 10 }, ]; const [shareholders, setShareholdersState] = useState(() => { try { const j = localStorage.getItem('ciga-sh'); if (j) return JSON.parse(j); } catch (e) {} return DEFAULT_SHAREHOLDERS; }); const setShareholders = (arr) => { setShareholdersState(arr); try { localStorage.setItem('ciga-sh', JSON.stringify(arr)); } catch (e) {} }; const [shModalOpen, setShModalOpen] = useState(false); const ccySym = moneySymbol(currency); const M = (v, opts) => fmtMoney(v, currency, opts, rate); const Mk = (v) => fmtMoney(v, currency, { compact: true }, rate); // ---- Export helpers ---- function downloadFile(filename, content, mime = 'text/plain') { const blob = new Blob([content], { type: mime + ';charset=utf-8' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); setTimeout(() => URL.revokeObjectURL(url), 1000); } function exportJSON() { const data = { meta: { exported_at: new Date().toISOString(), schema_version: '1.0', business: 'Ciga ELT Lao', }, settings: { exchange_rate_thb_lak: rate, display_currency: currency, mgmt_fee_pct: mgmtFeePct, }, products: window.CIGA.PRODUCTS, investments: invs.map(i => ({ ...i, date: i.date.toISOString() })), shareholders: shareholders.map((s, i) => ({ ...s, display_order: i + 1 })), transactions: txs.map(t => ({ ...t, date: t.date.toISOString() })), }; const ts = new Date().toISOString().slice(0, 10); downloadFile(`ciga-elt-export-${ts}.json`, JSON.stringify(data, null, 2), 'application/json'); } function exportSQL() { const esc = (s) => s == null ? 'NULL' : `'${String(s).replace(/'/g, "''")}'`; const num = (n) => n == null ? 'NULL' : String(n); const dt = (d) => `'${new Date(d).toISOString().slice(0, 19).replace('T', ' ')}'`; const date = (d) => `'${new Date(d).toISOString().slice(0, 10)}'`; let sql = ''; sql += '-- ============================================================\n'; sql += '-- Ciga · ELT Lao — Data Export\n'; sql += `-- Generated: ${new Date().toISOString()}\n`; sql += '-- Run schema.sql first, then this file.\n'; sql += '-- ============================================================\n\n'; sql += '-- ลบข้อมูลเก่า (ถ้ามี) ก่อน import ใหม่\n'; sql += 'DELETE FROM transactions;\nDELETE FROM investments;\nDELETE FROM shareholders;\nDELETE FROM products;\nDELETE FROM settings;\n\n'; sql += '-- Products\n'; for (const p of window.CIGA.PRODUCTS) { sql += `INSERT INTO products (id, name, short_code, color, hue, cost, price) VALUES (${esc(p.id)}, ${esc(p.name)}, ${esc(p.short)}, ${esc(p.color)}, ${num(p.hue)}, ${num(p.cost)}, ${num(p.price)});\n`; } sql += '\n-- Shareholders\n'; shareholders.forEach((s, i) => { sql += `INSERT INTO shareholders (id, name, pct, display_order) VALUES (${esc(s.id)}, ${esc(s.name)}, ${num(s.pct)}, ${i + 1});\n`; }); sql += '\n-- Investments\n'; for (const inv of invs) { sql += `INSERT INTO investments (id, invest_date, amount, source, note) VALUES (${esc(inv.id)}, ${date(inv.date)}, ${num(inv.amount)}, ${esc(inv.source)}, ${esc(inv.note)});\n`; } sql += '\n-- Transactions\n'; for (const t of txs) { sql += `INSERT INTO transactions (id, tx_date, tx_type, tx_kind, product_id, qty, unit_price, amount, channel, note) VALUES (${esc(t.id)}, ${dt(t.date)}, ${esc(t.type)}, ${esc(t.kind)}, ${esc(t.productId)}, ${num(t.qty)}, ${num(t.unitPrice)}, ${num(t.amount)}, ${esc(t.channel)}, ${esc(t.note)});\n`; } sql += '\n-- Settings\n'; const settings = [ ['exchange_rate_thb_lak', String(rate)], ['display_currency', currency], ['mgmt_fee_pct', String(mgmtFeePct)], ['business_name', 'Ciga ELT Lao'], ['schema_version', '1.0'], ]; for (const [k, v] of settings) { sql += `INSERT INTO settings (setting_key, setting_value) VALUES (${esc(k)}, ${esc(v)});\n`; } sql += '\n-- จบไฟล์\n'; const ts = new Date().toISOString().slice(0, 10); downloadFile(`ciga-elt-data-${ts}.sql`, sql, 'application/sql'); } function importJSON(file) { const reader = new FileReader(); reader.onload = (e) => { try { const data = JSON.parse(e.target.result); if (data.transactions) setTxs(data.transactions.map(t => ({ ...t, date: new Date(t.date) }))); if (data.investments) setInvs(data.investments.map(i => ({ ...i, date: new Date(i.date) }))); if (data.shareholders) setShareholders(data.shareholders); if (data.settings) { if (data.settings.exchange_rate_thb_lak) setRate(data.settings.exchange_rate_thb_lak); if (data.settings.display_currency) setCurrency(data.settings.display_currency); if (data.settings.mgmt_fee_pct != null) setMgmtFeePct(data.settings.mgmt_fee_pct); } alert('นำเข้าข้อมูลสำเร็จ ✓'); } catch (err) { alert('ไฟล์ JSON ไม่ถูกต้อง: ' + err.message); } }; reader.readAsText(file); } function clearAllData() { const ok = confirm( '⚠ ล้างข้อมูลทั้งหมด?\n\n' + '— ธุรกรรมทั้งหมด ' + txs.length + ' รายการ\n' + '— เงินลงทุน ' + invs.length + ' รายการ\n' + '— ผู้ถือหุ้น ' + shareholders.length + ' คน\n\n' + 'การกระทำนี้ไม่สามารถย้อนกลับได้\n(แนะนำให้ Export SQL/JSON ไว้ก่อน)' ); if (!ok) return; const ok2 = confirm('ยืนยันอีกครั้ง — ล้างข้อมูลทั้งหมด?'); if (!ok2) return; setTxs([]); setInvs([]); setShareholders([]); setMgmtFeePct(40); try { ['ciga-sh', 'ciga-mgmt'].forEach(k => localStorage.removeItem(k)); } catch (e) {} setSettingsOpen(false); setTimeout(() => alert('ล้างข้อมูลเรียบร้อย ✓ พร้อมเริ่มต้นใหม่'), 100); } const range = useMemo(() => getRange(period, window.CIGA.END), [period]); const prevRange = useMemo(() => { const span = range.end - range.start; return { start: new Date(range.start.getTime() - span - 1), end: new Date(range.start.getTime() - 1) }; }, [range]); const summary = useMemo(() => summarize(txs, range, prevRange), [txs, range, prevRange]); const stockState = useMemo(() => computeStockState(txs), [txs]); const totalInvested = useMemo(() => invs.reduce((s, i) => s + i.amount, 0), [invs]); // Daily series for charts const revSeries = useMemo(() => dailyBuckets(txs, range, t => (t.type === 'out' && t.kind === 'sale') ? t.amount : 0), [txs, range]); const expSeries = useMemo(() => dailyBuckets(txs, range, t => { if (t.type === 'expense') return t.amount; if (t.type === 'in' && t.kind === 'restock') return t.amount; return 0; }), [txs, range]); const stockMoveBars = useMemo(() => { const days = []; const start = new Date(range.start); start.setHours(0,0,0,0); const end = new Date(range.end); end.setHours(0,0,0,0); const cur = new Date(start); while (cur <= end) { days.push({ date: new Date(cur), valueIn: 0, valueOut: 0, label: String(cur.getDate()).padStart(2,'0')+'/'+String(cur.getMonth()+1).padStart(2,'0') }); cur.setDate(cur.getDate() + 1); } for (const t of txs) { if (!t.productId || !inRange(t.date, range)) continue; const d = new Date(t.date); d.setHours(0,0,0,0); const idx = Math.floor((d - start) / 86400000); if (idx < 0 || idx >= days.length) continue; if (t.type === 'in') days[idx].valueIn += t.qty; else if (t.type === 'out') days[idx].valueOut += t.qty; } return days; }, [txs, range]); // Sparkline points for KPI (last N days) function sparkFrom(series) { return series.map(d => d.value); } // Deltas vs previous period function pctDelta(cur, prev) { if (!prev) return 0; if (prev === 0) return cur > 0 ? 100 : 0; return ((cur - prev) / prev) * 100; } // ---- Cash position ---- const cashBalance = useMemo(() => { let cash = 0; for (const i of invs) cash += i.amount; for (const t of txs) { if (t.type === 'out' && t.kind === 'sale') cash += t.amount; if (t.type === 'in' && t.kind === 'restock') cash -= t.amount; if (t.type === 'expense') cash -= t.amount; } return cash; }, [txs, invs]); // ---- Ticker items ---- const tickerItems = [ { label: 'PILOT·MV', value: M(350000), delta: 2.4 }, { label: 'PILOT·GR', value: M(380000), delta: 1.1 }, { label: 'NET·30D', value: Mk(summary.netProfit), delta: pctDelta(summary.netProfit, summary.prev?.netProfit) }, { label: 'REV·30D', value: Mk(summary.revenue), delta: pctDelta(summary.revenue, summary.prev?.revenue) }, { label: 'CASH', value: Mk(cashBalance), delta: 8.2 }, { label: 'UNITS·MV', value: String(stockState['pilot-m']?.qty || 0), delta: -3.2 }, { label: 'UNITS·GR', value: String(stockState['pilot-g']?.qty || 0), delta: 1.8 }, { label: 'LAK/USD', value: '21,580', delta: -0.12 }, { label: 'LAK/THB', value: '615.4', delta: 0.04 }, { label: 'CCY', value: currency, delta: 0 }, ]; // ---- Actions ---- function addTransaction(tx) { setTxs(prev => [tx, ...prev]); } function deleteTransaction(id) { setTxs(prev => prev.filter(t => t.id !== id)); } function saveInvestment(inv) { setInvs(prev => { const idx = prev.findIndex(x => x.id === inv.id); if (idx >= 0) { const next = [...prev]; next[idx] = inv; return next; } return [...prev, inv].sort((a, b) => a.date - b.date); }); } function deleteInvestment(id) { setInvs(prev => prev.filter(i => i.id !== id)); } return (
setAddOpen(true)} onOpenSettings={() => setSettingsOpen(true)} />
{/* HERO KPI */}
= 0 ? '#22d3a3' : '#ff5a78'} delta={pctDelta(summary.netProfit, summary.prev?.netProfit)} deltaLabel={`Margin ${summary.revenue ? ((summary.netProfit / summary.revenue) * 100).toFixed(1) : '0'}%`} sparkPoints={revSeries.map((d, i) => d.value - (expSeries[i]?.value || 0))} /> totalInvested + revSeries.slice(0, i+1).reduce((s, x) => s + x.value, 0) - expSeries.slice(0, i+1).reduce((s, x) => s + x.value, 0))} />
{/* ROW 2: Revenue/Expense chart + Product mix */}
กระแสรายรับ-รายจ่าย
รายวัน · {fmtDate(range.start, 'long')} – {fmtDate(range.end, 'long')}
รายรับ รายจ่าย กำไรสะสม
i % Math.ceil(revSeries.length / 7) === 0 ? d.label : '')} series={[ { name: 'รายรับ', color: '#22d3a3', points: revSeries.map(d => d.value) }, { name: 'รายจ่าย', color: '#ff5a78', points: expSeries.map(d => d.value) }, { name: 'กำไรสะสม', color: '#ffb547', points: revSeries.map((d, i) => Math.max(0, revSeries.slice(0, i+1).reduce((s, x) => s + x.value, 0) - expSeries.slice(0, i+1).reduce((s, x) => s + x.value, 0))) }, ]} yFormat={v => Mk(v)} />
สัดส่วนสินค้า
รายรับจาก Pilot ม่วง vs เขียว
{window.CIGA.PRODUCTS.map(p => { const v = summary.revenueByProd[p.id]; const pct = summary.revenue ? (v / summary.revenue * 100) : 0; const units = summary.unitsSold[p.id]; return (
{p.name} {pct.toFixed(1)}%
{Mk(v)} · {units} ชิ้น
); })}
{/* ROW 3: Investment + Cashflow */}
เงินลงทุน
ประวัติการเติมทุน · รวม {Mk(totalInvested)}
{invs.map((inv, i) => (
{i < invs.length - 1 && }
{inv.id} {fmtDate(inv.date, 'long')}
{inv.note}
{inv.source} {M(inv.amount, { sign: true })}
))}
ผลตอบแทนจากการลงทุน (ROI)
{((summary.revenue - summary.cogs - summary.opex) / totalInvested * 100).toFixed(2)}%
เงินสด/ทุน
{(cashBalance / totalInvested * 100).toFixed(1)}%
สต็อกมูลค่า
{Mk( Object.entries(stockState).reduce((s, [id, st]) => { const p = window.CIGA.PRODUCTS.find(x => x.id === id); return s + st.qty * p.cost; }, 0))}
งบกำไรขาดทุน
P&L สรุปงวดที่เลือก
รายรับจากการขาย
{M(summary.revenue)}
ต้นทุนขาย (COGS)
{M(-summary.cogs)}
กำไรขั้นต้น
{M(summary.grossProfit)}
ค่าใช้จ่ายดำเนินงาน
{M(-summary.opex)}
กำไรสุทธิ
= 0 ? '#22d3a3' : '#ff5a78' }}> {M(summary.netProfit)}
= 0 ? '#22d3a3' : '#ff5a78' }}>
Gross Margin
{summary.revenue ? (summary.grossProfit / summary.revenue * 100).toFixed(1) : '0'}%
Net Margin
= 0 ? '#22d3a3' : '#ff5a78' }}> {summary.revenue ? (summary.netProfit / summary.revenue * 100).toFixed(1) : '0'}%
COGS Ratio
{summary.revenue ? (summary.cogs / summary.revenue * 100).toFixed(1) : '0'}%
{/* ROW 3.5: Shareholders / Profit split */}
สรุปแบ่งหุ้นส่วน
หักค่าบริหาร {mgmtFeePct}% ก่อนแบ่งตามสัดส่วนผู้ถือหุ้น · {shareholders.length} คน
{(() => { const totalPool = Math.max(0, summary.netProfit); const mgmtFee = totalPool * (mgmtFeePct / 100); const remainPct = 100 - mgmtFeePct; const remaining = totalPool - mgmtFee; const totalSharePct = shareholders.reduce((s, x) => s + Number(x.pct || 0), 0); const validSum = Math.abs(totalSharePct - 100) < 0.01; return (
ยอดคงเหลือทั้งหมด
{M(totalPool)}
ค่าบริหาร · {mgmtFeePct}%
{M(mgmtFee)}
คงเหลือแบ่งหุ้น · {remainPct}%
{M(remaining)}
{shareholders.map((sh, i) => { const share = remaining * (Number(sh.pct) / 100); const ofTotal = remainPct * (Number(sh.pct) / 100); const palette = ['#a78bfa', '#4ade80', '#60a5fa', '#ffb547', '#22d3a3', '#f472b6', '#fb923c', '#34d399']; const col = palette[i % palette.length]; return ( ); })}
ผู้ถือหุ้น หุ้น (%) สัดส่วนของยอดเต็ม ยอดที่ได้รับ
บริหาร (Management Fee) {mgmtFeePct}% {mgmtFeePct}% {M(mgmtFee)}
{(sh.name || '?').trim()[0]} {sh.name} {Number(sh.pct).toFixed(1)}% {ofTotal.toFixed(1)}% {M(share)}
รวมหุ้นส่วน {totalSharePct.toFixed(1)}% {!validSum && } {remainPct}% {M(remaining * totalSharePct / 100)}
{!validSum && (
สัดส่วนการถือหุ้นรวมต้องเท่ากับ 100% (ตอนนี้ {totalSharePct.toFixed(1)}%) · กด "จัดการผู้ถือหุ้น" เพื่อแก้ไข
)} {totalPool <= 0 && (
ยังไม่มีกำไรในงวดนี้ — เปลี่ยนช่วงเวลาให้ครอบคลุมการขาย เพื่อแบ่งหุ้น
)}
); })()}
{/* ROW 4: Stock + Movements */}
{window.CIGA.PRODUCTS.map(p => { const st = stockState[p.id] || { qty: 0, totalIn: 0, totalOut: 0 }; const cap = 400; const lowThreshold = 60; const low = st.qty < lowThreshold; const stockValue = st.qty * p.cost; const turnover = st.totalIn > 0 ? (st.totalOut / st.totalIn * 100) : 0; return (
{p.name}
SKU · {p.short}
{low && สต็อกต่ำ}
Math.round(v).toString()} />
ชิ้น
ต้นทุน/ชิ้น{M(p.cost)}
ราคาขาย{M(p.price)}
กำไร/ชิ้น{M(p.price - p.cost, { sign: true })}
มูลค่าสต็อก{Mk(stockValue)}
รับเข้ารวม
{st.totalIn}
ขายออกรวม
{st.totalOut}
Turnover
{turnover.toFixed(0)}%
); })}
การเคลื่อนไหวสต็อก
รับเข้า vs จ่ายออก (ชิ้น/วัน)
รับเข้า จ่ายออก
{/* ROW 5: Recent movements + transactions */}
รับเข้า-จ่ายออก ล่าสุด
การเคลื่อนไหวของสินค้าในงวด
t.productId).slice(0, 12)} columns={[ { key: 'date', label: 'วันที่', render: t => {fmtDate(t.date)} · {fmtTime(t.date)} }, { key: 'id', label: 'อ้างอิง', render: t => {t.id} }, { key: 'type', label: 'ทิศทาง', render: t => {t.type === 'in' ? '▼ รับเข้า' : '▲ จ่ายออก'} }, { key: 'product', label: 'สินค้า', render: t => { const p = window.CIGA.PRODUCTS.find(x => x.id === t.productId); return {p.name}; }}, { key: 'qty', label: 'จำนวน', align: 'right', render: t => {t.type === 'in' ? '+' : '−'}{t.qty} }, { key: 'amount', label: 'มูลค่า', align: 'right', render: t => {M(t.amount)} }, ]} onClickRow={setDetailTx} />
รายการธุรกรรมล่าสุด
รายรับและรายจ่ายทั้งหมด
{fmtDate(t.date)} }, { key: 'kind', label: 'ประเภท', render: t => { const map = { sale: ['ขาย','#22d3a3'], restock: ['รับสินค้า','#60a5fa'], opex: ['ค่าใช้จ่าย','#ff5a78'], rent: ['ค่าเช่า','#ff5a78'] }; const [lab, col] = map[t.kind] || ['อื่นๆ','#888']; return {lab}; }}, { key: 'note', label: 'รายละเอียด', render: t => {t.note}
{t.channel}
}, { key: 'amount', label: 'จำนวนเงิน', align: 'right', render: t => { const positive = t.type === 'out' && t.kind === 'sale'; return {positive ? '+' : '−'}{M(t.amount)} ; }}, { key: 'actions', label: '', render: t => ( )}, ]} onClickRow={setDetailTx} />
CIGA · ELT — ระบบสรุปบัญชี ลาว · v1.0 อัปเดตล่าสุด {fmtDate(window.CIGA.END, 'long')} · {summary.txInRange.length} รายการในงวด
{addOpen && setAddOpen(false)} onSubmit={tx => { addTransaction(tx); setAddOpen(false); }} />} {detailTx && setDetailTx(null)} onDelete={(id) => { deleteTransaction(id); setDetailTx(null); }} />} {investModal && setInvestModal(null)} onSubmit={(inv) => { saveInvestment(inv); setInvestModal(null); }} onDelete={(id) => { deleteInvestment(id); setInvestModal(null); }} />} {settingsOpen && setSettingsOpen(false)} onExportJSON={exportJSON} onExportSQL={exportSQL} onImportJSON={importJSON} onClearAll={clearAllData} stats={{ txCount: txs.length, invCount: invs.length, shCount: shareholders.length }} />} {shModalOpen && setShModalOpen(false)} />} ); } // ===== Table ===== function Table({ rows, columns, onClickRow }) { if (!rows.length) { return
ไม่มีข้อมูลในงวดนี้
; } return (
{columns.map(c => )} {rows.map((r, ri) => ( onClickRow && onClickRow(r)} style={{ cursor: onClickRow ? 'pointer' : 'default' }}> {columns.map(c => )} ))}
{c.label}
{c.render(r)}
); } // ===== Add transaction modal ===== function AddTxModal({ onClose, onSubmit }) { const { currency, rate: userRate } = useCurrency(); const sym = moneySymbol(currency); const convRate = currency === 'THB' ? 1 / userRate : 1; const M = (v, opts) => fmtMoney(v, currency, opts, userRate); // Input values are entered in the selected currency, stored in LAK. const [kind, setKind] = useState('sale'); // sale | restock | opex const [productId, setProductId] = useState('pilot-m'); const [qty, setQty] = useState(''); const [unitPrice, setUnitPrice] = useState(''); const [amount, setAmount] = useState(''); const [note, setNote] = useState(''); const [channel, setChannel] = useState(''); useEffect(() => { if (kind === 'sale') { const p = window.CIGA.PRODUCTS.find(x => x.id === productId); setUnitPrice(String(Math.round(p.price * convRate))); setChannel('ขายปลีก'); } else if (kind === 'restock') { const p = window.CIGA.PRODUCTS.find(x => x.id === productId); setUnitPrice(String(Math.round(p.cost * convRate))); setChannel('รับจากซัพพลายเออร์'); } else { setUnitPrice(''); setChannel('ค่าใช้จ่ายดำเนินงาน'); } }, [kind, productId, currency, convRate]); const totalDisplay = kind === 'opex' ? Number(amount) || 0 : (Number(qty) || 0) * (Number(unitPrice) || 0); const total = totalDisplay / convRate; // convert back to LAK for storage function submit() { const tx = { id: 'TX' + Date.now().toString(36).toUpperCase().slice(-5), date: new Date(), type: kind === 'sale' ? 'out' : kind === 'restock' ? 'in' : 'expense', kind, productId: kind === 'opex' ? null : productId, qty: kind === 'opex' ? null : Number(qty), unitPrice: kind === 'opex' ? null : Number(unitPrice), amount: total, channel: channel || 'อื่นๆ', note: note || '—', }; onSubmit(tx); } return (
e.stopPropagation()}>
เพิ่มรายการใหม่
บันทึกธุรกรรมเข้าสู่ระบบบัญชี
{[ { id: 'sale', label: 'ขายออก', color: '#22d3a3' }, { id: 'restock', label: 'รับเข้า', color: '#60a5fa' }, { id: 'opex', label: 'ค่าใช้จ่าย', color: '#ff5a78' }, ].map(o => ( ))}
{kind !== 'opex' && (
{window.CIGA.PRODUCTS.map(p => ( ))}
)}
{kind !== 'opex' && ( <>
setQty(e.target.value)} placeholder="0" />
setUnitPrice(e.target.value)} />
)} {kind === 'opex' && (
setAmount(e.target.value)} placeholder="0" />
)}
setChannel(e.target.value)} />
setNote(e.target.value)} placeholder="เช่น ลูกค้าประจำ คุณเอ" />
รวมเป็นเงิน {M(total)}
); } // ===== Detail modal ===== function DetailModal({ tx, onClose, onDelete }) { const { currency, rate } = useCurrency(); const M = (v, opts) => fmtMoney(v, currency, opts, rate); const p = tx.productId ? window.CIGA.PRODUCTS.find(x => x.id === tx.productId) : null; return (
e.stopPropagation()}>
{tx.id}
{fmtDate(tx.date, 'long')} · {fmtTime(tx.date)}
ประเภท {tx.kind === 'sale' ? 'ขายออก' : tx.kind === 'restock' ? 'รับเข้า' : tx.kind === 'opex' ? 'ค่าใช้จ่าย' : 'ค่าเช่า'}
{p &&
สินค้า{p.name}
} {tx.qty != null &&
จำนวน{tx.qty} ชิ้น
} {tx.unitPrice != null &&
ราคา/ชิ้น{M(tx.unitPrice)}
}
ช่องทาง{tx.channel}
หมายเหตุ{tx.note}
มูลค่ารวม{M(tx.amount)}
); } // ===== Investment add/edit modal ===== function InvestmentModal({ mode, inv, onClose, onSubmit, onDelete }) { const { currency, rate: userRate } = useCurrency(); const sym = moneySymbol(currency); const convRate = currency === 'THB' ? 1 / userRate : 1; const M = (v, opts) => fmtMoney(v, currency, opts, userRate); const isEdit = mode === 'edit'; const toLocalISO = (d) => { const z = new Date(d); z.setMinutes(z.getMinutes() - z.getTimezoneOffset()); return z.toISOString().slice(0, 10); }; const [amount, setAmount] = useState(() => isEdit ? String(Math.round(inv.amount * convRate)) : ''); const [source, setSource] = useState(() => isEdit ? inv.source : 'ผู้ก่อตั้ง'); const [note, setNote] = useState(() => isEdit ? inv.note : ''); const [date, setDate] = useState(() => isEdit ? toLocalISO(inv.date) : toLocalISO(new Date())); const lakAmount = (Number(amount) || 0) / convRate; const sources = ['ผู้ก่อตั้ง', 'พาร์ทเนอร์', 'นักลงทุน', 'เงินกู้', 'อื่นๆ']; function submit() { const next = { id: isEdit ? inv.id : 'INV' + String(Date.now()).slice(-6), date: new Date(date + 'T12:00:00'), amount: lakAmount, source, note: note || (isEdit ? inv.note : 'เงินลงทุนเพิ่มเติม'), }; onSubmit(next); } return (
e.stopPropagation()}>
{isEdit ? 'แก้ไขเงินลงทุน' : 'บันทึกเงินลงทุนใหม่'}
{isEdit ? inv.id : 'เพิ่มทุนเข้าระบบบัญชี'}
setDate(e.target.value)} />
setAmount(e.target.value)} placeholder="0" autoFocus={!isEdit} />
{sources.map(s => ( ))}
setNote(e.target.value)} placeholder="เช่น เพิ่มทุนขยายสต็อก" />
บันทึกเป็นเงิน LAK {fmtMoney(lakAmount, 'LAK')}
{currency !== 'LAK' && (
≈ {M(lakAmount)} (สกุลที่แสดง)
)}
{isEdit ? ( ) : }
); } // ===== Settings modal (currency rate, etc.) ===== function SettingsModal({ onClose, onExportJSON, onExportSQL, onImportJSON, onClearAll, stats }) { const { currency, rate, setRate } = useCurrency(); const [val, setVal] = useState(String(rate)); const n = parseFloat(val); const valid = Number.isFinite(n) && n > 0; function save() { if (!valid) return; setRate(n); onClose(); } // Quick-set presets const presets = [580, 600, 615, 630, 650]; return (
e.stopPropagation()}>
ตั้งค่า
อัตราแลกเปลี่ยนและตัวเลือกระบบ
อัตราแลกเปลี่ยน
฿1 = setVal(e.target.value)} style={{ flex: 1, fontSize: 16, fontWeight: 600 }} step="0.1" autoFocus /> ₭{val}
ค่าใช้บ่อย: {presets.map(p => ( ))}
{valid && (
ตัวอย่าง
฿1,000 ≈ ₭{(1000 * n).toLocaleString('en-US')}
฿10,000 ≈ ₭{(10000 * n).toLocaleString('en-US')}
₭1,000,000 ≈ ฿{Math.round(1000000 / n).toLocaleString('en-US')}
)}
สกุลเงินที่แสดง
กำลังแสดงผลใน {currency === 'LAK' ? 'กีบ (LAK)' : 'บาท (THB)'} · ข้อมูลทั้งหมดจัดเก็บภายในเป็นกีบเสมอ
บันทึกข้อมูล / อัพขึ้นฐานข้อมูล
{stats && (
ธุรกรรม{stats.txCount.toLocaleString('en-US')}
เงินลงทุน{stats.invCount}
ผู้ถือหุ้น{stats.shCount}
)}
วิธีอัพขึ้น database
  1. รัน database/schema.sql ใน phpMyAdmin / Supabase เพื่อสร้างตาราง
  2. กด Export SQL เพื่อดาวน์โหลดข้อมูลปัจจุบัน
  3. อัพโหลดไฟล์ขึ้น DB — เสร็จ
ล้างข้อมูลทั้งหมด
ล้างธุรกรรม เงินลงทุน และผู้ถือหุ้นทั้งหมด — เพื่อเริ่มต้นจากศูนย์
ไม่สามารถย้อนกลับได้ — แนะนำให้ Export SQL/JSON ไว้ก่อน
); } // ===== Shareholder management modal ===== function ShareholderModal({ shareholders, mgmtFeePct, setShareholders, setMgmtFeePct, onClose }) { const [fee, setFee] = useState(String(mgmtFeePct)); const [list, setList] = useState(() => shareholders.map(x => ({ ...x }))); const totalPct = list.reduce((s, x) => s + (Number(x.pct) || 0), 0); const valid = Math.abs(totalPct - 100) < 0.01; function update(idx, key, value) { setList(prev => prev.map((x, i) => i === idx ? { ...x, [key]: value } : x)); } function add() { const nextId = 'sh-' + (Date.now().toString(36).slice(-5)); setList(prev => [...prev, { id: nextId, name: '', pct: 0 }]); } function remove(idx) { setList(prev => prev.filter((_, i) => i !== idx)); } function distributeEvenly() { if (list.length === 0) return; const each = +(100 / list.length).toFixed(2); setList(prev => prev.map((x, i) => ({ ...x, pct: i === prev.length - 1 ? +(100 - each * (prev.length - 1)).toFixed(2) : each }))); } function save() { if (!valid) return; const f = Math.max(0, Math.min(100, parseFloat(fee) || 0)); setMgmtFeePct(f); setShareholders(list.filter(x => (x.name || '').trim() !== '')); onClose(); } return (
e.stopPropagation()}>
จัดการผู้ถือหุ้น
ปรับสัดส่วนหุ้น (%) และค่าบริหาร · รวมต้อง = 100%
setFee(e.target.value)} style={{ flex: 1 }} />
setFee(e.target.value)} style={{ width: 60, textAlign: 'right' }} min="0" max="100" /> %
หักก่อนแบ่งให้ผู้ถือหุ้น · เหลือ {(100 - (parseFloat(fee) || 0)).toFixed(0)}% สำหรับแบ่งหุ้น
ผู้ถือหุ้น รวม {totalPct.toFixed(1)}% / 100% {!valid && ' ⚠'}
{list.map((sh, i) => (
update(i, 'name', e.target.value)} style={{ flex: 1 }} />
update(i, 'pct', parseFloat(e.target.value) || 0)} style={{ width: 70, textAlign: 'right' }} /> %
))}
); } // Mount const root = ReactDOM.createRoot(document.getElementById('root')); root.render();