Compare commits

...

10 Commits

Author SHA1 Message Date
Yoga Pangestu
51614c8454 feat: enable SPA mode in AdminPanelProvider for improved navigation 2026-05-11 09:21:51 +07:00
Yoga Pangestu
b12e20f49a refactor: add local quantity state to feed items grid modal and allow direct save via parameter 2026-05-02 14:42:41 +07:00
Yoga Pangestu
a9e10849fd fix: make customer field optional in DelayedGoodForm schema 2026-05-02 14:36:34 +07:00
Yoga Pangestu
2b7535000a feat: add modal to manually update feed item quantities in cart 2026-05-02 14:34:42 +07:00
Yoga Pangestu
9619912f2f feat: Implement role-based dashboard content visibility and consolidate view permissions under 'Analisys'. 2026-03-09 19:32:05 +07:00
Yoga Pangestu
1eba57cb6b feat: Remove all Filament dashboard widgets including stats overview and various charts. 2026-03-09 19:12:51 +07:00
Yoga Pangestu
e11accb8cc refactor: Optimize analysis page by restructuring form components, enhancing date filter functionality, and improving customer and order statistics display. 2026-03-09 19:06:34 +07:00
Yoga Pangestu
b424d119fc feat: Create Dashboard page with comprehensive business statistics, including egg production, order counts, revenue, expenses, and top customers. 2026-03-09 18:57:18 +07:00
Yoga Pangestu
77c750db40 feat: add date filter, multiple new charts, and top customers list to the analysis page. 2026-03-09 18:04:27 +07:00
Yoga Pangestu
84909d9392 feat: Implement detailed feed scheduling and delivery seeders, refine factory data generation for various entities, and update database seeder. 2026-03-09 14:56:14 +07:00
25 changed files with 1303 additions and 383 deletions

View File

