feat: laporan keuangan

main
Yuri Dimas 2025-12-24 11:30:00 +07:00
parent a3b2d699b3
commit 0d0615cc49
No known key found for this signature in database
GPG Key ID: 9FD7E44BC294C68C
3 changed files with 468 additions and 0 deletions

View File

@ -0,0 +1,173 @@
using Microsoft.AspNetCore.Mvc;
using System.ComponentModel.DataAnnotations;
using System.Globalization;
namespace BankSampahApp.Controllers.Main;
[Route("Main/[controller]/[action]")]
public class LaporanKeuanganController : Controller
{
private static readonly List<MonthlyTransactionSnapshot> _monthlyTransactions =
[
new(2025, 1, 12_500_000m, 18_200_000m),
new(2025, 2, 10_350_000m, 16_750_000m),
new(2025, 3, 9_800_000m, 17_150_000m),
new(2025, 4, 11_200_000m, 15_600_000m),
new(2025, 5, 12_200_000m, 18_900_000m),
new(2025, 6, 10_450_000m, 14_800_000m),
new(2025, 7, 9_900_000m, 19_750_000m),
new(2025, 8, 11_650_000m, 17_500_000m),
new(2025, 9, 12_400_000m, 18_350_000m),
new(2024, 10, 10_050_000m, 15_500_000m),
new(2024, 11, 11_250_000m, 16_200_000m),
new(2024, 12, 12_800_000m, 18_600_000m)
];
private static readonly List<ManualExpense> _manualExpenses =
[
new() { Id = 1, ExpenseDate = new DateTime(2025, 1, 12), Title = "Transport & Operasional", Amount = 1_200_000m, Notes = "Pengiriman sampah ke offtaker" },
new() { Id = 2, ExpenseDate = new DateTime(2025, 3, 8), Title = "Perawatan Timbangan", Amount = 800_000m, Notes = "Kalibrasi timbangan BSU" },
new() { Id = 3, ExpenseDate = new DateTime(2025, 5, 20), Title = "Konsumsi Sosialisasi", Amount = 450_000m, Notes = "Kegiatan edukasi warga" },
new() { Id = 4, ExpenseDate = new DateTime(2024, 11, 14), Title = "Biaya Listrik Gudang", Amount = 600_000m, Notes = "Tagihan bulan berjalan" }
];
public IActionResult Index()
{
var years = _monthlyTransactions
.Select(t => t.Year)
.Concat(_manualExpenses.Select(e => e.ExpenseDate.Year))
.Distinct()
.OrderByDescending(y => y)
.ToList();
if (!years.Any())
{
years.Add(DateTime.UtcNow.Year);
}
ViewBag.AvailableYears = years;
ViewBag.DefaultYear = years.First();
return View("~/Views/Main/LaporanKeuangan/Index.cshtml");
}
[HttpGet]
public IActionResult Table(int? year)
{
var selectedYear = year ?? GetLatestYear();
var rows = BuildRows(selectedYear);
var totalSaldo = rows.LastOrDefault()?.SaldoAkumulatif ?? 0m;
var data = rows.Select((row, index) => new
{
no = index + 1,
bulan = row.Bulan,
pembelian = row.Pembelian,
penjualan = row.Penjualan,
pengeluaran = row.PengeluaranLain,
labaRugi = row.LabaRugiBulanan,
saldo = row.SaldoAkumulatif,
isProfit = row.LabaRugiBulanan >= 0
});
return Json(new { data, totalSaldo });
}
[HttpPost]
[ValidateAntiForgeryToken]
public IActionResult TambahPengeluaran([FromForm] PengeluaranInput input)
{
if (!ModelState.IsValid || input.Nominal <= 0)
{
return BadRequest(new { message = "Data pengeluaran tidak valid" });
}
var newExpense = new ManualExpense
{
Id = _manualExpenses.Any() ? _manualExpenses.Max(x => x.Id) + 1 : 1,
ExpenseDate = input.Tanggal,
Title = input.NamaPengeluaran,
Amount = input.Nominal,
Notes = input.Keterangan ?? string.Empty
};
_manualExpenses.Add(newExpense);
return Json(new { success = true });
}
private static List<FinancialRow> BuildRows(int year)
{
var rows = new List<FinancialRow>();
var culture = new CultureInfo("id-ID");
decimal saldoAkumulatif = 0m;
for (int month = 1; month <= 12; month++)
{
var baseData = _monthlyTransactions.FirstOrDefault(t => t.Year == year && t.Month == month);
var pembelian = baseData?.Pembelian ?? 0m;
var penjualan = baseData?.Penjualan ?? 0m;
var pengeluaranLain = _manualExpenses
.Where(e => e.ExpenseDate.Year == year && e.ExpenseDate.Month == month)
.Sum(e => e.Amount);
var labaRugi = penjualan - (pembelian + pengeluaranLain);
saldoAkumulatif += labaRugi;
rows.Add(new FinancialRow
{
Bulan = culture.DateTimeFormat.GetMonthName(month) + " " + year,
Pembelian = pembelian,
Penjualan = penjualan,
PengeluaranLain = pengeluaranLain,
LabaRugiBulanan = labaRugi,
SaldoAkumulatif = saldoAkumulatif
});
}
return rows;
}
private static int GetLatestYear()
{
return _monthlyTransactions.Select(t => t.Year)
.Concat(_manualExpenses.Select(e => e.ExpenseDate.Year))
.DefaultIfEmpty(DateTime.UtcNow.Year)
.Max();
}
private record MonthlyTransactionSnapshot(int Year, int Month, decimal Pembelian, decimal Penjualan);
private class ManualExpense
{
public int Id { get; set; }
public DateTime ExpenseDate { get; set; }
public string Title { get; set; } = string.Empty;
public decimal Amount { get; set; }
public string Notes { get; set; } = string.Empty;
}
public class PengeluaranInput
{
[Required]
public DateTime Tanggal { get; set; }
[Required]
[StringLength(150)]
public string NamaPengeluaran { get; set; } = string.Empty;
[Range(typeof(decimal), "0.01", "79228162514264337593543950335")]
public decimal Nominal { get; set; }
[StringLength(200)]
public string? Keterangan { get; set; }
}
private class FinancialRow
{
public string Bulan { get; set; } = string.Empty;
public decimal Pembelian { get; set; }
public decimal Penjualan { get; set; }
public decimal PengeluaranLain { get; set; }
public decimal LabaRugiBulanan { get; set; }
public decimal SaldoAkumulatif { get; set; }
}
}

