feat: laporan keuangan
parent
a3b2d699b3
commit
0d0615cc49
|
|
@ -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; }
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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" : "")>
|
||||
|
|
|
|||
Loading…
Reference in New Issue