@ -4,17 +4,101 @@
use App\Models\Customer;
use App\Models\DelayedGood;
use App\Models\EggCollection;
use App\Models\EggCollectionItem;
use App\Models\Expense;
use App\Models\Feed;
use App\Models\FeedPurchase;
use App\Models\Order;
use App\Models\Payroll;
use BackedEnum;
use BezhanSalleh\FilamentShield\Traits\HasPageShield;
use Carbon\Carbon;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\Select;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Pages\Page;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Illuminate\Support\Facades\DB;
class Analisys extends Page
class Analisys extends Page implements HasForms
{
use HasPageShield, InteractsWithForms;
public ?array $data = [];
public function mount(): void
{
$this->form->fill([
'period' => 'this_month',
'from_date' => now()->startOfMonth()->format('d F Y'),
'to_date' => now()->format('d F Y'),
]);
}
public function form(Schema $form): Schema
{
return $form
->schema([
Section::make()
->schema([
Select::make('period')
->label('Periode Cepat')
->options([
'today' => 'Hari Ini',
'this_week' => 'Minggu Ini',
'this_month' => 'Bulan Ini',
'this_year' => 'Tahun Ini',
'custom' => 'Kustom Tanggal',
])
->live()
->afterStateUpdated(function ($state, $set, $get) {
if ($state === 'today') {
$set('from_date', now()->format('d F Y'));
$set('to_date', now()->format('d F Y'));
} elseif ($state === 'this_week') {
$set('from_date', now()->startOfWeek()->format('d F Y'));
$set('to_date', now()->endOfWeek()->format('d F Y'));
} elseif ($state === 'this_month') {
$set('from_date', now()->startOfMonth()->format('d F Y'));
$set('to_date', now()->endOfMonth()->format('d F Y'));
} elseif ($state === 'this_year') {
$set('from_date', now()->startOfYear()->format('d F Y'));
$set('to_date', now()->endOfYear()->format('d F Y'));
}
$this->dispatch('stats-updated');
})
->native(false),
DatePicker::make('from_date')
->label('Dari Tanggal')
->native(false)
->displayFormat('d F Y')
->live()
->afterStateUpdated(function ($set) {
$set('period', 'custom');
$this->dispatch('stats-updated');
}),
DatePicker::make('to_date')
->label('Sampai Tanggal')
->native(false)
->displayFormat('d F Y')
->live()
->afterStateUpdated(function ($set) {
$set('period', 'custom');
$this->dispatch('stats-updated');
}),
])
->compact()
->columns(3),
])
->statePath('data');
}
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedChartPie;
protected static ?string $navigationLabel = 'Analisis';
@ -28,15 +112,24 @@ class Analisys extends Page
public function getViewData(): array
{
$formatNumber = fn ($val) => number_format((float) $val, $val == floor($val) ? 0 : 2, ',', '.');
$now = now();
// 1. Count customers
$countPelanggan = Customer::count();
$fromDateInput = $this->data['from_date'] ?? now()->startOfMonth()->format('d F Y');
$toDateInput = $this->data['to_date'] ?? now()->format('d F Y');
// 2. Count total eggs (by unit)
// Normalize for DB queries
$fromDate = Carbon::parse($fromDateInput)->format('Y-m-d');
$toDate = Carbon::parse($toDateInput)->format('Y-m-d');
// 1. Customer Count
$customerCount = Customer::count();
// 2. Egg Production by Unit - Optimized join query
$totalEggsByUnit = EggCollectionItem::query()
->join('egg_collections', 'egg_collection_items.egg_collection_id', '=', 'egg_collections.id')
->join('units', 'egg_collection_items.unit_id', '=', 'units.id')
->whereNull('egg_collections.deleted_at')
->whereBetween('egg_collections.production_date', [$fromDate, $toDate])
->selectRaw('units.name as unit_name,
sum(case when is_broken = 0 then quantity else 0 end) as total_good,
sum(case when is_broken = 1 then quantity else 0 end) as total_broken')
@ -48,12 +141,17 @@ public function getViewData(): array
'total_broken' => $formatNumber($item->total_broken),
]);
// 3. Count total orders
$countPesanan = Order::count();
// 3. Orders Stats - Combined count and sum
$orderStats = Order::whereBetween('order_date', [$fromDate, $toDate])
->selectRaw('count(*) as count, sum(total_amount) as total')
->first();
$orderCount = (int) ($orderStats->count ?? 0);
$orderRevenue = (float) ($orderStats->total ?? 0);
// 4. Count total delayed goods (by unit)
$totalDelayedByUnit = DelayedGood::query()
// 4. Delayed Goods Stats - Combined by unit
$delayedStatsByUnit = DelayedGood::query()
->join('units', 'delayed_goods.unit_id', '=', 'units.id')
->whereBetween('delayed_goods.stored_date', [$fromDate, $toDate])
->selectRaw('units.name as unit_name, sum(quantity) as total')
->groupBy('units.name')
->get()
@ -62,45 +160,156 @@ public function getViewData(): array
'total' => $formatNumber($item->total),
]);
// 5. Revenue (from orders and delayed goods)
$pendapatanPesanan = Order::sum('total_amount');
$pendapatanTertunda = DelayedGood::sum('total_amount');
$totalPendapatan = $pendapatanPesanan + $pendapatanTertunda;
// 5. Financial Totals in Range
$delayedTotals = DelayedGood::whereBetween('stored_date', [$fromDate, $toDate])
->selectRaw('sum(total_amount) as total, sum(paid_amount) as paid')
->first();
// 6. Expenses
$totalPengeluaran = Expense::sum('amount');
$delayedRevenue = (float) ($delayedTotals->total ?? 0);
$paidAmountInRange = (float) ($delayedTotals->paid ?? 0);
$totalRevenue = $orderRevenue + $delayedRevenue;
// 7. Payroll
$totalPenggajian = Payroll::sum('total_salary');
$totalExpenses = (float) Expense::whereBetween('expense_date', [$fromDate, $toDate])->sum('amount');
$totalPayroll = (float) Payroll::whereBetween('period_month', [Carbon::parse($fromDate)->format('Y-m'), Carbon::parse($toDate)->format('Y-m')])->sum('total_salary');
$totalFeedPurchase = (float) FeedPurchase::whereBetween('purchase_date', [$fromDate, $toDate])->sum('total_price');
// 8. Feed purchases
$totalBelanjaPakan = FeedPurchase::sum('total_price');
// 9. Gross profit (Revenue - COGS/Feed)
$labaKotor = $totalPendapatan - $totalBelanjaPakan;
// 10. Net profit (Gross Profit - Expenses - Payroll)
$labaBersih = $labaKotor - $totalPengeluaran - $totalPenggajian;
// 11. Unpaid accounts receivable (delayed goods)
$totalPiutang = DelayedGood::sum('total_amount') - DelayedGood::sum('paid_amount');
$grossProfit = $totalRevenue - $totalFeedPurchase;
$netProfit = $grossProfit - $totalExpenses - $totalPayroll;
$totalReceivables = $delayedRevenue - $paidAmountInRange;
$mainStats = [
['label' => 'Total Pelanggan', 'desc' => 'Jumlah pelanggan terdaftar', 'value' => $countPelanggan, 'prefix' => ''],
['label' => 'Total Pesanan', 'desc' => 'Jumlah transaksi pesanan', 'value' => $countPesanan, 'prefix' => ''],
['label' => 'Laba Bersih', 'desc' => 'Setelah dikurangi beban operasional', 'value' => $formatNumber($labaBersih), 'prefix' => 'Rp'],
['label' => 'Total Piutang', 'desc' => 'Piutang barang tertunda', 'value' => $formatNumber($totalPiutang), 'prefix' => 'Rp'],
['label' => 'Total Pendapatan', 'desc' => 'Akumulasi semua pemasukan', 'value' => $formatNumber($totalPendapatan), 'prefix' => 'Rp'],
['label' => 'Laba Kotor', 'desc' => 'Pendapatan dikurangi biaya pakan', 'value' => $formatNumber($labaKotor), 'prefix' => 'Rp'],
['label' => 'Total Pengeluaran', 'desc' => 'Biaya operasional & umum', 'value' => $formatNumber($totalPengeluaran), 'prefix' => 'Rp'],
['label' => 'Total Penggajian', 'desc' => 'Total gaji karyawan', 'value' => $formatNumber($totalPenggajian), 'prefix' => 'Rp'],
['label' => 'Belanja Pakan', 'desc' => 'Total pembelian pakan ayam', 'value' => $formatNumber($totalBelanjaPakan), 'prefix' => 'Rp'],
['label' => 'Total Pelanggan', 'desc' => 'Jumlah pelanggan terdaftar', 'value' => $customerCount, 'prefix' => ''],
['label' => 'Total Pesanan', 'desc' => 'Jumlah transaksi pesanan', 'value' => $orderCount, 'prefix' => ''],
['label' => 'Laba Bersih', 'desc' => 'Setelah dikurangi beban operasional', 'value' => $formatNumber($netProfit), 'prefix' => 'Rp'],
['label' => 'Total Piutang', 'desc' => 'Piutang barang tertunda (periode)', 'value' => $formatNumber($totalReceivables), 'prefix' => 'Rp'],
['label' => 'Total Pendapatan', 'desc' => 'Akumulasi semua pemasukan', 'value' => $formatNumber($totalRevenue), 'prefix' => 'Rp'],
['label' => 'Laba Kotor', 'desc' => 'Pendapatan dikurangi biaya pakan', 'value' => $formatNumber($grossProfit), 'prefix' => 'Rp'],
['label' => 'Total Pengeluaran', 'desc' => 'Biaya operasional & umum', 'value' => $formatNumber($totalExpenses), 'prefix' => 'Rp'],
['label' => 'Total Penggajian', 'desc' => 'Total gaji karyawan', 'value' => $formatNumber($totalPayroll), 'prefix' => 'Rp'],
['label' => 'Belanja Pakan', 'desc' => 'Total pembelian pakan ayam', 'value' => $formatNumber($totalFeedPurchase), 'prefix' => 'Rp'],
];
// 6. Top 5 Customers - Optimized summing
$topCustomers = Customer::query()
->select('id', 'name', 'phone_number')
->withSum(['orders' => fn ($q) => $q->whereBetween('order_date', [$fromDate, $toDate])], 'total_amount')
->withSum(['delayedGoods' => fn ($q) => $q->whereBetween('stored_date', [$fromDate, $toDate])], 'total_amount')
->get()
->map(function ($customer) {
$customer->total_spent = ($customer->orders_sum_total_amount ?? 0) + ($customer->delayed_goods_sum_total_amount ?? 0);
return $customer;
})
->sortByDesc('total_spent')
->take(5)
->values();
// 7. Chart: Daily Production (Optimized query)
$diffDays = Carbon::parse($fromDate)->diffInDays(Carbon::parse($toDate));
$rangeQueryDays = collect(range(0, min($diffDays, 30)))->map(fn ($i) => Carbon::parse($fromDate)->addDays($i)->format('Y-m-d'))->values();
$productionDataMap = EggCollection::whereBetween('production_date', [$fromDate, $toDate])
->selectRaw('production_date, sum(total_eggs) as good, sum(total_broken_eggs) as broken')
->groupBy('production_date')
->get()
->keyBy('production_date');
$productionChartData = [
'labels' => $rangeQueryDays->map(fn ($d) => Carbon::parse($d)->format('d M'))->values(),
'good' => $rangeQueryDays->map(fn ($d) => (float) ($productionDataMap[$d]->good ?? 0))->values(),
'broken' => $rangeQueryDays->map(fn ($d) => (float) ($productionDataMap[$d]->broken ?? 0))->values(),
];
// 8. Monthly Financial Trends (Optimized aggregates)
$months = collect(range(0, 5))->map(fn ($i) => Carbon::parse($toDate)->subMonths($i)->format('Y-m'))->reverse();
$monthlyOrderRev = Order::whereIn(DB::raw("DATE_FORMAT(order_date, '%Y-%m')"), $months)
->selectRaw("DATE_FORMAT(order_date, '%Y-%m') as month, sum(total_amount) as total")
->groupBy('month')->pluck('total', 'month');
$monthlyDelayedRev = DelayedGood::whereIn(DB::raw("DATE_FORMAT(stored_date, '%Y-%m')"), $months)
->selectRaw("DATE_FORMAT(stored_date, '%Y-%m') as month, sum(total_amount) as total")
->groupBy('month')->pluck('total', 'month');
$monthlyExpenses = Expense::whereIn(DB::raw("DATE_FORMAT(expense_date, '%Y-%m')"), $months)
->selectRaw("DATE_FORMAT(expense_date, '%Y-%m') as month, sum(amount) as total")
->groupBy('month')->pluck('total', 'month');
$monthlyPayroll = Payroll::whereIn('period_month', $months)
->selectRaw('period_month as month, sum(total_salary) as total')
->groupBy('month')->pluck('total', 'month');
$monthlyFeed = FeedPurchase::whereIn(DB::raw("DATE_FORMAT(purchase_date, '%Y-%m')"), $months)
->selectRaw("DATE_FORMAT(purchase_date, '%Y-%m') as month, sum(total_price) as total")
->groupBy('month')->pluck('total', 'month');
$financialChartData = [
'labels' => $months->map(fn ($m) => Carbon::parse($m)->translatedFormat('M Y'))->values(),
'revenue' => $months->map(fn ($m) => (float) (($monthlyOrderRev[$m] ?? 0) + ($monthlyDelayedRev[$m] ?? 0)))->values(),
'expenses' => $months->map(fn ($m) => (float) (($monthlyExpenses[$m] ?? 0) + ($monthlyPayroll[$m] ?? 0) + ($monthlyFeed[$m] ?? 0)))->values(),
];
$financialChartData['profit'] = $financialChartData['revenue']->map(fn ($rev, $i) => (float) ($rev - $financialChartData['expenses'][$i]))->values();
// 9. Distribution Charts
$expenseDistributionData = [
'labels' => ['Gaji Karyawan', 'Belanja Pakan', 'Pengeluaran Umum'],
'data' => [(float) $totalPayroll, (float) $totalFeedPurchase, (float) $totalExpenses],
];
$salesByUnit = Order::whereBetween('order_date', [$fromDate, $toDate])
->join('units', 'orders.unit_id', '=', 'units.id')
->selectRaw('units.name, sum(total_amount) as total')
->groupBy('units.name')
->get();
$debtStatusData = [
'paid' => (float) $paidAmountInRange,
'unpaid' => (float) ($delayedRevenue - $paidAmountInRange),
];
$feedStocks = Feed::query()
->join('units', 'feeds.unit_id', '=', 'units.id')
->select('feeds.name', 'feeds.stock', 'units.alias as unit_alias')
->get();
$productionByWarehouse = EggCollection::whereBetween('production_date', [$fromDate, $toDate])
->join('warehouses', 'egg_collections.warehouse_id', '=', 'warehouses.id')
->selectRaw('warehouses.name, sum(total_eggs) as total')
->groupBy('warehouses.name')
->get();
$revenueSplitData = [
'orders' => (float) $orderRevenue,
'delayed' => (float) $delayedRevenue,
];
return [
'mainStats' => $mainStats,
'totalEggsByUnit' => $totalEggsByUnit,
'totalDelayedByUnit' => $totalDelayedByUnit,
'totalDelayedByUnit' => $delayedStatsByUnit,
'topCustomers' => $topCustomers,
'productionChart' => $productionChartData,
'financialChart' => $financialChartData,
'expenseDistribution' => $expenseDistributionData,
'salesByUnit' => [
'labels' => $salesByUnit->pluck('name')->values(),
'data' => $salesByUnit->pluck('total')->map(fn ($v) => (float) $v)->values(),
],
'debtStatus' => $debtStatusData,
'feedStocks' => [
'labels' => $feedStocks->pluck('name')->values(),
'data' => $feedStocks->pluck('stock')->map(fn ($v) => (float) $v)->values(),
'units' => $feedStocks->pluck('unit_alias')->values(),
],
'productionByWarehouse' => [
'labels' => $productionByWarehouse->pluck('name')->values(),
'data' => $productionByWarehouse->pluck('total')->map(fn ($v) => (float) $v)->values(),
],
'revenueSplit' => [
'labels' => ['Penjualan Langsung', 'Barang Tertunda'],
'data' => [$revenueSplitData['orders'], $revenueSplitData['delayed']],
],
'formatNumber' => $formatNumber,
];
}
}