View File

@ -0,0 +1,287 @@
@{
ViewData["Title"] = "Laporan Keuangan";
}
<div class="flex flex-col gap-2 md:flex-row md:justify-between md:gap-0">
<div class="prose">
<span class="text-xl font-semibold text-black">
Laporan Keuangan
</span>
<p class="text-sm text-gray-500">Rekap otomatis pembelian, penjualan, dan pengeluaran BSU per bulan.</p>
</div>
<div class="flex flex-col gap-2 md:flex-row items-center">
<select id="filter_tahun" class="select select-bordered w-full md:w-48">
</select>
<button class="btn btn-sm bg-green-800 max-w-full rounded-full text-white hover:bg-green-900" onclick="modal_pengeluaran.showModal()">
Tambah Pengeluaran Lain-lain
<i class="ph ph-plus"></i>
</button>
</div>
</div>
<div class="h-6"></div>
<div class="card bg-white">
<div class="card-body p-2">
<div class="w-full overflow-x-auto">
<table class="table-zebra table" id="laporanTable">
<thead>
<tr>
<th class="w-[5%]">No</th>
<th class="w-[20%]">Bulan</th>
<th class="w-[15%]">Pembelian (Rp)</th>
<th class="w-[15%]">Penjualan (Rp)</th>
<th class="w-[15%]">Pengeluaran Lain-lain (Rp)</th>
<th class="w-[15%]">Laba/Rugi Bulanan (Rp)</th>
<th class="w-[15%]">Saldo Akumulatif (Rp)</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
</div>
<div class="mt-4 rounded-2xl bg-gray-50 p-4 flex flex-col md:flex-row md:items-center md:justify-between gap-2">
<div>
<p class="text-sm text-gray-500">Saldo akumulatif tahun berjalan</p>
<p class="text-xl font-semibold text-gray-900" id="totalSaldoLabel">Rp 0</p>
</div>
<div class="text-sm text-gray-500">
Data dihitung otomatis dari transaksi pembelian, penjualan, dan pengeluaran manual.
</div>
</div>
<!-- Modal Pengeluaran -->
<dialog id="modal_pengeluaran" class="modal modal-bottom sm:modal-middle">
<div class="modal-box w-full max-w-2xl p-6 bg-white rounded-2xl">
<h3 class="text-gray-900 text-xl font-semibold leading-8 mb-4">Tambah Pengeluaran Lain-lain</h3>
<form id="formPengeluaran">
<div class="flex flex-col gap-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<fieldset class="fieldset">
<legend class="fieldset-legend">Tanggal<span class="text-red-500">*</span></legend>
<input type="date" name="Tanggal" class="input w-full" required />
</fieldset>
</div>
<div>
<fieldset class="fieldset">
<legend class="fieldset-legend">Nama Pengeluaran<span class="text-red-500">*</span></legend>
<input type="text" name="NamaPengeluaran" class="input w-full" placeholder="Contoh: Biaya Operasional" required />
</fieldset>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<fieldset class="fieldset">
<legend class="fieldset-legend">Nominal (Rp)<span class="text-red-500">*</span></legend>
<input type="number" min="0" step="1000" name="Nominal" class="input w-full" placeholder="0" required />
</fieldset>
</div>
<div>
<fieldset class="fieldset">
<legend class="fieldset-legend">Keterangan</legend>
<input type="text" name="Keterangan" class="input w-full" placeholder="Opsional" />
</fieldset>
</div>
</div>
<div class="flex justify-end gap-3">
<button type="button" class="btn bg-white rounded-full" onclick="closePengeluaranModal()">Batal</button>
<button type="submit" class="btn bg-green-800 text-white rounded-full">Simpan</button>
</div>
</div>
</form>
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
<!-- /modal -->
@section Scripts {
<script type="text/javascript">
const monthlyTransactions = [
{ year: 2025, month: 1, pembelian: 12500000, penjualan: 18200000 },
{ year: 2025, month: 2, pembelian: 10350000, penjualan: 16750000 },
{ year: 2025, month: 3, pembelian: 9800000, penjualan: 17150000 },
{ year: 2025, month: 4, pembelian: 11200000, penjualan: 15600000 },
{ year: 2025, month: 5, pembelian: 12200000, penjualan: 18900000 },
{ year: 2025, month: 6, pembelian: 10450000, penjualan: 14800000 },
{ year: 2025, month: 7, pembelian: 9900000, penjualan: 19750000 },
{ year: 2025, month: 8, pembelian: 11650000, penjualan: 17500000 },
{ year: 2025, month: 9, pembelian: 12400000, penjualan: 18350000 },
{ year: 2024, month: 10, pembelian: 10050000, penjualan: 15500000 },
{ year: 2024, month: 11, pembelian: 11250000, penjualan: 16200000 },
{ year: 2024, month: 12, pembelian: 12800000, penjualan: 18600000 }
];
let manualExpenses = [
{ id: 1, tanggal: '2025-01-12', title: 'Transport & Operasional', amount: 1200000, notes: 'Pengiriman sampah ke offtaker' },
{ id: 2, tanggal: '2025-03-08', title: 'Perawatan Timbangan', amount: 800000, notes: 'Kalibrasi timbangan BSU' },
{ id: 3, tanggal: '2025-05-20', title: 'Konsumsi Sosialisasi', amount: 450000, notes: 'Kegiatan edukasi warga' },
{ id: 4, tanggal: '2024-11-14', title: 'Biaya Listrik Gudang', amount: 600000, notes: 'Tagihan bulan berjalan' }
];
let latestExpenseId = manualExpenses.length;
let currentYear = getLatestYear();
let laporanTable;
$(document).ready(function () {
populateYearFilter();
laporanTable = new DataTable('#laporanTable', {
data: [],
scrollX: true,
autoWidth: false,
columns: [
{ data: 'no' },
{ data: 'bulan' },
{ data: 'pembelian', render: (d) => formatCurrency(d) },
{ data: 'penjualan', render: (d) => formatCurrency(d) },
{ data: 'pengeluaran', render: (d) => formatCurrency(d) },
{
data: 'labaRugi',
render: (d, t, row) => {
const color = row.isProfit ? 'text-green-600' : 'text-red-600';
return `<span class="${color} font-semibold">${formatCurrency(d)}</span>`;
}
},
{ data: 'saldo', render: (d) => formatCurrency(d) }
]
});
refreshTable();
$('#filter_tahun').on('change', function () {
currentYear = parseInt($(this).val(), 10) || getLatestYear();
refreshTable();
});
$('#formPengeluaran').on('submit', function (e) {
e.preventDefault();
const formData = new FormData(this);
const tanggal = formData.get('Tanggal');
const nama = formData.get('NamaPengeluaran')?.trim();
const nominal = Number(formData.get('Nominal'));
const keterangan = formData.get('Keterangan')?.trim() || '';
if (!tanggal || !nama || isNaN(nominal) || nominal <= 0) {
Swal.fire('Gagal', 'Mohon lengkapi semua data pengeluaran.', 'error');
return;
}
manualExpenses.push({
id: ++latestExpenseId,
tanggal,
title: nama,
amount: nominal,
notes: keterangan
});
populateYearFilter();
refreshTable();
Swal.fire({
title: 'Berhasil',
text: 'Pengeluaran berhasil ditambahkan',
icon: 'success',
confirmButtonText: 'OK',
buttonsStyling: false,
customClass: {
confirmButton: 'btn bg-green-800 text-white hover:bg-green-900 rounded-full'
}
}).then(() => {
document.getElementById('formPengeluaran').reset();
closePengeluaranModal();
});
});
});
function refreshTable() {
const rows = buildRows(currentYear);
laporanTable.clear();
laporanTable.rows.add(rows);
laporanTable.draw();
updateTotalSaldo(rows.length ? rows[rows.length - 1].saldo : 0);
}
function buildRows(year) {
const formatter = new Intl.DateTimeFormat('id-ID', { month: 'long' });
let saldoAkumulatif = 0;
const rows = [];
for (let month = 1; month <= 12; month++) {
const baseData = monthlyTransactions.find((t) => t.year === year && t.month === month);
const pembelian = baseData ? baseData.pembelian : 0;
const penjualan = baseData ? baseData.penjualan : 0;
const pengeluaran = manualExpenses
.filter((e) => new Date(e.tanggal).getFullYear() === year && new Date(e.tanggal).getMonth() + 1 === month)
.reduce((sum, e) => sum + e.amount, 0);
const labaRugi = penjualan - (pembelian + pengeluaran);
saldoAkumulatif += labaRugi;
rows.push({
no: month,
bulan: `${formatter.format(new Date(year, month - 1, 1))} ${year}`,
pembelian,
penjualan,
pengeluaran,
labaRugi,
saldo: saldoAkumulatif,
isProfit: labaRugi >= 0
});
}
return rows;
}
function populateYearFilter() {
const select = $('#filter_tahun');
const years = Array.from(new Set([
...monthlyTransactions.map((t) => t.year),
...manualExpenses.map((e) => new Date(e.tanggal).getFullYear())
])).sort((a, b) => b - a);
if (!years.includes(currentYear)) {
currentYear = years[0] || new Date().getFullYear();
}
select.empty();
years.forEach((year) => {
const option = $('<option></option>').attr('value', year).text(year);
if (year === currentYear) {
option.attr('selected', 'selected');
}
select.append(option);
});
}
function getLatestYear() {
const allYears = [
...monthlyTransactions.map((t) => t.year),
...manualExpenses.map((e) => new Date(e.tanggal).getFullYear())
];
return allYears.length ? Math.max(...allYears) : new Date().getFullYear();
}
function formatCurrency(value) {
const number = Number(value) || 0;
return new Intl.NumberFormat('id-ID', { style: 'currency', currency: 'IDR', minimumFractionDigits: 0 }).format(number);
}
function updateTotalSaldo(value) {
$('#totalSaldoLabel').text(formatCurrency(value));
}
function closePengeluaranModal() {
document.getElementById('formPengeluaran').reset();
modal_pengeluaran.close();
}
</script>
}

View File

@ -133,6 +133,14 @@
</a>
</li>
<!-- Laporan Keuangan -->
<li>
<a href="/Main/LaporanKeuangan/Index" class="@(controller == "LaporanKeuangan" ? "menu-active" : "")">
<i class="ph ph-chart-line-up me-1 text-lg text-gray-400"></i>
Laporan Keuangan
</a>
</li>
<!-- Wilayah -->
<li>
<details @(module == "Wilayah" && new[] { "Kota", "Kecamatan", "Kelurahan" }.Contains(controller) ? "open" : "")>