// Finance — Gelir & Gider (Gelişmiş)
const FNIcons = window.Icons;
const INCOME_CATS = ['Firma Ödemesi', 'Avans', 'Kira Geliri', 'Ek Gelir', 'İade', 'Diğer'];
const EXPENSE_CATS = ['Yazılım & Araç', 'Reklam', 'Barındırma', 'Seyahat', 'Malzeme', 'Personel', 'Abonelik', 'Vergi', 'Diğer'];
// Sabit + kullanıcı tanımlı kategoriler
const fnGetCats = (state, type) => {
const base = type === 'income' ? INCOME_CATS : EXPENSE_CATS;
const custom = state?.customCategories?.[type] || [];
return [...base, ...custom.filter(c => !base.includes(c))];
};
// KDV tutarı — tutar KDV dahil kabul edilir
const fnVatAmount = (tx) => {
if (!tx.vat || tx.vat <= 0) return 0;
return tx.amount - tx.amount / (1 + tx.vat / 100);
};
// Aynı gün bir sonraki ay (ay sonuna kıstırılır)
const fnNextMonth = (dateStr) => {
const d = new Date(dateStr + 'T12:00:00');
const day = d.getDate();
d.setDate(1); d.setMonth(d.getMonth() + 1);
const lastDay = new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate();
d.setDate(Math.min(day, lastDay));
const pad = n => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
};
const INC_COLOR = '#22c55e';
const EXP_COLOR = '#ef4444';
// ── İşlem Durumu ──────────────────────────────────────────────
const TX_STATUSES = {
done: { label: 'Alındı / Ödendi', short: 'Alındı', color: '#22c55e', bg: 'rgba(34,197,94,.12)' },
pending: { label: 'Bekliyor', short: 'Bekliyor', color: '#f59e0b', bg: 'rgba(245,158,11,.12)' },
deferred: { label: 'Vadeli', short: 'Vadeli', color: '#6366f1', bg: 'rgba(99,102,241,.12)' },
cancelled: { label: 'İptal', short: 'İptal', color: '#94a3b8', bg: 'rgba(148,163,184,.12)'},
};
const fnTodayISO = () => new Date().toISOString().slice(0, 10);
// Vade geçmiş + henüz tamamlanmamış → gecikmiş
const fnIsOverdue = (tx) => {
const s = tx.status || 'done';
if (s === 'done' || s === 'cancelled') return false;
const due = tx.dueDate || tx.date;
return !!due && due < fnTodayISO();
};
// Bakiyeye sayılır mı? (done veya status yok → gerçekleşmiş)
const fnIsRealized = (tx) => !tx.status || tx.status === 'done';
// Durum rozeti bileşeni
function StatusBadge({ tx }) {
const overdue = fnIsOverdue(tx);
if (overdue) {
return Gecikmiş;
}
const st = TX_STATUSES[tx.status] || TX_STATUSES.done;
if (tx.status === 'done' || !tx.status) return null; // done = varsayılan, rozet yok
return (
{st.short}
);
}
// Önceki döneme göre değişim rozeti (tutar bazlı)
function FnDelta({ cur, prev, invert }) {
if (prev === null || prev === undefined) return null;
const diff = cur - prev;
if (Math.abs(diff) < 1) return null;
const up = diff > 0;
const good = invert ? !up : up;
return (
{up ? '▲' : '▼'} {fmtM(Math.abs(diff))} önceki döneme göre
);
}
const fmtM = (n, compact = false) => {
if (compact && Math.abs(n) >= 1000) {
return new Intl.NumberFormat('tr-TR', { maximumFractionDigits: 1 }).format(n / 1000) + 'B';
}
return new Intl.NumberFormat('tr-TR', { maximumFractionDigits: 0 }).format(n) + ' ₺';
};
const fmtDate = (d) => new Date(d + 'T00:00:00').toLocaleDateString('tr-TR', { day: 'numeric', month: 'short', year: 'numeric' });
// ── Nakit Akışı Grafik (SVG Grouped Bar + Net Line) ───────────
function CashFlowChart({ data }) {
const [hov, setHov] = React.useState(null);
const W = 700, H = 200, pL = 52, pR = 12, pT = 14, pB = 34;
const cW = W - pL - pR, cH = H - pT - pB;
const maxVal = Math.max(...data.map(m => Math.max(m.inc, m.exp)), 1);
const n = data.length;
const groupW = cW / n;
const barW = Math.min(18, groupW * 0.35);
const gap = 3;
const barX = (i, which) => pL + i * groupW + groupW / 2 + (which === 0 ? -(barW + gap / 2) : gap / 2);
const barH = (v) => Math.max(2, (v / maxVal) * cH);
const barY = (v) => pT + cH - barH(v);
const netPoints = data.map((m, i) => {
const net = m.inc - m.exp;
const cx = pL + i * groupW + groupW / 2;
const cy = pT + cH - ((net / maxVal) * cH);
return [cx, Math.max(pT + 2, Math.min(pT + cH, cy))];
});
// Köşeli kırık çizgi yerine yumuşak kübik eğri — premium his
const netPath = netPoints.reduce((p, [x1, y1], i) => {
if (i === 0) return `M${x1},${y1}`;
const [x0, y0] = netPoints[i - 1];
const mx = (x0 + x1) / 2;
return p + ` C${mx},${y0} ${mx},${y1} ${x1},${y1}`;
}, '');
const yTicks = [0, 0.5, 1].map(r => ({ y: pT + cH * (1 - r), val: maxVal * r }));
const TIP_W = 124, TIP_H = 66;
return (
);
}
// ── Kategori Yatay Bar ────────────────────────────────────────
function CategoryBars({ data, color, max }) {
if (!data.length) return Bu dönemde veri yok
;
const maxVal = max || Math.max(...data.map(d => d.value), 1);
return (
{data.map((d, i) => (
{d.label}
{fmtM(d.value)}
))}
);
}
// ── Yaklaşan Ödemeler & Tahsilatlar (14 gün) ─────────────────
function YaklasanOdemeler({ transactions, projById }) {
const today = fnTodayISO();
const in14 = new Date(); in14.setDate(in14.getDate() + 14);
const in14S = in14.toISOString().slice(0, 10);
const upcoming = transactions
.filter(t => {
if (t.status === 'done' || t.status === 'cancelled') return false;
const due = t.dueDate || t.date;
return due >= today && due <= in14S;
})
.sort((a, b) => (a.dueDate||a.date).localeCompare(b.dueDate||b.date))
.slice(0, 6);
if (!upcoming.length) return null;
const fmtShort = d => new Date(d+'T00:00:00').toLocaleDateString('tr-TR',{day:'numeric',month:'short'});
const diffDays = d => Math.round((new Date(d+'T00:00:00') - new Date().setHours(0,0,0,0)) / 86400000);
return (
Yaklaşan Ödemeler & Tahsilatlar
{upcoming.length} işlem · 14 gün
{upcoming.map(t => {
const proj = projById[t.projectId];
const due = t.dueDate || t.date;
const d = diffDays(due);
const lbl = d === 0 ? 'Bugün' : d === 1 ? 'Yarın' : `${d}g`;
const isUrgent = d <= 1;
return (
{lbl}
{fmtShort(due)}
{t.description}
{proj && {proj.name}}
{t.type === 'income' ? '+' : '-'}{fmtM(t.amount)}
);
})}
);
}
// ── Nakit Akış Tahmini (önümüzdeki 30 gün) ───────────────────
function NakitTahmini({ transactions, projById }) {
const today = fnTodayISO();
const in30 = new Date(); in30.setDate(in30.getDate() + 30);
const in30S = in30.toISOString().slice(0, 10);
const pending = transactions.filter(t => {
if (t.status === 'done' || t.status === 'cancelled') return false;
const due = t.dueDate || t.date;
return due <= in30S;
});
const overdueIncluded = pending.filter(t => (t.dueDate || t.date) < today).reduce((s,t) => s+t.amount, 0);
const expInc = pending.filter(t => t.type === 'income') .reduce((s,t) => s+t.amount, 0);
const expExp = pending.filter(t => t.type === 'expense').reduce((s,t) => s+t.amount, 0);
const netP = expInc - expExp;
const total = expInc + expExp || 1;
const incPct = Math.round(expInc / total * 100);
const expPct = Math.round(expExp / total * 100);
const sorted = [...pending]
.sort((a, b) => (a.dueDate||a.date).localeCompare(b.dueDate||b.date))
.slice(0, 5);
const fmtShort = d => new Date(d+'T00:00:00').toLocaleDateString('tr-TR',{day:'numeric',month:'short'});
const diffDays = d => Math.round((new Date(d+'T00:00:00') - new Date().setHours(0,0,0,0)) / 86400000);
return (
30 Günlük Projeksiyon
{pending.length} bekleyen işlem
{pending.length === 0 ? (
Bekleyen işlem yok
) : (
<>
{fmtM(expInc)}
Beklenen Gelir
{fmtM(expExp)}
Beklenen Gider
= 0 ? '#16a34a' : '#dc2626' }}>
{netP >= 0 ? '+' : ''}{fmtM(netP)}
Net Projeksiyon
Gelir {incPct}%
Gider {expPct}%
{sorted.length > 0 && (
{sorted.map(t => {
const due = t.dueDate || t.date;
const d = diffDays(due);
const lbl = d < 0 ? Math.abs(d) + 'g gecikmiş' : d === 0 ? 'Bugün' : d === 1 ? 'Yarın' : d + 'g';
const overdue = d < 0;
const proj = projById[t.projectId];
return (
{t.description}
{proj && {proj.name}}
{t.type === 'income' ? '+' : '-'}{fmtM(t.amount)}
{lbl}
);
})}
)}
{overdueIncluded > 0 && (
⚠ {fmtM(overdueIncluded)} vadesi geçmiş işlem dahil
)}
>
)}
);
}
// ── Kâr Marjı Trendi ─────────────────────────────────────────
function MarjTrend({ monthlyData }) {
const [hovIdx, setHovIdx] = React.useState(null);
const pts = monthlyData.filter(m => m.inc > 0 || m.exp > 0);
if (pts.length < 2) return null;
const margins = pts.map(m => m.inc > 0 ? Math.round((m.inc-m.exp)/m.inc*100) : 0);
const W = 400, H = 60;
const minM = Math.min(...margins, -10), maxM = Math.max(...margins, 30);
const range = maxM - minM || 1;
const step = W / (pts.length - 1);
const path = margins.map((v, i) => `${i===0?'M':'L'}${(i*step).toFixed(1)},${(H - ((v-minM)/range)*H).toFixed(1)}`).join(' ');
const lastM = margins[margins.length-1];
const color = lastM >= 0 ? '#16a34a' : '#dc2626';
const zeroY = H - ((0-minM)/range)*H;
const TIP_W = 90, TIP_H = 30;
return (
12 Aylık Kâr Marjı Trendi
{lastM}%
);
}
// ── Firma K&Z Tablosu ─────────────────────────────────────────
function ProjectPLTable({ rows, monthlyData, onStatement }) {
if (!rows.length) return null;
return (
Firma
Gelir
Gider
Net
Kâr Marjı
{rows.map(({ project: p, inc, exp, net, margin }) => {
const isPos = net >= 0;
const barPct = Math.min(100, Math.abs(margin ?? 0));
return (
{p.name}
{inc > 0 ? fmtM(inc) : '—'}
{exp > 0 ? fmtM(exp) : '—'}
{net !== 0 ? (isPos ? '+' : '') + fmtM(net) : '—'}
{margin !== null ? (
<>
{margin}%
>
) :
—}
);
})}
);
}
const NET_POS = '#16a34a';
const NET_NEG = '#dc2626';
// ── Firma Cari Ekstresi ──────────────────────────────────────
function StatementModal({ project, transactions, onClose }) {
const rows = React.useMemo(() => {
const txs = transactions
.filter(t => t.projectId === project.id && t.status !== 'cancelled')
.sort((a, b) => a.date.localeCompare(b.date) || (a.createdAt||'').localeCompare(b.createdAt||''));
let balance = 0;
return txs.map(t => {
balance += t.type === 'income' ? t.amount : -t.amount;
return { tx: t, balance };
});
}, [project.id, transactions]);
const totInc = rows.reduce((s, r) => s + (r.tx.type === 'income' ? r.tx.amount : 0), 0);
const totExp = rows.reduce((s, r) => s + (r.tx.type === 'expense' ? r.tx.amount : 0), 0);
const finalBalance = rows.length ? rows[rows.length - 1].balance : 0;
React.useEffect(() => {
const fn = e => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', fn);
return () => window.removeEventListener('keydown', fn);
}, [onClose]);
const printStatement = () => {
const root = document.documentElement;
const prevTheme = root.dataset.theme;
root.dataset.theme = 'light';
document.body.classList.add('fn-printing-statement');
setTimeout(() => {
window.print();
root.dataset.theme = prevTheme;
document.body.classList.remove('fn-printing-statement');
}, 100);
};
return (
e.stopPropagation()}>
{project.name} — Cari Ekstre
Düzenleme tarihi: {fmtDate(fnTodayISO())} · {rows.length} işlem
{rows.length === 0 ? (
Bu firmaya ait işlem yok
) : (
<>
Tarih
Açıklama
Gelir
Gider
Bakiye
{rows.map(({ tx: t, balance }) => (
{fmtDate(t.date)}
{t.description}
{t.invoiceNo && #{t.invoiceNo}}
{t.status && t.status !== 'done' && ({TX_STATUSES[t.status]?.short})}
{t.type === 'income' ? fmtM(t.amount) : '—'}
{t.type === 'expense' ? fmtM(t.amount) : '—'}
= 0 ? NET_POS : NET_NEG }}>
{balance < 0 ? '-' : ''}{fmtM(Math.abs(balance))}
))}
Toplam
{fmtM(totInc)}
{fmtM(totExp)}
= 0 ? NET_POS : NET_NEG }}>
{finalBalance < 0 ? '-' : ''}{fmtM(Math.abs(finalBalance))}
>
)}
);
}
// ── Kategori Bütçe Hedefleri Modalı ──────────────────────────
function BudgetModal({ state, onSetCategoryBudget, onClose }) {
const cats = fnGetCats(state, 'expense');
const budgets = state.categoryBudgets || {};
const [draft, setDraft] = React.useState(() => {
const d = {};
cats.forEach(c => { d[c] = budgets[c] ? String(budgets[c]) : ''; });
return d;
});
React.useEffect(() => {
const fn = e => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', fn);
return () => window.removeEventListener('keydown', fn);
}, [onClose]);
const save = () => {
cats.forEach(c => {
const v = parseFloat(String(draft[c]).replace(',', '.'));
onSetCategoryBudget(c, v > 0 ? v : null);
});
onClose();
};
return (
e.stopPropagation()} style={{ maxWidth: 420 }}>
Aylık Bütçe Hedefleri
Kategori bazında aylık gider limiti belirle. Boş bırakılan kategori takip edilmez.
{cats.map(c => (
))}
);
}
// ── Ana Ekran ─────────────────────────────────────────────────
function FinanceView({ state, onAddTransaction, onUpdateTransaction, onDeleteTransaction, onSetCategoryBudget, onAddCustomCategory }) {
const [period, setPeriod] = React.useState('month');
const [customFrom, setCustomFrom] = React.useState('');
const [customTo, setCustomTo] = React.useState('');
const [filterType, setFilterType] = React.useState('all');
const [filterProj, setFilterProj] = React.useState('all');
const [filterCat, setFilterCat] = React.useState('all');
const [filterStatus, setFilterStatus]= React.useState('all');
const [search, setSearch] = React.useState('');
const [catTab, setCatTab] = React.useState('expense');
const [showAdd, setShowAdd] = React.useState(false);
const [addType, setAddType] = React.useState('income');
const [editTx, setEditTx] = React.useState(null);
const [delConfirm, setDelConfirm] = React.useState(null);
const [statementProj,setStatementProj] = React.useState(null);
const [showBudgets, setShowBudgets] = React.useState(false);
const [editGoal, setEditGoal] = React.useState(false);
const [goalDraft, setGoalDraft] = React.useState('');
const transactions = state.transactions || [];
const projects = state.projects || [];
const projById = Object.fromEntries(projects.map(p => [p.id, p]));
// ── Tekrarlayan işlemler — şablonlardan eksik ayları üret ──
const recurGenRef = React.useRef(false);
React.useEffect(() => {
if (recurGenRef.current) return;
recurGenRef.current = true;
const today = fnTodayISO();
transactions.filter(t => t.recurrence?.type === 'monthly').forEach(tpl => {
const children = transactions.filter(t => t.recurrenceSourceId === tpl.id);
let lastDate = children.reduce((m, c) => c.date > m ? c.date : m, tpl.date);
let next = fnNextMonth(lastDate);
let guard = 0;
while (next <= today && guard < 12) {
onAddTransaction({
type: tpl.type, amount: tpl.amount,
description: tpl.description,
category: tpl.category, projectId: tpl.projectId || null,
invoiceNo: null, notes: tpl.notes || null, vat: tpl.vat || null,
date: next, status: 'pending', dueDate: next,
recurrenceSourceId: tpl.id,
});
lastDate = next; next = fnNextMonth(lastDate); guard++;
}
});
}, []);
// Dönem hesaplama
const todayStr = new Date().toISOString().slice(0, 10);
const dateRange = React.useMemo(() => {
const now = new Date();
const pad = n => String(n).padStart(2, '0');
const fmt = d => `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}`;
if (period === 'month') {
return { from: fmt(new Date(now.getFullYear(), now.getMonth(), 1)), to: fmt(new Date(now.getFullYear(), now.getMonth() + 1, 0)) };
}
if (period === 'last') {
return { from: fmt(new Date(now.getFullYear(), now.getMonth() - 1, 1)), to: fmt(new Date(now.getFullYear(), now.getMonth(), 0)) };
}
if (period === 'year') {
return { from: `${now.getFullYear()}-01-01`, to: `${now.getFullYear()}-12-31` };
}
if (period === 'custom') return { from: customFrom, to: customTo };
return { from: '', to: '' };
}, [period, customFrom, customTo]);
// Döneme göre filtre
const periodTx = React.useMemo(() => transactions.filter(t => {
if (dateRange.from && t.date < dateRange.from) return false;
if (dateRange.to && t.date > dateRange.to) return false;
return true;
}), [transactions, dateRange]);
// Liste için ek filtre
const filtered = React.useMemo(() => [...periodTx].filter(t => {
if (filterType !== 'all' && t.type !== filterType) return false;
if (filterProj !== 'all' && t.projectId !== filterProj) return false;
if (filterCat !== 'all' && t.category !== filterCat) return false;
if (filterStatus !== 'all') {
if (filterStatus === 'overdue') { if (!fnIsOverdue(t)) return false; }
else { if ((t.status || 'done') !== filterStatus) return false; }
}
if (search) {
const q = search.toLowerCase();
const hay = [t.description, t.invoiceNo, t.notes, t.category].filter(Boolean).join(' ').toLowerCase();
if (!hay.includes(q)) return false;
}
return true;
}).sort((a, b) => b.date.localeCompare(a.date) || (b.createdAt||'').localeCompare(a.createdAt||'')),
[periodTx, filterType, filterProj, filterCat, filterStatus, search]);
// KPI'lar — gerçekleşen vs beklenen ayrımı
const realizedInc = periodTx.filter(t => t.type === 'income' && fnIsRealized(t)).reduce((s,t) => s+t.amount, 0);
const realizedExp = periodTx.filter(t => t.type === 'expense' && fnIsRealized(t)).reduce((s,t) => s+t.amount, 0);
const netRealized = realizedInc - realizedExp;
const expectedInc = periodTx.filter(t => t.type === 'income' && !fnIsRealized(t) && t.status !== 'cancelled').reduce((s,t) => s+t.amount, 0);
const expectedExp = periodTx.filter(t => t.type === 'expense' && !fnIsRealized(t) && t.status !== 'cancelled').reduce((s,t) => s+t.amount, 0);
const overdueList = periodTx.filter(t => fnIsOverdue(t));
const overdueAmt = overdueList.reduce((s,t) => s+t.amount, 0);
// Gelir hedefi
const incomeGoal = (state.categoryBudgets || {})['__income_goal__'] || 0;
const goalPct = incomeGoal > 0 ? Math.round((realizedInc / incomeGoal) * 100) : 0;
// Önceki dönem karşılaştırması
const prevKpi = React.useMemo(() => {
const now = new Date();
const pad = n => String(n).padStart(2, '0');
const fmt = d => `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}`;
let from = '', to = '';
if (period === 'month') {
from = fmt(new Date(now.getFullYear(), now.getMonth() - 1, 1));
to = fmt(new Date(now.getFullYear(), now.getMonth(), 0));
} else if (period === 'last') {
from = fmt(new Date(now.getFullYear(), now.getMonth() - 2, 1));
to = fmt(new Date(now.getFullYear(), now.getMonth() - 1, 0));
} else if (period === 'year') {
from = `${now.getFullYear()-1}-01-01`; to = `${now.getFullYear()-1}-12-31`;
} else if (period === 'custom' && customFrom && customTo) {
const f = new Date(customFrom + 'T00:00:00'), t2 = new Date(customTo + 'T00:00:00');
const span = Math.round((t2 - f) / 86400000) + 1;
const pf = new Date(f); pf.setDate(pf.getDate() - span);
const pt = new Date(f); pt.setDate(pt.getDate() - 1);
from = fmt(pf); to = fmt(pt);
} else return null;
const ptx = transactions.filter(t => t.date >= from && t.date <= to);
return {
inc: ptx.filter(t => t.type === 'income' && fnIsRealized(t)).reduce((s,t) => s+t.amount, 0),
exp: ptx.filter(t => t.type === 'expense' && fnIsRealized(t)).reduce((s,t) => s+t.amount, 0),
};
}, [period, customFrom, customTo, transactions]);
// KDV özeti (seçili dönem, iptal hariç)
const vatCollected = periodTx.filter(t => t.type === 'income' && t.status !== 'cancelled').reduce((s,t) => s + fnVatAmount(t), 0);
const vatPaid = periodTx.filter(t => t.type === 'expense' && t.status !== 'cancelled').reduce((s,t) => s + fnVatAmount(t), 0);
const hasVat = vatCollected > 0 || vatPaid > 0;
// Eski KPI isimleri (grafik ve diğer hesaplar için)
const totalIncome = realizedInc + expectedInc;
const totalExpense = realizedExp + expectedExp;
const netBalance = netRealized;
const txCount = periodTx.filter(t => t.status !== 'cancelled').length;
// Son 12 ay verisi (grafik her zaman 12 ayı gösterir)
const monthlyData = React.useMemo(() => {
const now = new Date();
return Array.from({ length: 12 }, (_, i) => {
const d = new Date(now.getFullYear(), now.getMonth() - (11 - i), 1);
const key = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}`;
const lbl = d.toLocaleDateString('tr-TR', { month: 'short' });
const inc = transactions.filter(t => t.type === 'income' && t.date.startsWith(key)).reduce((s,t) => s+t.amount, 0);
const exp = transactions.filter(t => t.type === 'expense' && t.date.startsWith(key)).reduce((s,t) => s+t.amount, 0);
return { key, lbl, inc, exp };
});
}, [transactions]);
// Aylık ortalamalar (veri olan aylardan)
const activeMonths = Math.max(1, monthlyData.filter(m => m.inc > 0 || m.exp > 0).length);
const allIncome = transactions.filter(t => t.type === 'income') .reduce((s,t) => s+t.amount, 0);
const allExpense = transactions.filter(t => t.type === 'expense').reduce((s,t) => s+t.amount, 0);
const avgIncome = allIncome / activeMonths;
const avgExpense = allExpense / activeMonths;
// En iyi ay (net)
const bestMonth = monthlyData.reduce((best, m) => (m.inc - m.exp) > (best ? best.inc - best.exp : -Infinity) ? m : best, null);
// Kategori dağılımı (seçilen dönem)
const catData = React.useMemo(() => {
const cats = fnGetCats(state, catTab);
return cats.map(cat => ({
label: cat,
value: periodTx.filter(t => t.type === catTab && t.category === cat).reduce((s,t) => s+t.amount, 0),
})).filter(c => c.value > 0).sort((a,b) => b.value - a.value);
}, [periodTx, catTab, state.customCategories]);
// Bütçe takibi — her zaman içinde bulunulan aya göre
const categoryBudgets = state.categoryBudgets || {};
const budgetRows = React.useMemo(() => {
const monthKey = todayStr.slice(0, 7);
return Object.entries(categoryBudgets).filter(([cat]) => cat !== '__income_goal__').map(([cat, limit]) => {
const spent = transactions
.filter(t => t.type === 'expense' && t.category === cat && t.status !== 'cancelled' && t.date.startsWith(monthKey))
.reduce((s, t) => s + t.amount, 0);
return { cat, limit, spent, pct: Math.round((spent / limit) * 100) };
}).sort((a, b) => b.pct - a.pct);
}, [categoryBudgets, transactions, todayStr]);
// Proje K&Z
const projectPL = React.useMemo(() => projects.map(p => {
const txs = periodTx.filter(t => t.projectId === p.id);
const inc = txs.filter(t => t.type === 'income') .reduce((s,t) => s+t.amount, 0);
const exp = txs.filter(t => t.type === 'expense').reduce((s,t) => s+t.amount, 0);
const net = inc - exp;
const margin = inc > 0 ? Math.round((net / inc) * 100) : null;
return { project: p, inc, exp, net, margin, count: txs.length };
}).filter(r => r.count > 0).sort((a,b) => Math.abs(b.net) - Math.abs(a.net)),
[projects, periodTx]);
// İşlemleri aya göre grupla
const txGroups = React.useMemo(() => {
const groups = {};
filtered.forEach(t => {
const key = t.date.slice(0, 7);
if (!groups[key]) {
const d = new Date(key + '-01');
groups[key] = { key, label: d.toLocaleDateString('tr-TR', { month: 'long', year: 'numeric' }), items: [], inc: 0, exp: 0 };
}
groups[key].items.push(t);
if (t.type === 'income') groups[key].inc += t.amount;
else groups[key].exp += t.amount;
});
return Object.values(groups).sort((a,b) => b.key.localeCompare(a.key));
}, [filtered]);
// Tüm kategoriler (liste filtresi için)
const allCats = React.useMemo(() => {
const set = new Set(periodTx.map(t => t.category));
return [...set].sort();
}, [periodTx]);
// İşlem kopyalama (rest spread kullanılmıyor — Babel global scope çakışması)
const duplicateTx = (t) => {
onAddTransaction({
type: t.type,
amount: t.amount,
description: t.description,
category: t.category,
projectId: t.projectId || null,
invoiceNo: t.invoiceNo || null,
notes: t.notes || null,
vat: t.vat || null,
date: todayStr,
status: 'pending',
dueDate: null,
});
};
// Hızlı durum güncelleme
const cycleStatus = (t) => {
const ORDER = ['done','pending','deferred','cancelled'];
const cur = t.status || 'done';
const next = ORDER[(ORDER.indexOf(cur) + 1) % ORDER.length];
onUpdateTransaction?.(t.id, { status: next });
};
// CSV dışa aktarma
const exportCSV = () => {
const rows = [
['Tarih','Tür','Tutar','Açıklama','Kategori','Firma'],
...filtered.map(t => [
t.date,
t.type === 'income' ? 'Gelir' : 'Gider',
t.amount.toFixed(2),
`"${(t.description||'').replace(/"/g,'""')}"`,
t.category || '',
projById[t.projectId]?.name || '',
].join(','))
];
const blob = new Blob(['' + rows.join('\n')], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = `finans-${todayStr}.csv`;
a.click(); URL.revokeObjectURL(url);
};
const PERIODS = [
{ id: 'month', label: 'Bu Ay' },
{ id: 'last', label: 'Geçen Ay' },
{ id: 'year', label: 'Bu Yıl' },
{ id: 'all', label: 'Tümü' },
{ id: 'custom',label: 'Özel' },
];
return (
{/* ── Header ──────────────────────────────────────────── */}
{/* Cetvel imzası */}
{/* ── KPI Kartları — gerçekleşen / beklenen / gecikmiş ── */}
{/* Gerçekleşen Gelir */}
Tahsil Edilen
{fmtM(realizedInc)}
{expectedInc > 0 &&
+ {fmtM(expectedInc)} bekleniyor
}
{prevKpi &&
}
{/* Gerçekleşen Gider */}
Ödenen
{fmtM(realizedExp)}
{expectedExp > 0 &&
+ {fmtM(expectedExp)} bekliyor
}
{prevKpi &&
}
{/* Net Gerçek */}
= 0 ? 'net-pos' : 'net-neg'}`}>
Net Gerçek
= 0 ? 'inc' : 'exp'}`}>
{netRealized < 0 ? '-' : ''}{fmtM(Math.abs(netRealized))}
{(expectedInc > 0 || expectedExp > 0) && (
Proj: {(netRealized + expectedInc - expectedExp) >= 0 ? '+' : ''}{fmtM(netRealized + expectedInc - expectedExp)}
)}
{prevKpi &&
}
{/* Gecikmiş */}
0 ? 'net-neg' : 'neutral'}`}>
Gecikmiş
0 ? 'exp' : ''}`} style={overdueList.length === 0 ? {color:'var(--text-faint)'} : {}}>
{overdueList.length > 0 ? fmtM(overdueAmt) : 'Yok'}
{overdueList.length > 0
?
{overdueList.length} işlem
:
Her şey vadesinde ✓
}
{/* İşlem sayısı / ort. gelir */}
Ort. Aylık Gelir (tüm zamanlar)
{transactions.length ? fmtM(avgIncome) : '—'}
{txCount} işlem
{/* ── Gelir Hedefi Şeridi ─────────────────────────────── */}
0 ? '' : ' fn-goal-strip--empty'}`}
onClick={() => { setGoalDraft(incomeGoal > 0 ? String(incomeGoal) : ''); setEditGoal(true); }}>
0 ? 'var(--accent)' : 'var(--text-faint)' }} />
Gelir Hedefi
{incomeGoal > 0 ? (
<>
= 100 ? '#22c55e' : goalPct >= 75 ? '#f59e0b' : 'var(--accent)',
}} />
= 100 ? '#22c55e' : goalPct >= 75 ? '#f59e0b' : 'var(--accent)' }}>
{goalPct}%
{fmtM(realizedInc)} / {fmtM(incomeGoal)}
>
) : (
Tıkla ve hedef belirle
)}
{/* ── KDV Özeti ──────────────────────────────────────── */}
{hasVat && (
KDV Özeti (seçili dönem):
Hesaplanan {fmtM(vatCollected)}
İndirilecek {fmtM(vatPaid)}
Fark = 0 ? NET_NEG : NET_POS }}>{fmtM(vatCollected - vatPaid)}
)}
{/* ── Yaklaşan Ödemeler ──────────────────────────────── */}
{/* ── Grafik + Kategori ───────────────────────────────── */}
{/* Nakit akış grafiği */}
Son 12 Ay — Nakit Akışı
Gelir
Gider
Net
{transactions.length > 0
?
:
Henüz veri yok
}
{bestMonth && (bestMonth.inc > 0 || bestMonth.exp > 0) && (
En iyi ay: {bestMonth.lbl} — net {fmtM(bestMonth.inc - bestMonth.exp)}
)}
{/* Nakit tahmini */}
{/* Kategori dağılımı */}
Kategori Dağılımı
{catTab === 'expense' && (
)}
{catTab === 'expense' && budgetRows.length > 0 && (
Bu Ay — Bütçe Hedefleri
{budgetRows.map(({ cat, limit, spent, pct }) => {
const over = spent > limit;
const warn = !over && pct >= 80;
return (
{cat}
{fmtM(spent, true)} / {fmtM(limit, true)}{over ? ' ⚠' : ''}
);
})}
)}
{/* ── Firma K&Z ───────────────────────────────────────── */}
{projectPL.length > 0 && (
Firma Kâr & Zarar
{period === 'all' ? 'Tüm zamanlar' : 'Seçili dönem'}
)}
{/* ── İşlem Listesi ───────────────────────────────────── */}
{/* Filtre çubuğu */}
setSearch(e.target.value)}
/>
{search && }
{[{id:'all',label:'Tümü'},{id:'income',label:'Gelirler'},{id:'expense',label:'Giderler'}].map(t => (
))}
{allCats.length > 0 && (
)}
{filtered.length} kayıt
{/* Gruplu liste */}
{filtered.length === 0 ? (
{transactions.length === 0 ? 'Henüz kayıt yok' : 'Filtre kriterlerine uyan kayıt bulunamadı'}
{transactions.length === 0 && (
)}
) : (
{txGroups.map(g => (
{g.label}
{g.inc > 0 && +{fmtM(g.inc)}}
{g.exp > 0 && -{fmtM(g.exp)}}
= 0 ? 'pos' : 'neg'}`}>
Net: {(g.inc - g.exp) < 0 ? '-' : ''}{fmtM(Math.abs(g.inc - g.exp))}
{g.items.map(t => {
const proj = projById[t.projectId];
return (
{t.description}
{t.invoiceNo && (
#{t.invoiceNo}
)}
cycleStatus(t)}
title="Tıkla: durumu değiştir"
>
{fnIsOverdue(t) ? 'Gecikmiş' : (t.status && t.status !== 'done' ? TX_STATUSES[t.status]?.short : null)}
{t.category}
{t.vat > 0 && <>·KDV %{t.vat}>}
{t.recurrence && <>· Aylık>}
{proj && (
<>·{proj.name}>
)}
·
{fmtDate(t.date)}
{t.dueDate && t.dueDate !== t.date && (
<>·
Vade: {fmtDate(t.dueDate)}
>
)}
{t.notes && (
<>·
📝 {t.notes.slice(0, 40)}{t.notes.length > 40 ? '…' : ''}>
)}
{t.type === 'income' ? '+' : '-'}{fmtM(t.amount)}
{t.receipt && (
)}
);
})}
))}
)}
{/* ── Modallar ────────────────────────────────────────── */}
{(showAdd || editTx) && (
{
if (editTx) {
onUpdateTransaction?.(editTx.id, data);
setEditTx(null);
} else {
if (Array.isArray(data)) data.forEach(d => onAddTransaction(d));
else onAddTransaction(data);
setShowAdd(false);
}
}}
onClose={() => { setShowAdd(false); setEditTx(null); }}
/>
)}
{statementProj && (
setStatementProj(null)} />
)}
{showBudgets && (
setShowBudgets(false)} />
)}
{/* ── Gelir Hedefi Modalı ──────────────────────────────── */}
{editGoal && (
setEditGoal(false)}>
e.stopPropagation()}>
Aylık Gelir Hedefi
Seçili dönemde tahsil edilen gelirle karşılaştırılır.
setGoalDraft(e.target.value)}
autoFocus
onKeyDown={e => {
if (e.key === 'Enter') {
const v = parseFloat(String(goalDraft).replace(',', '.'));
onSetCategoryBudget('__income_goal__', v > 0 ? v : null);
setEditGoal(false);
}
if (e.key === 'Escape') setEditGoal(false);
}}
/>
{incomeGoal > 0 && (
)}
)}
{delConfirm && (
setDelConfirm(null)}>
e.stopPropagation()}>
Kayıt silinsin mi?
Bu işlem geri alınamaz.
)}
);
}
// ── Yeni Kayıt / Düzenleme Modalı ────────────────────────────
function AddTransactionModal({ state, initialType, editingTx, onSave, onClose, onAddCustomCategory }) {
const isEdit = !!editingTx;
const [type, setType] = React.useState(editingTx?.type || initialType || 'income');
const [amount, setAmount] = React.useState(editingTx ? String(editingTx.amount) : '');
const [description, setDescription] = React.useState(editingTx?.description || '');
const [invoiceNo, setInvoiceNo] = React.useState(editingTx?.invoiceNo || '');
const [notes, setNotes] = React.useState(editingTx?.notes || '');
const [category, setCategory] = React.useState(
editingTx?.category || fnGetCats(state, initialType === 'expense' ? 'expense' : 'income')[0]
);
const [projectId, setProjectId] = React.useState(editingTx?.projectId || state.activeProjectId || '');
const [date, setDate] = React.useState(editingTx?.date || new Date().toISOString().slice(0, 10));
const [status, setStatus] = React.useState(editingTx?.status || 'done');
const [dueDate, setDueDate] = React.useState(editingTx?.dueDate || '');
const [vat, setVat] = React.useState(editingTx?.vat ? String(editingTx.vat) : '');
const [recurring, setRecurring] = React.useState(!!editingTx?.recurrence);
const [installments,setInstallments]= React.useState(1);
const [receipt, setReceipt] = React.useState(editingTx?.receipt || null);
const [newCatMode, setNewCatMode] = React.useState(false);
const [newCatName, setNewCatName] = React.useState('');
const [fileError, setFileError] = React.useState('');
React.useEffect(() => {
if (!isEdit) setCategory(fnGetCats(state, type)[0]);
}, [type]);
const handleFile = (e) => {
const f = e.target.files?.[0];
if (!f) return;
setFileError('');
if (f.size > 1.5 * 1024 * 1024) { setFileError('Dosya 1,5 MB\'tan büyük olamaz.'); return; }
const reader = new FileReader();
reader.onload = () => setReceipt({ name: f.name, data: reader.result });
reader.readAsDataURL(f);
};
const addNewCat = () => {
const name = newCatName.trim();
if (!name) return;
onAddCustomCategory?.(type, name);
setCategory(name);
setNewCatMode(false); setNewCatName('');
};
React.useEffect(() => {
const fn = e => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', fn);
return () => window.removeEventListener('keydown', fn);
}, [onClose]);
const save = () => {
const amt = parseFloat(String(amount).replace(',', '.'));
if (!amt || amt <= 0 || !description.trim()) return;
const vatNum = parseFloat(String(vat).replace(',', '.')) || null;
const base = {
type, amount: amt,
description: description.trim(),
invoiceNo: invoiceNo.trim() || null,
notes: notes.trim() || null,
category,
projectId: projectId || null,
date,
status,
dueDate: (status !== 'done' && status !== 'cancelled' && dueDate) ? dueDate : null,
vat: vatNum && vatNum > 0 ? vatNum : null,
recurrence: !isEdit && recurring ? { type: 'monthly' } : (isEdit && editingTx.recurrence && recurring ? editingTx.recurrence : null),
receipt,
};
// Taksitlendirme — ilk taksit seçilen tarih/durumla, kalanlar aylık vadeli
if (!isEdit && !recurring && installments > 1) {
const per = Math.floor((amt / installments) * 100) / 100;
const first = Math.round((amt - per * (installments - 1)) * 100) / 100;
const list = [];
let d = date;
for (let i = 0; i < installments; i++) {
list.push({
...base,
amount: i === 0 ? first : per,
description: `${base.description} (${i + 1}/${installments})`,
date: d,
status: i === 0 ? status : 'deferred',
dueDate: i === 0 ? base.dueDate : d,
receipt: i === 0 ? receipt : null,
});
d = fnNextMonth(d);
}
onSave(list);
return;
}
onSave(base);
};
const isValid = parseFloat(String(amount).replace(',', '.')) > 0 && description.trim();
const cats = fnGetCats(state, type);
const borderColor = type === 'income' ? INC_COLOR : EXP_COLOR;
return (
e.stopPropagation()}>
{isEdit ? 'Kaydı Düzenle' : (type === 'income' ? 'Gelir Ekle' : 'Gider Ekle')}
);
}
window.FinanceView = FinanceView;
console.log('[finance.jsx] loaded, FinanceView=', typeof window.FinanceView);