View File

@ -0,0 +1,144 @@
<?php
namespace App\Filament\Pages;
use App\Enums\RoleEnum;
use App\Models\Customer;
use App\Models\DelayedGood;
use App\Models\EggCollectionItem;
use App\Models\Expense;
use App\Models\FeedPurchase;
use App\Models\Order;
use App\Models\Payroll;
use Filament\Pages\Dashboard as BaseDashboard;
use Filament\Widgets\AccountWidget;
class Dashboard extends BaseDashboard
{
protected static ?string $title = 'Dasbor';
protected string $view = 'filament.pages.dashboard';
public function getHeaderWidgets(): array
{
return [
AccountWidget::class,
];
}
public function getViewData(): array
{
$formatNumber = fn ($val) => number_format((float) $val, $val == floor($val) ? 0 : 2, ',', '.');
$now = now();
$today = $now->format('Y-m-d');
// 1. Egg Production by Unit - Single optimized query with joins
$totalEggsByUnit = EggCollectionItem::query()
->join('egg_collections', 'egg_collection_items.egg_collection_id', 'egg_collections.id')
->join('units', 'egg_collection_items.unit_id', 'units.id')
->whereNull('egg_collections.deleted_at')
->where('egg_collections.production_date', $today)
->selectRaw('units.name as unit_name,
sum(case when is_broken = 0 then quantity else 0 end) as total_good,
sum(case when is_broken = 1 then quantity else 0 end) as total_broken')
->groupBy('units.name')
->get()
->map(fn ($item) => [
'unit_name' => $item->unit_name,
'total_good' => $formatNumber($item->total_good),
'total_broken' => $formatNumber($item->total_broken),
]);
// 2. Orders Stats - Combined count and sum
$orderStats = Order::whereDate('order_date', $today)
->selectRaw('count(*) as count, sum(total_amount) as total')
->first();
$orderCount = (int) ($orderStats->count ?? 0);
$orderRevenue = (float) ($orderStats->total ?? 0);
// 3. Delayed Goods Stats - Combined sum and paid_amount
$delayedStatsByUnit = DelayedGood::query()
->join('units', 'delayed_goods.unit_id', 'units.id')
->whereDate('delayed_goods.stored_date', $today)
->selectRaw('units.name as unit_name, sum(quantity) as total')
->groupBy('units.name')
->get()
->map(fn ($item) => [
'unit_name' => $item->unit_name,
'total' => $formatNumber($item->total),
]);
$delayedStatsTotals = DelayedGood::whereDate('stored_date', $today)
->selectRaw('sum(total_amount) as total, sum(paid_amount) as paid')
->first();
$delayedRevenue = (float) ($delayedStatsTotals->total ?? 0);
$paidToday = (float) ($delayedStatsTotals->paid ?? 0);
$totalRevenue = $orderRevenue + $delayedRevenue;
// 4. Expenses & Financials Today
$totalExpenses = (float) Expense::whereDate('expense_date', $today)->sum('amount');
$monthlyPayroll = (float) Payroll::where('period_month', $now->format('Y-m'))->sum('total_salary');
$totalFeedPurchase = (float) FeedPurchase::whereDate('purchase_date', $today)->sum('total_price');
$grossProfit = $totalRevenue - $totalFeedPurchase;
$netProfit = $grossProfit - $totalExpenses - $monthlyPayroll;
$totalReceivablesToday = $delayedRevenue - $paidToday;
$mainStats = [
['label' => 'Pesanan', 'desc' => 'Jumlah transaksi', 'value' => $orderCount, 'prefix' => ''],
['label' => 'Laba Bersih', 'desc' => 'Setelah dikurangi pengeluaran harian', 'value' => $formatNumber($netProfit), 'prefix' => 'Rp'],
['label' => 'Piutang', 'desc' => 'Piutang barang tertunda', 'value' => $formatNumber($totalReceivablesToday), 'prefix' => 'Rp'],
['label' => 'Pendapatan', 'desc' => 'Akumulasi pemasukan', 'value' => $formatNumber($totalRevenue), 'prefix' => 'Rp'],
['label' => 'Belanja Pakan', 'desc' => 'Baru dibeli', 'value' => $formatNumber($totalFeedPurchase), 'prefix' => 'Rp'],
];
// 5. Top Customers Today - Optimized summing
$topCustomers = Customer::query()
->select('id', 'name', 'phone_number')
->withSum(['orders' => fn ($q) => $q->whereDate('order_date', $today)], 'total_amount')
->withSum(['delayedGoods' => fn ($q) => $q->whereDate('stored_date', $today)], 'total_amount')
->get()
->map(function ($customer) {
$customer->total_spent = ($customer->orders_sum_total_amount ?? 0) + ($customer->delayed_goods_sum_total_amount ?? 0);
return $customer;
})
->filter(fn ($c) => $c->total_spent > 0)
->sortByDesc('total_spent')
->take(5)
->values();
// 6. Distribution Charts Data
$revenueSplitData = [
'labels' => ['Penjualan Langsung', 'Barang Tertunda'],
'data' => [$orderRevenue, $delayedRevenue],
];
$expenseDistributionData = [
'labels' => ['Belanja Pakan', 'Pengeluaran Umum'],
'data' => [$totalFeedPurchase, $totalExpenses],
];
$salesByUnit = Order::whereDate('order_date', $today)
->join('units', 'orders.unit_id', 'units.id')
->selectRaw('units.name, sum(total_amount) as total')
->groupBy('units.name')
->get();
return [
'isAdministrator' => auth()->user()->hasRole(RoleEnum::ADMINISTRATOR->value),
'mainStats' => $mainStats,
'totalEggsByUnit' => $totalEggsByUnit,
'totalDelayedByUnit' => $delayedStatsByUnit,
'topCustomers' => $topCustomers,
'revenueSplit' => $revenueSplitData,
'expenseDistribution' => $expenseDistributionData,
'salesByUnit' => [
'labels' => $salesByUnit->pluck('name')->values(),
'data' => $salesByUnit->pluck('total')->map(fn ($v) => (float) $v)->values(),
],
'formatNumber' => $formatNumber,
];
}
}

View File

@ -89,8 +89,7 @@ public static function configure(Schema $schema): Schema
->relationship('customer', 'name')
->searchable()
->preload()
->native(false)
->required(),
->native(false),
])
->columnSpanFull(),

View File

@ -9,6 +9,14 @@ trait HasCart
{
public array $cart = [];
public bool $isQtyModalOpen = false;
public ?int $modalFeedId = null;
public ?int $modalQty = null;
public ?string $modalFeedName = null;
public function loadCartFromDb(): void
{
$id = isset($this->record) ? $this->record->id : null;
@ -20,6 +28,35 @@ public function loadCartFromDb(): void
->toArray();
}
public function openQtyModal(int $feedId): void
{
$feed = Feed::find($feedId);
if (! $feed) {
return;
}
$this->modalFeedId = $feedId;
$this->modalFeedName = $feed->name;
$this->modalQty = $this->cart[$feedId]['qty'] ?? 0;
$this->isQtyModalOpen = true;
$this->dispatch('open-modal', id: 'qty-modal');
}
public function saveQtyModal($qty = null): void
{
if ($qty !== null) {
$this->modalQty = $qty;
}
if ($this->modalFeedId) {
$this->updateQty($this->modalFeedId, (int) $this->modalQty);
}
$this->isQtyModalOpen = false;
$this->dispatch('close-modal', id: 'qty-modal');
}
public function addFeed(int $feedId): void
{
$feed = Feed::find($feedId);
@ -81,6 +118,60 @@ public function decreaseQty(int $feedId): void
}
}
public function updateQty(int $feedId, $qty): void
{
$qty = (int) $qty;
$feed = Feed::find($feedId);
if (! $feed) {
return;
}
$id = isset($this->record) ? $this->record->id : null;
$item = FeedPurchaseItem::where('feed_purchase_id', $id)
->where('feed_id', $feedId)
->first();
if ($qty <= 0) {
if ($item) {
// Revert stock if editing
if ($id) {
$item->feed?->decrement('stock', $item->quantity);
}
$item->delete();
}
} else {
if ($item) {
$oldQty = $item->quantity;
$item->update([
'quantity' => $qty,
'subtotal' => $qty * $item->unit_price,
]);
// Update stock if editing
if ($id) {
$feed->increment('stock', $qty - $oldQty);
}
} else {
FeedPurchaseItem::create([
'feed_purchase_id' => $id,
'feed_id' => $feedId,
'quantity' => $qty,
'unit_price' => $feed->price,
'subtotal' => $qty * $feed->price,
]);
// Update stock if editing
if ($id) {
$feed->increment('stock', $qty);
}
}
}
$this->loadCartFromDb();
$this->dispatch('cart-updated');
}
public function removeCartItem(int $itemId): void
{
$id = isset($this->record) ? $this->record->id : null;

View File

@ -1,43 +0,0 @@
<?php
namespace App\Filament\Widgets;
use App\Models\EggCollection;
use BezhanSalleh\FilamentShield\Traits\HasWidgetShield;
use Filament\Widgets\ChartWidget;
use Illuminate\Support\Carbon;
class EggProductionChart extends ChartWidget
{
use HasWidgetShield;
protected ?string $heading = 'Trend Produksi Telur (30 Hari Terakhir)';
protected static ?int $sort = 2;
protected function getData(): array
{
$data = EggCollection::where('production_date', '>=', now()->subDays(30))
->orderBy('production_date')
->get()
->groupBy(fn ($item) => Carbon::parse($item->production_date)->translatedFormat('Y-m-d'))
->map(fn ($items) => $items->sum('total_eggs'));
return [
'datasets' => [
[
'label' => 'Total Telur',
'data' => $data->values()->toArray(),
'fill' => 'start',
'tension' => 0.4,
],
],
'labels' => $data->keys()->map(fn ($date) => Carbon::parse($date)->translatedFormat('d M'))->toArray(),
];
}
protected function getType(): string
{
return 'line';
}
}

View File

@ -1,43 +0,0 @@
<?php
namespace App\Filament\Widgets;
use App\Models\Order;
use BezhanSalleh\FilamentShield\Traits\HasWidgetShield;
use Filament\Widgets\ChartWidget;
use Illuminate\Support\Carbon;
class OrderTrendChart extends ChartWidget
{
use HasWidgetShield;
protected ?string $heading = 'Trend Jumlah Pesanan (30 Hari Terakhir)';
protected static ?int $sort = 4;
protected function getData(): array
{
$data = Order::where('order_date', '>=', now()->subDays(30))
->orderBy('order_date')
->get()
->groupBy(fn ($item) => Carbon::parse($item->order_date)->translatedFormat('Y-m-d'))
->map(fn ($items) => $items->count());
return [
'datasets' => [
[
'label' => 'Jumlah Pesanan',
'data' => $data->values()->toArray(),
'fill' => 'start',
'tension' => 0.4,
],
],
'labels' => $data->keys()->map(fn ($date) => Carbon::parse($date)->translatedFormat('d M'))->toArray(),
];
}
protected function getType(): string
{
return 'line';
}
}

View File

@ -1,80 +0,0 @@
<?php
namespace App\Filament\Widgets;
use App\Models\Expense;
use App\Models\FeedPurchase;
use App\Models\Order;
use BezhanSalleh\FilamentShield\Traits\HasWidgetShield;
use Filament\Widgets\ChartWidget;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
class RevenueExpenseChart extends ChartWidget
{
use HasWidgetShield;
protected ?string $heading = 'Pendapatan vs Pengeluaran (6 Bulan Terakhir)';
protected static ?int $sort = 3;
protected function getData(): array
{
$months = collect(range(5, 0))->reverse()->map(function ($i) {
return now()->subMonths($i)->format('Y-m');
});
$revenues = Order::select(
DB::raw("DATE_FORMAT(order_date, '%Y-%m') as month"),
DB::raw('SUM(total_amount) as total')
)
->where('order_date', '>=', now()->subMonths(5)->startOfMonth())
->groupBy('month')
->get()
->pluck('total', 'month');
$expenses = Expense::select(
DB::raw("DATE_FORMAT(expense_date, '%Y-%m') as month"),
DB::raw('SUM(amount) as total')
)
->where('expense_date', '>=', now()->subMonths(5)->startOfMonth())
->groupBy('month')
->get()
->pluck('total', 'month');
$feedPurchases = FeedPurchase::select(
DB::raw("DATE_FORMAT(purchase_date, '%Y-%m') as month"),
DB::raw('SUM(total_price) as total')
)
->where('purchase_date', '>=', now()->subMonths(5)->startOfMonth())
->groupBy('month')
->get()
->pluck('total', 'month');
$revenueData = $months->map(fn ($month) => (int) $revenues->get($month, 0));
$expenseData = $months->map(fn ($month) => (int) ($expenses->get($month, 0) + $feedPurchases->get($month, 0)));
return [
'datasets' => [
[
'label' => 'Pendapatan (Order)',
'data' => $revenueData->values()->toArray(),
'backgroundColor' => '#10b981',
'borderColor' => '#10b981',
],
[
'label' => 'Pengeluaran (Operasional + Pakan)',
'data' => $expenseData->values()->toArray(),
'backgroundColor' => '#ef4444',
'borderColor' => '#ef4444',
],
],
'labels' => $months->values()->map(fn ($month) => Carbon::parse($month)->translatedFormat('M Y'))->toArray(),
];
}
protected function getType(): string
{
return 'bar';
}
}

View File

@ -1,75 +0,0 @@
<?php
namespace App\Filament\Widgets;
use App\Models\Customer;
use App\Models\DelayedGood;
use App\Models\EggCollection;
use App\Models\Expense;
use App\Models\FeedPurchase;
use App\Models\Order;
use BezhanSalleh\FilamentShield\Traits\HasWidgetShield;
use Filament\Widgets\StatsOverviewWidget as BaseWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;
class StatsOverview extends BaseWidget
{
use HasWidgetShield;
protected static ?int $sort = 1;
protected function getStats(): array
{
return [
Stat::make('Produksi Telur', number_format(EggCollection::sum('total_eggs'), 0, ',', '.'))
->description('Total akumulasi produksi telur (berbagai satuan).')
->descriptionIcon('heroicon-m-circle-stack')
->color('warning'),
Stat::make('Order', number_format(Order::count(), 0, ',', '.').' Pesanan')
->description('Jumlah pesanan yang dibuat.')
->descriptionIcon('heroicon-m-shopping-cart')
->color('info'),
Stat::make('Pendapatan', 'Rp '.number_format(Order::sum('total_amount'), 0, ',', '.'))
->description('Total pendapatan dari pesanan.')
->descriptionIcon('heroicon-m-banknotes')
->color('success'),
Stat::make('Total Pelanggan', number_format(Customer::count(), 0, ',', '.').' Orang')
->description('Total pelanggan terdaftar.')
->descriptionIcon('heroicon-m-users')
->color('primary'),
Stat::make('Total Pengeluaran', 'Rp '.number_format(Expense::sum('amount'), 0, ',', '.'))
->description('Total akumulasi biaya pengeluaran.')
->descriptionIcon('heroicon-m-credit-card')
->color('danger'),
Stat::make('Barang Tertunda', number_format(DelayedGood::count(), 0, ',', '.').' Item')
->description('Jumlah barang yang masih tertunda.')
->descriptionIcon('heroicon-m-clock')
->color('warning'),
Stat::make('Total Belanja Pakan', 'Rp '.number_format(FeedPurchase::sum('total_price'), 0, ',', '.'))
->description('Total pengeluaran untuk pakan.')
->descriptionIcon('heroicon-m-shopping-bag')
->color('info'),
Stat::make('Laba Kotor', 'Rp '.number_format(Order::sum('total_amount') - (Expense::sum('amount') + FeedPurchase::sum('total_price')), 0, ',', '.'))
->description('Pendapatan - (Pengeluaran + Pakan).')
->descriptionIcon('heroicon-m-presentation-chart-line')
->color('success'),
Stat::make('Piutang Belum Terbayar', 'Rp '.number_format(DelayedGood::where('is_paid', false)->sum('total_amount') - DelayedGood::where('is_paid', false)->sum('paid_amount'), 0, ',', '.'))
->description('Total piutang dari barang tertunda.')
->descriptionIcon('heroicon-m-receipt-percent')
->color('danger'),
Stat::make('Total Biaya Operasional', 'Rp '.number_format(Expense::sum('amount') + FeedPurchase::sum('total_price'), 0, ',', '.'))
->description('Total beban biaya saat ini.')
->descriptionIcon('heroicon-m-calculator')
->color('warning'),
];
}
}

View File

@ -1,56 +0,0 @@
<?php
namespace App\Filament\Widgets;
use App\Models\Order;
use BezhanSalleh\FilamentShield\Traits\HasWidgetShield;
use Filament\Widgets\ChartWidget;
use Illuminate\Support\Facades\DB;
class TopCustomersChart extends ChartWidget
{
use HasWidgetShield;
protected ?string $heading = 'Top 5 Pelanggan (Berdasarkan Total Order)';
protected static ?int $sort = 6;
protected function getData(): array
{
$data = Order::with('customer')
->select('customer_id', DB::raw('SUM(total_amount) as total_spent'))
->groupBy('customer_id')
->orderByDesc('total_spent')
->limit(5)
->get();
return [
'datasets' => [
[
'label' => 'Total Pembelian (Rp)',
'data' => $data->pluck('total_spent')->toArray(),
'backgroundColor' => [
'#fbbf24',
'#f59e0b',
'#d97706',
'#b45309',
'#92400e',
],
],
],
'labels' => $data->map(fn ($item) => $item->customer?->name ?? 'Umum')->toArray(),
];
}
protected function getType(): string
{
return 'bar';
}
protected function getOptions(): array
{
return [
'indexAxis' => 'y',
];
}
}

View File

@ -4,6 +4,7 @@
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class Customer extends Model
@ -11,4 +12,14 @@ class Customer extends Model
use HasFactory, SoftDeletes;
protected $guarded = ['id'];
public function orders(): HasMany
{
return $this->hasMany(Order::class);
}
public function delayedGoods(): HasMany
{
return $this->hasMany(DelayedGood::class);
}
}

View File

@ -5,6 +5,7 @@
use AchyutN\FilamentLogViewer\FilamentLogViewer;
use App\Enums\RoleEnum;
use App\Filament\Pages\Auth\Login;
use App\Filament\Pages\Dashboard;
use App\Filament\Pages\Profile;
use BezhanSalleh\FilamentShield\FilamentShieldPlugin;
use DiogoGPinto\AuthUIEnhancer\AuthUIEnhancerPlugin;
@ -12,7 +13,6 @@
use Filament\Http\Middleware\AuthenticateSession;
use Filament\Http\Middleware\DisableBladeIconComponents;
use Filament\Http\Middleware\DispatchServingFilamentEvent;
use Filament\Pages\Dashboard;
use Filament\Panel;
use Filament\PanelProvider;
use Filament\Support\Colors\Color;
@ -133,6 +133,7 @@ public function panel(Panel $panel): Panel
->breadcrumbs(false)
->globalSearch(false)
->defaultAvatarProvider(FacehashProvider::class)
->maxContentWidth(Width::Full);
->maxContentWidth(Width::Full)
->spa();
}
}

View File

@ -18,16 +18,26 @@ class DelayedGoodFactory extends Factory
*/
public function definition(): array
{
$quantity = fake()->numberBetween(1, 100);
$unitPrice = fake()->numberBetween(5000, 20000);
$unit = Unit::whereIn('alias', ['butir', 'kg'])->inRandomOrder()->first()
?? Unit::where('alias', 'butir')->first()
?? Unit::factory()->create();
if ($unit->alias === 'butir') {
$quantity = fake()->numberBetween(10, 100);
$unitPrice = fake()->numberBetween(1500, 2500);
} else {
$quantity = fake()->randomFloat(2, 1, 15);
$unitPrice = fake()->numberBetween(25000, 32000);
}
$totalAmount = $quantity * $unitPrice;
$isPaid = fake()->boolean();
$paidAmount = $isPaid ? $totalAmount : fake()->numberBetween(0, $totalAmount);
$storedDate = fake()->dateTimeBetween('-1 month', 'now');
return [
'customer_id' => Customer::factory(),
'unit_id' => Unit::factory(),
'customer_id' => Customer::inRandomOrder()->first()?->id ?? Customer::factory(),
'unit_id' => $unit->id,
'quantity' => $quantity,
'unit_price' => $unitPrice,
'total_amount' => $totalAmount,

View File

@ -18,8 +18,18 @@ public function definition(): array
{
return [
'expense_date' => fake()->dateTimeBetween('-1 month', 'now'),
'amount' => fake()->numberBetween(10000, 5000000),
'notes' => fake()->sentence(),
'amount' => fake()->numberBetween(50000, 2000000),
'notes' => fake()->randomElement([
'Listrik Kandang',
'Gaji Pegawai',
'Beli Vaksin/Obat',
'Beli Vitamin',
'Perbaikan Kandang',
'Bahan Bakar Genset',
'Operasional Kantor',
'Sewa Kendaraan',
'Beli ATK',
]),
];
}
}

View File

@ -4,7 +4,6 @@
use App\Models\Feed;
use App\Models\FeedDelivery;
use App\Models\FeedSchedule;
use Illuminate\Database\Eloquent\Factories\Factory;
class FeedDeliveryFactory extends Factory
@ -14,8 +13,7 @@ class FeedDeliveryFactory extends Factory
public function definition(): array
{
return [
'feed_schedule_id' => FeedSchedule::factory(),
'feed_id' => Feed::factory(),
'feed_id' => Feed::inRandomOrder()->first()?->id ?? Feed::factory(),
'quantity' => $this->faker->numberBetween(1, 50),
'delivered_at' => $this->faker->dateTimeBetween('-1 week', 'now'),
];

View File

@ -17,11 +17,13 @@ public function definition(): array
{
return [
'name' => $this->faker->randomElement([
'Pakan Starter',
'Pakan Grower',
'Pakan Layer',
'Konsentrat Ayam',
'Pelet',
'Jagung Giling',
'Jagung Utuh',
'Konsentrat Layer',
'Bekatul/Dedak',
'Mineral Mix',
'Pakan Finisher',
]),
'unit_id' => Unit::inRandomOrder()->value('id') ?? Unit::factory(),
'stock' => $this->faker->numberBetween(0, 500),

View File

@ -2,7 +2,6 @@
namespace Database\Factories;
use App\Models\Feed;
use App\Models\FeedSchedule;
use Illuminate\Database\Eloquent\Factories\Factory;
@ -13,10 +12,7 @@ class FeedScheduleFactory extends Factory
public function definition(): array
{
return [
'feed_id' => Feed::factory(),
'quantity' => $this->faker->numberBetween(1, 50),
'scheduled_at' => $this->faker->dateTimeBetween('-1 week', '+1 week'),
'executed_at' => null,
'scheduled_at' => $this->faker->time('H:i'),
];
}
}

View File

@ -5,6 +5,7 @@
use App\Models\Customer;
use App\Models\Order;
use App\Models\Unit;
use App\Models\Warehouse;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
@ -23,19 +24,28 @@ public function definition(): array
{
$date = $this->faker->dateTimeBetween('-3 months', 'now');
$eggPrice = $this->faker->numberBetween(28000, 35000);
$unit = Unit::whereIn('alias', ['butir', 'kg'])->inRandomOrder()->first()
?? Unit::where('alias', 'butir')->first()
?? Unit::factory()->create();
$customer = Customer::inRandomOrder()->first() ?? Customer::factory()->create();
$eggQuantity = $this->faker->randomFloat(2, 10, 500);
$unitPrice = $eggPrice / 10;
$discount = $this->faker->optional(0.3)->numberBetween(5000, 50000) ?? 0;
if ($unit->alias === 'butir') {
$eggQuantity = $this->faker->numberBetween(30, 300);
$unitPrice = $this->faker->numberBetween(1500, 2500); // Price per egg
} else {
$eggQuantity = $this->faker->randomFloat(2, 5, 50);
$unitPrice = $this->faker->numberBetween(25000, 32000); // Price per kg
}
$discount = $this->faker->optional(0.3)->numberBetween(5000, 25000) ?? 0;
$subtotal = $eggQuantity * $unitPrice;
$totalAmount = max(0, $subtotal - $discount);
return [
'customer_id' => $customer->id,
'unit_id' => Unit::inRandomOrder()->first()->id ?? Unit::factory()->create()->id,
'unit_id' => $unit->id,
'warehouse_id' => Warehouse::inRandomOrder()->first()?->id ?? Warehouse::factory(),
'egg_quantity' => $eggQuantity,
'unit_price' => $unitPrice,
'order_date' => $date,

View File

@ -21,6 +21,8 @@ public function run(): void
UnitSeeder::class,
WarehouseSeeder::class,
FeedSeeder::class,
FeedScheduleSeeder::class,
FeedDeliverySeeder::class,
FeedPurchaseSeeder::class,
EggCollectionSeeder::class,
CustomerSeeder::class,

View File

@ -0,0 +1,17 @@
<?php
namespace Database\Seeders;
use App\Models\FeedDelivery;
use Illuminate\Database\Seeder;
class FeedDeliverySeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
FeedDelivery::factory()->count(30)->create();
}
}

View File

@ -0,0 +1,42 @@
<?php
namespace Database\Seeders;
use App\Models\Feed;
use App\Models\FeedSchedule;
use Illuminate\Database\Seeder;
class FeedScheduleSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
$times = ['07:00', '12:00', '16:00'];
$feeds = Feed::all();
if ($feeds->isEmpty()) {
$this->call(FeedSeeder::class);
$feeds = Feed::all();
}
foreach ($times as $time) {
$schedule = FeedSchedule::firstOrCreate([
'scheduled_at' => $time,
]);
// Attach 1-2 random feeds to each schedule
$selectedFeeds = $feeds->random(rand(1, 2));
foreach ($selectedFeeds as $feed) {
// Check if already attached to avoid duplicates if re-run
if (! $schedule->feeds()->where('feed_id', $feed->id)->exists()) {
$schedule->feeds()->attach($feed->id, [
'quantity' => rand(10, 50),
]);
}
}
}
}
}

View File

@ -3,6 +3,7 @@
namespace Database\Seeders;
use App\Models\Feed;
use App\Models\Unit;
use Illuminate\Database\Seeder;
class FeedSeeder extends Seeder
@ -12,6 +13,25 @@ class FeedSeeder extends Seeder
*/
public function run(): void
{
Feed::factory()->count(20)->create();
$unitId = Unit::where('alias', 'kg')->first()?->id;
$feeds = [
['name' => 'Pelet', 'price' => 7500],
['name' => 'Jagung', 'price' => 6000],
['name' => 'Konsentrat', 'price' => 12000],
['name' => 'Bekatul', 'price' => 3500],
['name' => 'Mineral Mix', 'price' => 15000],
];
foreach ($feeds as $feed) {
Feed::firstOrCreate(
['name' => $feed['name']],
[
'unit_id' => $unitId,
'price' => $feed['price'],
'stock' => 0,
]
);
}
}
}

View File

@ -22,11 +22,7 @@ public function run(): void
"name": "Developer",
"guard_name": "web",
"permissions" : [
"View:StatsOverview",
"View:EggProductionChart",
"View:OrderTrendChart",
"View:RevenueExpenseChart",
"View:TopCustomersChart",
"View:Analisys",
"ViewAny:Customer",
"View:Customer",
@ -189,11 +185,7 @@ public function run(): void
"name": "Pemilik",
"guard_name": "web",
"permissions" : [
"View:StatsOverview",
"View:EggProductionChart",
"View:OrderTrendChart",
"View:RevenueExpenseChart",
"View:TopCustomersChart",
"View:Analisys",
"ViewAny:Customer",
"View:Customer",

View File

@ -1,5 +1,9 @@
<x-filament-panels::page>
<div class="space-y-6">
<form wire:submit="getViewData">
{{ $this->form }}
</form>
<div class="space-y-6 pb-12">
{{-- Main Stats Grid --}}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
@foreach ($mainStats as $stat)
@ -11,32 +15,172 @@ class="text-xs font-bold text-gray-400 dark:text-gray-500 uppercase tracking-tig
<div class="flex items-baseline gap-1">
@if ($stat['prefix'])
<span
class="text-sm font-semibold text-gray-400 dark:text-gray-500">{{ $stat['prefix'] }}</span>
class="text-xs font-semibold text-gray-400 dark:text-gray-500">{{ $stat['prefix'] }}</span>
@endif
<span class="text-2xl font-bold text-gray-900 dark:text-white">{{ $stat['value'] }}</span>
</div>
<span class="text-xs text-gray-400 dark:text-gray-600">{{ $stat['desc'] }}</span>
<span
class="text-[10px] text-gray-900 dark:text-gray-600 italic tracking-wide uppercase">{{ $stat['desc'] }}</span>
</div>
</div>
@endforeach
</div>
{{-- First Row: Production Chart & Expense Distribution --}}
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div
class="lg:col-span-2 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg shadow-sm p-6">
<h3 class="text-sm font-bold text-gray-700 dark:text-gray-300 mb-6 flex items-center gap-2">
<x-heroicon-o-chart-bar class="w-4 h-4 text-primary-500" />
Tren Produksi Telur (7-14 Hari Terakhir)
</h3>
<div class="h-64">
<canvas id="productionChart"></canvas>
</div>
</div>
<div class="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg shadow-sm p-6">
<h3 class="text-sm font-bold text-gray-700 dark:text-gray-300 mb-6 flex items-center gap-2">
<x-heroicon-o-chart-pie class="w-4 h-4 text-primary-500" />
Alokasi Pengeluaran
</h3>
<div class="h-64 flex flex-col items-center">
<canvas id="expenseChart"></canvas>
</div>
</div>
</div>
{{-- Second Row: Financial Trend & Top Customers --}}
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div
class="lg:col-span-2 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg shadow-sm p-6">
<h3 class="text-sm font-bold text-gray-700 dark:text-gray-300 mb-6 flex items-center gap-2">
<x-heroicon-o-presentation-chart-line class="w-4 h-4 text-primary-500" />
Performa Keuangan (6 Bulan Terakhir)
</h3>
<div class="h-64">
<canvas id="financialChart"></canvas>
</div>
</div>
<div
class="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg shadow-sm overflow-hidden flex flex-col">
<div
class="px-5 py-3 border-b border-gray-100 dark:border-gray-800 bg-gray-50/50 dark:bg-gray-800/50 flex items-center gap-2">
<x-heroicon-o-star class="w-4 h-4 text-amber-500" />
<h3 class="text-sm font-bold text-gray-700 dark:text-gray-300">Top 5 Pelanggan</h3>
</div>
<div class="flex-1 divide-y divide-gray-100 dark:divide-gray-800">
@foreach ($topCustomers as $customer)
<div
class="px-5 py-4 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-800/40 transition">
<div class="flex flex-col">
<span
class="text-sm font-bold text-gray-800 dark:text-gray-200">{{ $customer->name }}</span>
<span
class="text-xs text-gray-400 tracking-tight">{{ $customer->phone_number ?? 'Telp tidak ada' }}</span>
</div>
<div class="text-right">
<span
class="text-xs font-semibold text-gray-400 uppercase block leading-none mb-1">Total
Belanja</span>
<span class="text-sm font-black text-primary-600">Rp
{{ $formatNumber($customer->total_spent) }}</span>
</div>
</div>
@endforeach
</div>
</div>
</div>
{{-- Third Row: Sales by Unit & Payment Status --}}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 pt-6 border-t border-gray-100 dark:border-gray-800">
<div class="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg shadow-sm p-6">
<h3 class="text-sm font-bold text-gray-700 dark:text-gray-300 mb-6 flex items-center gap-2">
<x-heroicon-o-shopping-bag class="w-4 h-4 text-primary-500" />
Penjualan Berdasarkan Satuan (Order)
</h3>
<div class="h-64 flex flex-col items-center">
<canvas id="salesUnitChart"></canvas>
</div>
</div>
<div class="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg shadow-sm p-6">
<h3 class="text-sm font-bold text-gray-700 dark:text-gray-300 mb-6 flex items-center gap-2">
<x-heroicon-o-shield-check class="w-4 h-4 text-primary-500" />
Status Pelunasan (Barang Tertunda)
</h3>
<div class="h-64 flex flex-col items-center">
<canvas id="debtStatusChart"></canvas>
</div>
</div>
</div>
{{-- Fourth Row: Feed Stocks --}}
<div
class="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg shadow-sm p-6 mt-6">
<h3 class="text-sm font-bold text-gray-700 dark:text-gray-300 mb-6 flex items-center gap-2">
<x-heroicon-o-circle-stack class="w-4 h-4 text-primary-500" />
Level Stok Pakan Saat Ini
</h3>
<div class="h-80">
<canvas id="feedStockChart"></canvas>
</div>
</div>
{{-- Fifth Row: Profit Trend & Warehouse Productivity --}}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 pt-6 border-t border-gray-100 dark:border-gray-800">
<div class="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg shadow-sm p-6">
<h3 class="text-sm font-bold text-gray-700 dark:text-gray-300 mb-6 flex items-center gap-2">
<x-heroicon-o-arrow-trending-up class="w-4 h-4 text-emerald-500" />
Tren Laba Bersih (6 Bulan Terakhir)
</h3>
<div class="h-64">
<canvas id="profitTrendChart"></canvas>
</div>
</div>
<div class="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg shadow-sm p-6">
<h3 class="text-sm font-bold text-gray-700 dark:text-gray-300 mb-6 flex items-center gap-2">
<x-heroicon-o-home-modern class="w-4 h-4 text-primary-500" />
Produktivitas per Gudang
</h3>
<div class="h-64">
<canvas id="warehouseChart"></canvas>
</div>
</div>
</div>
{{-- Sixth Row: Revenue Split --}}
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 pt-6">
<div class="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg shadow-sm p-6">
<h3 class="text-sm font-bold text-gray-700 dark:text-gray-300 mb-6 flex items-center gap-2">
<x-heroicon-o-arrows-right-left class="w-4 h-4 text-primary-500" />
Sumber Pendapatan
</h3>
<div class="h-64 flex flex-col items-center">
<canvas id="revenueSplitChart"></canvas>
</div>
</div>
{{-- Placeholder/Extra space to balance --}}
<div class="lg:col-span-2"></div>
</div>
{{-- Units Breakdown --}}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
{{-- Production --}}
<div
class="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg shadow-sm overflow-hidden">
<div class="px-5 py-3 border-b border-gray-100 dark:border-gray-800 bg-gray-50/50 dark:bg-gray-800/50">
<h3 class="text-sm font-bold text-gray-700 dark:text-gray-300">Produksi Telur (By Unit)</h3>
<h3 class="text-sm font-bold text-gray-700 dark:text-gray-300">Total Stok (By Unit)</h3>
</div>
<div class="divide-y divide-gray-100 dark:divide-gray-800">
@forelse($totalEggsByUnit as $item)
<div
class="px-5 py-3 flex justify-between items-center transition hover:bg-gray-50 dark:hover:bg-gray-800/40">
<div class="flex flex-col">
<span
class="text-sm text-gray-600 dark:text-gray-400 font-medium">{{ $item['unit_name'] }}</span>
</div>
<span
class="text-sm text-gray-600 dark:text-gray-400 font-medium">{{ $item['unit_name'] }}</span>
<div class="flex gap-4">
<div class="text-right">
<span class="text-[10px] text-gray-400 font-bold uppercase block">Bagus</span>
@ -51,7 +195,7 @@ class="text-sm font-bold text-rose-600 dark:text-rose-400">{{ $item['total_broke
</div>
</div>
@empty
<div class="px-5 py-10 text-center text-gray-400 text-sm">Belum ada data produksi</div>
<div class="px-5 py-10 text-center text-gray-400 text-sm italic">Belum ada data produksi</div>
@endforelse
</div>
</div>
@ -71,13 +215,251 @@ class="text-sm text-gray-600 dark:text-gray-400 font-medium">{{ $item['unit_name
<span class="text-sm font-bold text-gray-900 dark:text-white">{{ $item['total'] }}</span>
</div>
@empty
<div class="px-5 py-10 text-center text-gray-400 text-sm">Tidak ada barang tertunda</div>
<div class="px-5 py-10 text-center text-gray-400 text-sm italic">Tidak ada barang tertunda</div>
@endforelse
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>
let chartInstances = {};
function destroyCharts() {
Object.values(chartInstances).forEach(chart => {
if (chart) chart.destroy();
});
chartInstances = {};
}
function initCharts() {
destroyCharts();
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'bottom',
labels: {
boxWidth: 10,
font: {
size: 10
}
}
}
}
};
// Production Chart
const ctxProduction = document.getElementById('productionChart');
if (ctxProduction) {
chartInstances.production = new Chart(ctxProduction, {
type: 'line',
data: {
labels: {!! json_encode($productionChart['labels']) !!},
datasets: [{
label: 'Lampu/Bagus',
data: {!! json_encode($productionChart['good']) !!},
borderColor: '#10b981',
backgroundColor: '#10b98122',
tension: 0.4,
fill: true
},
{
label: 'Pecah',
data: {!! json_encode($productionChart['broken']) !!},
borderColor: '#f43f5e',
backgroundColor: '#f43f5e22',
tension: 0.4,
fill: true
}
]
},
options: chartOptions
});
}
// Financial Chart
const ctxFinancial = document.getElementById('financialChart');
if (ctxFinancial) {
chartInstances.financial = new Chart(ctxFinancial, {
type: 'bar',
data: {
labels: {!! json_encode($financialChart['labels']) !!},
datasets: [{
label: 'Pendapatan',
data: {!! json_encode($financialChart['revenue']) !!},
backgroundColor: '#6366f1',
borderRadius: 4
},
{
label: 'Total Biaya',
data: {!! json_encode($financialChart['expenses']) !!},
backgroundColor: '#94a3b8',
borderRadius: 4
}
]
},
options: chartOptions
});
}
// Expense Category Chart
const ctxExpense = document.getElementById('expenseChart');
if (ctxExpense) {
chartInstances.expense = new Chart(ctxExpense, {
type: 'doughnut',
data: {
labels: {!! json_encode($expenseDistribution['labels']) !!},
datasets: [{
data: {!! json_encode($expenseDistribution['data']) !!},
backgroundColor: ['#a855f7', '#f97316', '#64748b'],
borderWidth: 0
}]
},
options: {
...chartOptions,
cutout: '70%'
}
});
}
// Sales by Unit Chart
const ctxSalesUnit = document.getElementById('salesUnitChart');
if (ctxSalesUnit) {
chartInstances.salesUnit = new Chart(ctxSalesUnit, {
type: 'pie',
data: {
labels: {!! json_encode($salesByUnit['labels']) !!},
datasets: [{
data: {!! json_encode($salesByUnit['data']) !!},
backgroundColor: ['#3b82f6', '#06b6d4', '#8b5cf6', '#ec4899'],
}]
},
options: chartOptions
});
}
// Debt Status Chart
const ctxDebtStatus = document.getElementById('debtStatusChart');
if (ctxDebtStatus) {
chartInstances.debtStatus = new Chart(ctxDebtStatus, {
type: 'doughnut',
data: {
labels: ['Sudah Bayar', 'Belum Bayar'],
datasets: [{
data: [{!! $debtStatus['paid'] !!}, {!! $debtStatus['unpaid'] !!}],
backgroundColor: ['#10b981', '#f43f5e'],
}]
},
options: {
...chartOptions,
cutout: '70%'
}
});
}
// Feed Stock Chart
const ctxFeedStock = document.getElementById('feedStockChart');
if (ctxFeedStock) {
chartInstances.feedStock = new Chart(ctxFeedStock, {
type: 'bar',
data: {
labels: {!! json_encode($feedStocks['labels']) !!},
datasets: [{
label: 'Stok Tersedia',
data: {!! json_encode($feedStocks['data']) !!},
backgroundColor: '#f59e0b',
borderRadius: 6
}]
},
options: {
...chartOptions,
indexAxis: 'y',
}
});
}
// Profit Trend Chart
const ctxProfitTrend = document.getElementById('profitTrendChart');
if (ctxProfitTrend) {
chartInstances.profitTrend = new Chart(ctxProfitTrend, {
type: 'line',
data: {
labels: {!! json_encode($financialChart['labels']) !!},
datasets: [{
label: 'Laba Bersih',
data: {!! json_encode($financialChart['profit']) !!},
borderColor: '#10b981',
backgroundColor: '#10b98122',
tension: 0.4,
fill: true
}]
},
options: chartOptions
});
}
// Warehouse Productivity Chart
const ctxWarehouse = document.getElementById('warehouseChart');
if (ctxWarehouse) {
chartInstances.warehouse = new Chart(ctxWarehouse, {
type: 'bar',
data: {
labels: {!! json_encode($productionByWarehouse['labels']) !!},
datasets: [{
label: 'Total Produksi',
data: {!! json_encode($productionByWarehouse['data']) !!},
backgroundColor: '#6366f1',
borderRadius: 4
}]
},
options: chartOptions
});
}
// Revenue Split Chart
const ctxRevenueSplit = document.getElementById('revenueSplitChart');
if (ctxRevenueSplit) {
chartInstances.revenueSplit = new Chart(ctxRevenueSplit, {
type: 'doughnut',
data: {
labels: {!! json_encode($revenueSplit['labels']) !!},
datasets: [{
data: {!! json_encode($revenueSplit['data']) !!},
backgroundColor: ['#6366f1', '#94a3b8'],
}]
},
options: {
...chartOptions,
cutout: '70%'
}
});
}
}
document.addEventListener('DOMContentLoaded', () => {
initCharts();
});
document.addEventListener('stats-updated', () => {
// Give Livewire time to update the DOM if necessary
setTimeout(() => {
initCharts();
}, 50);
});
// Also hook into Livewire re-renders as backup
document.addEventListener('livewire:initialized', () => {
Livewire.on('stats-updated', () => {
setTimeout(() => {
initCharts();
}, 50);
});
});
</script>
<style>
@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@200;300;400;500;600;700;800&display=swap');

View File

@ -0,0 +1,248 @@
<x-filament-panels::page>
@if (!$isAdministrator)
<div class="space-y-6 pb-12">
{{-- Main Stats Grid --}}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-4">
@foreach ($mainStats as $stat)
<div
class="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg shadow-sm p-4 transition hover:shadow-md border-b-2">
<div class="flex flex-col gap-1">
<span
class="text-[10px] font-bold text-gray-400 dark:text-gray-500 uppercase tracking-wider">{{ $stat['label'] }}</span>
<div class="flex items-baseline gap-1">
@if ($stat['prefix'])
<span
class="text-xs font-semibold text-gray-400 dark:text-gray-500">{{ $stat['prefix'] }}</span>
@endif
<span class="text-xl font-bold text-gray-900 dark:text-white">{{ $stat['value'] }}</span>
</div>
<span
class="text-[9px] text-gray-900 dark:text-gray-600 uppercase">{{ $stat['desc'] }}</span>
</div>
</div>
@endforeach
</div>
{{-- Distribution Row --}}
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div
class="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg shadow-sm p-6">
<h3 class="text-sm font-bold text-gray-700 dark:text-gray-300 mb-6 flex items-center gap-2">
<x-heroicon-o-arrows-right-left class="w-4 h-4 text-primary-500" />
Sumber Pemasukan
</h3>
<div class="h-64 flex flex-col items-center">
<canvas id="revenueSplitChart"></canvas>
</div>
</div>
<div
class="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg shadow-sm p-6">
<h3 class="text-sm font-bold text-gray-700 dark:text-gray-300 mb-6 flex items-center gap-2">
<x-heroicon-o-chart-pie class="w-4 h-4 text-primary-500" />
Alokasi Pengeluaran
</h3>
<div class="h-64 flex flex-col items-center">
<canvas id="expenseChart"></canvas>
</div>
</div>
<div
class="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg shadow-sm p-6">
<h3 class="text-sm font-bold text-gray-700 dark:text-gray-300 mb-6 flex items-center gap-2">
<x-heroicon-o-shopping-bag class="w-4 h-4 text-primary-500" />
Penjualan per Satuan
</h3>
<div class="h-64 flex flex-col items-center">
<canvas id="salesUnitChart"></canvas>
</div>
</div>
</div>
{{-- Units Breakdown --}}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
{{-- Production --}}
<div
class="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg shadow-sm overflow-hidden">
<div
class="px-5 py-3 border-b border-gray-100 dark:border-gray-800 bg-gray-50/50 dark:bg-gray-800/50 flex items-center justify-between">
<h3 class="text-sm font-bold text-gray-700 dark:text-gray-300">Produksi (Unit)</h3>
<span class="text-[10px] font-bold text-gray-400 uppercase">Hari Ini</span>
</div>
<div class="divide-y divide-gray-100 dark:divide-gray-800">
@forelse($totalEggsByUnit as $item)
<div
class="px-5 py-3 flex justify-between items-center transition hover:bg-gray-50 dark:hover:bg-gray-800/40">
<span
class="text-sm text-gray-600 dark:text-gray-400 font-medium">{{ $item['unit_name'] }}</span>
<div class="flex gap-4">
<div class="text-right">
<span class="text-[10px] text-gray-400 font-bold uppercase block">Bagus</span>
<span
class="text-sm font-bold text-emerald-600 dark:text-emerald-400">{{ $item['total_good'] }}</span>
</div>
<div class="text-right border-l border-gray-100 dark:border-gray-800 pl-4">
<span class="text-[10px] text-gray-400 font-bold uppercase block">Pecah</span>
<span
class="text-sm font-bold text-rose-600 dark:text-rose-400">{{ $item['total_broken'] }}</span>
</div>
</div>
</div>
@empty
<div class="px-5 py-10 text-center text-gray-400 text-sm italic">Belum ada data produksi
</div>
@endforelse
</div>
</div>
{{-- Delayed --}}
<div
class="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg shadow-sm overflow-hidden">
<div
class="px-5 py-3 border-b border-gray-100 dark:border-gray-800 bg-gray-50/50 dark:bg-gray-800/50 flex items-center justify-between">
<h3 class="text-sm font-bold text-gray-700 dark:text-gray-300">Tertunda (Unit)</h3>
<span class="text-[10px] font-bold text-gray-400 uppercase">Hari Ini</span>
</div>
<div class="divide-y divide-gray-100 dark:divide-gray-800">
@forelse($totalDelayedByUnit as $item)
<div
class="px-5 py-3 flex justify-between items-center transition hover:bg-gray-50 dark:hover:bg-gray-800/40">
<span
class="text-sm text-gray-600 dark:text-gray-400 font-medium">{{ $item['unit_name'] }}</span>
<span
class="text-sm font-bold text-gray-900 dark:text-white">{{ $item['total'] }}</span>
</div>
@empty
<div class="px-5 py-10 text-center text-gray-400 text-sm italic">Tidak ada barang tertunda
hari
ini</div>
@endforelse
</div>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
{{-- Top Customers Today --}}
<div
class="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg shadow-sm overflow-hidden flex flex-col">
<div
class="px-5 py-3 border-b border-gray-100 dark:border-gray-800 bg-gray-50/50 dark:bg-gray-800/50 flex items-center gap-2">
<x-heroicon-o-star class="w-4 h-4 text-amber-500" />
<h3 class="text-sm font-bold text-gray-700 dark:text-gray-300">Top Pelanggan Hari Ini</h3>
</div>
<div class="flex-1 divide-y divide-gray-100 dark:divide-gray-800">
@forelse ($topCustomers as $customer)
<div
class="px-5 py-4 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-800/40 transition">
<div class="flex flex-col">
<span
class="text-sm font-bold text-gray-800 dark:text-gray-200">{{ $customer->name }}</span>
<span
class="text-xs text-gray-400 tracking-tight">{{ $customer->phone_number ?? 'Telp tidak ada' }}</span>
</div>
<div class="text-right">
<span
class="text-[10px] font-semibold text-gray-400 uppercase block leading-none mb-1">Transaksi</span>
<span class="text-sm font-black text-primary-600">Rp
{{ $formatNumber($customer->total_spent) }}</span>
</div>
</div>
@empty
<div class="px-5 py-10 text-center text-gray-400 text-sm italic">Belum ada transaksi hari
ini
</div>
@endforelse
</div>
</div>
</div>
</div>
@endif
@if (!$isAdministrator)
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'bottom',
labels: {
boxWidth: 10,
font: {
size: 10
}
}
}
}
};
// Revenue Split Chart
const revenueSplitCtx = document.getElementById('revenueSplitChart');
if (revenueSplitCtx) {
new Chart(revenueSplitCtx, {
type: 'doughnut',
data: {
labels: {!! json_encode($revenueSplit['labels']) !!},
datasets: [{
data: {!! json_encode($revenueSplit['data']) !!},
backgroundColor: ['#6366f1', '#94a3b8'],
}]
},
options: {
...chartOptions,
cutout: '70%'
}
});
}
// Expense Category Chart
const expenseCtx = document.getElementById('expenseChart');
if (expenseCtx) {
new Chart(expenseCtx, {
type: 'doughnut',
data: {
labels: {!! json_encode($expenseDistribution['labels']) !!},
datasets: [{
data: {!! json_encode($expenseDistribution['data']) !!},
backgroundColor: ['#f59e0b', '#64748b'],
borderWidth: 0
}]
},
options: {
...chartOptions,
cutout: '70%'
}
});
}
// Sales by Unit Chart
const salesUnitCtx = document.getElementById('salesUnitChart');
if (salesUnitCtx) {
new Chart(salesUnitCtx, {
type: 'pie',
data: {
labels: {!! json_encode($salesByUnit['labels']) !!},
datasets: [{
data: {!! json_encode($salesByUnit['data']) !!},
backgroundColor: ['#3b82f6', '#06b6d4', '#8b5cf6', '#ec4899'],
}]
},
options: chartOptions
});
}
});
</script>
@endif
<style>
@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@200;300;400;500;600;700;800&display=swap');
* {
font-family: 'Plus Jakarta Sans', sans-serif;
}
</style>
</x-filament-panels::page>

View File

@ -29,7 +29,10 @@ class="w-8 h-8 rounded-full border border-gray-200 dark:border-gray-600 flex ite
-
</a>
<span class="text-sm font-bold w-6 text-center text-gray-900 dark:text-gray-100">
<span
wire:click="openQtyModal({{ $feed->id }})"
class="text-sm font-bold w-6 text-center text-gray-900 dark:text-gray-100 cursor-pointer hover:text-teal-500 transition-colors"
>
{{ $this->cart[$feed->id]['qty'] ?? 0 }}
</span>
@ -42,6 +45,36 @@ class="w-8 h-8 rounded-full flex items-center justify-center text-white text-lg
</div>
</div>
@endforeach
<x-filament::modal id="qty-modal" wire:model.live="isQtyModalOpen" width="sm">
<x-slot name="heading">
Atur Jumlah: {{ $this->modalFeedName }}
</x-slot>
<div x-data="{ localQty: 0 }" x-on:open-modal.window="if ($event.detail.id === 'qty-modal') { localQty = $wire.modalQty }">
<div class="py-2">
<x-filament::input.wrapper>
<x-filament::input
type="number"
x-model="localQty"
x-on:keydown.enter="$wire.saveQtyModal(localQty)"
placeholder="Masukan jumlah..."
autofocus
/>
</x-filament::input.wrapper>
</div>
<div class="flex justify-end gap-3 mt-4">
<x-filament::button color="gray" wire:click="$set('isQtyModalOpen', false)" size="sm">
Batal
</x-filament::button>
<x-filament::button x-on:click="$wire.saveQtyModal(localQty)" size="sm">
Simpan
</x-filament::button>
</div>
</div>
</x-filament::modal>
</div>