Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production
This commit is contained in:
commit
876c5301b3
172
Documentation/Laporan.md
Normal file
172
Documentation/Laporan.md
Normal file
@ -0,0 +1,172 @@
|
|||||||
|
# Dokumentasi Refactoring LaporanController
|
||||||
|
|
||||||
|
## 📋 Ringkasan Refactoring
|
||||||
|
|
||||||
|
File `LaporanController` yang awalnya berukuran **~600 baris** telah dipecah menjadi **6 file** yang lebih terorganisir dan mudah dipelihara:
|
||||||
|
|
||||||
|
1. **LaporanController** - Controller utama yang ramping
|
||||||
|
2. **LaporanService** - Business logic layer
|
||||||
|
3. **TransaksiRepository** - Data access layer
|
||||||
|
4. **LaporanHelper** - Utility functions
|
||||||
|
5. **DetailLaporanRequest** - Validation untuk detail laporan
|
||||||
|
6. **ExportLaporanRequest** - Validation untuk export
|
||||||
|
|
||||||
|
## 🏗️ Struktur Baru
|
||||||
|
|
||||||
|
### 1. LaporanController (~80 baris)
|
||||||
|
|
||||||
|
- **Tanggung jawab**: Menangani HTTP requests dan responses
|
||||||
|
- **Fitur**: Error handling, logging, delegasi ke service layer
|
||||||
|
- **Prinsip**: Single Responsibility - hanya menangani concerns HTTP
|
||||||
|
|
||||||
|
### 2. LaporanService (~180 baris)
|
||||||
|
|
||||||
|
- **Tanggung jawab**: Business logic dan orchestration
|
||||||
|
- **Fitur**:
|
||||||
|
- Caching logic
|
||||||
|
- Data processing coordination
|
||||||
|
- Export functionality
|
||||||
|
- Input validation bisnis
|
||||||
|
- **Prinsip**: Service layer yang mengkoordinasi antara repository dan helper
|
||||||
|
|
||||||
|
### 3. TransaksiRepository (~120 baris)
|
||||||
|
|
||||||
|
- **Tanggung jawab**: Data access dan query operations
|
||||||
|
- **Fitur**:
|
||||||
|
- Complex database queries
|
||||||
|
- Data aggregation
|
||||||
|
- Pagination logic untuk laporan
|
||||||
|
- **Prinsip**: Repository pattern untuk data abstraction
|
||||||
|
|
||||||
|
### 4. LaporanHelper (~180 baris)
|
||||||
|
|
||||||
|
- **Tanggung jawab**: Utility functions dan data formatting
|
||||||
|
- **Fitur**:
|
||||||
|
- Data formatting (currency, weight)
|
||||||
|
- Data mapping dan transformation
|
||||||
|
- Pagination info building
|
||||||
|
- Filter info building
|
||||||
|
- **Prinsip**: Helper class untuk fungsi-fungsi utility yang reusable
|
||||||
|
|
||||||
|
### 5. DetailLaporanRequest (~60 baris)
|
||||||
|
|
||||||
|
- **Tanggung jawab**: Validation rules untuk detail laporan
|
||||||
|
- **Fitur**:
|
||||||
|
- Input validation
|
||||||
|
- Custom error messages
|
||||||
|
- Data preparation
|
||||||
|
- **Prinsip**: Form Request untuk clean validation
|
||||||
|
|
||||||
|
### 6. ExportLaporanRequest (~40 baris)
|
||||||
|
|
||||||
|
- **Tanggung jawab**: Validation rules untuk export
|
||||||
|
- **Fitur**:
|
||||||
|
- Export format validation
|
||||||
|
- Filter validation
|
||||||
|
- **Prinsip**: Separated concerns untuk different validation needs
|
||||||
|
|
||||||
|
## 🎯 Keuntungan Refactoring
|
||||||
|
|
||||||
|
### ✅ Maintainability
|
||||||
|
|
||||||
|
- **Sebelum**: 1 file besar (~600 baris) sulit untuk debug dan modify
|
||||||
|
- **Sesudah**: 6 file kecil dengan tanggung jawab yang jelas
|
||||||
|
|
||||||
|
### ✅ Testability
|
||||||
|
|
||||||
|
- **Sebelum**: Sulit untuk unit test karena semua logic tercampur
|
||||||
|
- **Sesudah**: Setiap layer dapat di-test secara terpisah
|
||||||
|
- Service layer dapat di-mock
|
||||||
|
- Repository dapat di-test dengan database
|
||||||
|
- Helper functions dapat di-unit test
|
||||||
|
|
||||||
|
### ✅ Reusability
|
||||||
|
|
||||||
|
- **LaporanHelper** dapat digunakan di controller/service lain
|
||||||
|
- **TransaksiRepository** dapat digunakan untuk keperluan transaksi lain
|
||||||
|
- **Form Requests** dapat digunakan di route lain
|
||||||
|
|
||||||
|
### ✅ SOLID Principles
|
||||||
|
|
||||||
|
- **S** - Single Responsibility: Setiap class punya satu tanggung jawab
|
||||||
|
- **O** - Open/Closed: Mudah untuk extend tanpa modify existing code
|
||||||
|
- **L** - Liskov Substitution: Repository dapat di-substitute dengan implementasi lain
|
||||||
|
- **I** - Interface Segregation: Dependencies yang spesifik
|
||||||
|
- **D** - Dependency Inversion: Controller depend pada abstraction (Service), bukan concrete class
|
||||||
|
|
||||||
|
### ✅ Performance
|
||||||
|
|
||||||
|
- Caching logic tetap terjaga di Service layer
|
||||||
|
- Query optimization tetap di Repository layer
|
||||||
|
- No performance degradation dari refactoring
|
||||||
|
|
||||||
|
## 🔧 Cara Implementasi
|
||||||
|
|
||||||
|
### 1. Buat file-file baru:
|
||||||
|
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
├── Http/
|
||||||
|
│ ├── Controllers/
|
||||||
|
│ │ └── LaporanController.php
|
||||||
|
│ └── Requests/
|
||||||
|
│ ├── DetailLaporanRequest.php
|
||||||
|
│ └── ExportLaporanRequest.php
|
||||||
|
├── Services/
|
||||||
|
│ └── LaporanService.php
|
||||||
|
├── Repositories/
|
||||||
|
│ └── TransaksiRepository.php
|
||||||
|
└── Helpers/
|
||||||
|
└── LaporanHelper.php
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Register dependencies di Service Provider:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// AppServiceProvider.php
|
||||||
|
public function register()
|
||||||
|
{
|
||||||
|
$this->app->bind(TransaksiRepository::class, TransaksiRepository::class);
|
||||||
|
$this->app->bind(LaporanHelper::class, LaporanHelper::class);
|
||||||
|
$this->app->bind(LaporanService::class, LaporanService::class);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Update routes (tidak ada perubahan):
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Routes tetap sama, hanya implementasi internal yang berubah
|
||||||
|
Route::get('/laporan/ringkasan', [LaporanController::class, 'ringkasan']);
|
||||||
|
Route::get('/laporan/detail-per-produk', [LaporanController::class, 'detailPerProduk']);
|
||||||
|
Route::get('/laporan/detail-per-nampan', [LaporanController::class, 'detailPerNampan']);
|
||||||
|
Route::post('/laporan/export', [LaporanController::class, 'exportRingkasan']);
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📊 Perbandingan Ukuran File
|
||||||
|
|
||||||
|
| File Original | Baris | File Baru | Baris | Pengurangan |
|
||||||
|
| --------------------- | ------- | ------------------------ | ------- | ----------- |
|
||||||
|
| LaporanController.php | ~600 | LaporanController.php | ~80 | 87% |
|
||||||
|
| | | LaporanService.php | ~180 | |
|
||||||
|
| | | TransaksiRepository.php | ~120 | |
|
||||||
|
| | | LaporanHelper.php | ~180 | |
|
||||||
|
| | | DetailLaporanRequest.php | ~60 | |
|
||||||
|
| | | ExportLaporanRequest.php | ~40 | |
|
||||||
|
| **Total** | **600** | **Total** | **660** | **+60** |
|
||||||
|
|
||||||
|
_Note: Sedikit penambahan baris karena struktur class yang lebih terorganisir dan dokumentasi yang lebih baik_
|
||||||
|
|
||||||
|
## 🚀 Langkah Selanjutnya (Optional)
|
||||||
|
|
||||||
|
1. **Interface Implementation**: Buat interface untuk Service dan Repository
|
||||||
|
2. **Unit Tests**: Tambahkan comprehensive unit tests untuk setiap layer
|
||||||
|
3. **API Documentation**: Update API documentation
|
||||||
|
4. **Caching Strategy**: Implement more sophisticated caching dengan Redis
|
||||||
|
5. **Query Optimization**: Review dan optimize database queries di Repository
|
||||||
|
|
||||||
|
## ⚠️ Catatan Penting
|
||||||
|
|
||||||
|
- **Backward Compatibility**: API endpoints dan response format tetap sama
|
||||||
|
- **Dependencies**: Pastikan semua dependencies di-register di Service Provider
|
||||||
|
- **Testing**: Lakukan thorough testing sebelum deploy ke production
|
||||||
|
- **Migration**: Bisa dilakukan secara bertahap jika diperlukan
|
83
app/Exports/DetailNampanExport.php
Normal file
83
app/Exports/DetailNampanExport.php
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Exports;
|
||||||
|
|
||||||
|
use Maatwebsite\Excel\Concerns\FromCollection;
|
||||||
|
use Maatwebsite\Excel\Concerns\WithHeadings;
|
||||||
|
use Maatwebsite\Excel\Concerns\WithTitle;
|
||||||
|
use Maatwebsite\Excel\Concerns\WithStyles;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
||||||
|
|
||||||
|
class DetailNampanExport implements FromCollection, WithHeadings, WithTitle, WithStyles
|
||||||
|
{
|
||||||
|
private $data;
|
||||||
|
|
||||||
|
public function __construct($data)
|
||||||
|
{
|
||||||
|
$this->data = $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function collection()
|
||||||
|
{
|
||||||
|
$collection = collect();
|
||||||
|
|
||||||
|
// Add individual nampan data
|
||||||
|
if (isset($this->data['nampan'])) {
|
||||||
|
foreach ($this->data['nampan'] as $item) {
|
||||||
|
$collection->push([
|
||||||
|
$item['nama_nampan'],
|
||||||
|
$item['jumlah_item_terjual'],
|
||||||
|
$item['berat_terjual'],
|
||||||
|
$item['pendapatan'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isset($this->data['rekap_harian'])) {
|
||||||
|
$rekap = $this->data['rekap_harian'];
|
||||||
|
$collection->push([
|
||||||
|
'REKAP TOTAL',
|
||||||
|
$rekap['total_item_terjual'],
|
||||||
|
$rekap['total_berat_terjual'],
|
||||||
|
$rekap['total_pendapatan'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return $collection;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function headings(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'Nama Nampan',
|
||||||
|
'Jumlah Item Terjual',
|
||||||
|
'Berat Terjual',
|
||||||
|
'Pendapatan'
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function title(): string
|
||||||
|
{
|
||||||
|
$filterInfo = $this->data['filter'] ?? [];
|
||||||
|
$tanggal = $filterInfo['tanggal'] ?? 'Unknown';
|
||||||
|
return "Detail Nampan {$tanggal}";
|
||||||
|
}
|
||||||
|
|
||||||
|
public function styles(Worksheet $sheet)
|
||||||
|
{
|
||||||
|
$styles = [
|
||||||
|
1 => ['font' => ['bold' => true]], // Header row
|
||||||
|
];
|
||||||
|
|
||||||
|
// Style for recap row if exists
|
||||||
|
if (isset($this->data['rekap_harian'])) {
|
||||||
|
$styles[2] = [
|
||||||
|
'font' => ['bold' => true],
|
||||||
|
'fill' => [
|
||||||
|
'fillType' => \PhpOffice\PhpSpreadsheet\Style\Fill::FILL_SOLID,
|
||||||
|
'startColor' => ['argb' => 'FFE2E3E5'],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $styles;
|
||||||
|
}
|
||||||
|
}
|
89
app/Exports/DetailProdukExport.php
Normal file
89
app/Exports/DetailProdukExport.php
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Exports;
|
||||||
|
|
||||||
|
use Maatwebsite\Excel\Concerns\FromCollection;
|
||||||
|
use Maatwebsite\Excel\Concerns\WithHeadings;
|
||||||
|
use Maatwebsite\Excel\Concerns\WithTitle;
|
||||||
|
use Maatwebsite\Excel\Concerns\WithStyles;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
||||||
|
|
||||||
|
class DetailProdukExport implements FromCollection, WithHeadings, WithTitle, WithStyles
|
||||||
|
{
|
||||||
|
private $data;
|
||||||
|
|
||||||
|
public function __construct($data)
|
||||||
|
{
|
||||||
|
$this->data = $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function collection()
|
||||||
|
{
|
||||||
|
$collection = collect();
|
||||||
|
|
||||||
|
// Add summary row first
|
||||||
|
if (isset($this->data['rekap_harian'])) {
|
||||||
|
$rekap = $this->data['rekap_harian'];
|
||||||
|
$collection->push([
|
||||||
|
'REKAP TOTAL',
|
||||||
|
$rekap['total_item_terjual'],
|
||||||
|
$rekap['total_berat_terjual'],
|
||||||
|
$rekap['total_pendapatan'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Add empty row separator
|
||||||
|
$collection->push(['', '', '', '']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add individual produk data
|
||||||
|
if (isset($this->data['produk'])) {
|
||||||
|
foreach ($this->data['produk'] as $item) {
|
||||||
|
$collection->push([
|
||||||
|
$item['nama_produk'],
|
||||||
|
$item['jumlah_item_terjual'],
|
||||||
|
$item['berat_terjual'],
|
||||||
|
$item['pendapatan'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $collection;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function headings(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'Nama Produk',
|
||||||
|
'Jumlah Item Terjual',
|
||||||
|
'Berat Terjual',
|
||||||
|
'Pendapatan'
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function title(): string
|
||||||
|
{
|
||||||
|
$filterInfo = $this->data['filter'] ?? [];
|
||||||
|
$tanggal = $filterInfo['tanggal'] ?? 'Unknown';
|
||||||
|
return "Detail Produk {$tanggal}";
|
||||||
|
}
|
||||||
|
|
||||||
|
public function styles(Worksheet $sheet)
|
||||||
|
{
|
||||||
|
$styles = [
|
||||||
|
1 => ['font' => ['bold' => true]], // Header row
|
||||||
|
];
|
||||||
|
|
||||||
|
// Style for recap row if exists
|
||||||
|
if (isset($this->data['rekap_harian'])) {
|
||||||
|
$styles[2] = [
|
||||||
|
'font' => ['bold' => true],
|
||||||
|
'fill' => [
|
||||||
|
'fillType' => \PhpOffice\PhpSpreadsheet\Style\Fill::FILL_SOLID,
|
||||||
|
'startColor' => ['argb' => 'FFE2E3E5'],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $styles;
|
||||||
|
}
|
||||||
|
}
|
@ -2,68 +2,80 @@
|
|||||||
|
|
||||||
namespace App\Exports;
|
namespace App\Exports;
|
||||||
|
|
||||||
use Maatwebsite\Excel\Concerns\FromArray;
|
use Maatwebsite\Excel\Concerns\FromCollection;
|
||||||
use Maatwebsite\Excel\Concerns\WithHeadings;
|
use Maatwebsite\Excel\Concerns\WithHeadings;
|
||||||
use Maatwebsite\Excel\Concerns\ShouldAutoSize;
|
use Maatwebsite\Excel\Concerns\WithTitle;
|
||||||
|
use Maatwebsite\Excel\Concerns\WithStyles;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
||||||
|
|
||||||
class RingkasanExport implements FromArray, WithHeadings, ShouldAutoSize
|
class RingkasanExport implements FromCollection, WithHeadings, WithTitle, WithStyles
|
||||||
{
|
{
|
||||||
protected $data;
|
private $data;
|
||||||
|
private $page;
|
||||||
|
|
||||||
public function __construct(iterable $data)
|
public function __construct(iterable $data, $page = 1)
|
||||||
{
|
{
|
||||||
$this->data = $data;
|
$this->data = $data;
|
||||||
|
$this->page = $page;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function array(): array
|
|
||||||
|
public function collection()
|
||||||
{
|
{
|
||||||
$rows = [];
|
$collection = collect();
|
||||||
|
$items = method_exists($this->data, 'items') ? $this->data->items() : $this->data;
|
||||||
|
|
||||||
// Iterasi setiap hari/bulan
|
foreach ($items as $item) {
|
||||||
foreach ($this->data as $item) {
|
$collection->push([
|
||||||
// Baris pertama untuk entri sales pertama
|
'Tanggal' => $item['tanggal'] ?? '-',
|
||||||
if (count($item['sales']) > 0) {
|
'Total Item Terjual' => $item['total_item_terjual'] ?? 0,
|
||||||
foreach ($item['sales'] as $index => $sales) {
|
'Total Berat' => $item['total_berat'] ?? 0,
|
||||||
$rows[] = [
|
'Total Pendapatan' => $item['total_pendapatan'] ?? 0,
|
||||||
'Tanggal' => $item['tanggal'],
|
'Detail Sales' => $this->formatSalesData($item['sales'] ?? []),
|
||||||
'Nama Sales' => $sales['nama'],
|
]);
|
||||||
'Item Terjual' => $sales['item_terjual'],
|
|
||||||
'Berat Terjual' => $sales['berat_terjual'],
|
|
||||||
'Pendapatan' => $sales['pendapatan'],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Baris jika tidak ada sales hari itu
|
|
||||||
$rows[] = [
|
|
||||||
'Tanggal' => $item['tanggal'],
|
|
||||||
'Nama Sales' => 'N/A',
|
|
||||||
'Item Terjual' => 0,
|
|
||||||
'Berat Terjual' => 0,
|
|
||||||
'Pendapatan' => 0,
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Baris Total Harian/Bulanan
|
return $collection;
|
||||||
$rows[] = [
|
|
||||||
'Tanggal' => $item['tanggal'],
|
|
||||||
'Nama Sales' => '** TOTAL **', // Tandai sebagai baris total
|
|
||||||
'Item Terjual' => $item['total_item_terjual'],
|
|
||||||
'Berat Terjual' => $item['total_berat'],
|
|
||||||
'Pendapatan' => $item['total_pendapatan'],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return $rows;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function headings(): array
|
public function headings(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'Periode',
|
'Tanggal',
|
||||||
'Nama Sales/Keterangan',
|
'Total Item Terjual',
|
||||||
'Item Terjual',
|
'Total Berat',
|
||||||
'Total Berat Terjual',
|
|
||||||
'Total Pendapatan',
|
'Total Pendapatan',
|
||||||
|
'Detail Sales'
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function title(): string
|
||||||
|
{
|
||||||
|
return "Ringkasan Halaman {$this->page}";
|
||||||
|
}
|
||||||
|
|
||||||
|
public function styles(Worksheet $sheet)
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
1 => ['font' => ['bold' => true]],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function formatSalesData($sales): string
|
||||||
|
{
|
||||||
|
if (empty($sales)) {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
$formatted = [];
|
||||||
|
foreach ($sales as $sale) {
|
||||||
|
$nama = $sale['nama'] ?? 'Sales Tidak Dikenal';
|
||||||
|
$itemTerjual = $sale['item_terjual'] ?? 0;
|
||||||
|
$pendapatan = $sale['pendapatan'] ?? '-';
|
||||||
|
|
||||||
|
$formatted[] = "{$nama}: {$itemTerjual} item, {$pendapatan}";
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode('; ', $formatted);
|
||||||
|
}
|
||||||
}
|
}
|
204
app/Helpers/LaporanHelper.php
Normal file
204
app/Helpers/LaporanHelper.php
Normal file
@ -0,0 +1,204 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Helpers;
|
||||||
|
|
||||||
|
use App\Models\Nampan;
|
||||||
|
use App\Models\Sales;
|
||||||
|
use App\Models\Produk;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Pagination\LengthAwarePaginator;
|
||||||
|
|
||||||
|
class LaporanHelper
|
||||||
|
{
|
||||||
|
public const CURRENCY_SYMBOL = 'Rp ';
|
||||||
|
public const WEIGHT_UNIT = ' g';
|
||||||
|
public const DEFAULT_DISPLAY = '-';
|
||||||
|
|
||||||
|
public function calculateTotals(Collection $data): array
|
||||||
|
{
|
||||||
|
$totalPendapatan = $data->sum('pendapatan');
|
||||||
|
$totalItemTerjual = $data->sum('jumlah_item_terjual');
|
||||||
|
$totalBeratTerjual = $data->sum('berat_terjual');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'total_item_terjual' => $totalItemTerjual,
|
||||||
|
'total_berat_terjual' => $this->formatWeight($totalBeratTerjual),
|
||||||
|
'total_pendapatan' => $this->formatCurrency($totalPendapatan),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getAllNampanWithPagination(int $page, int $perPage): LengthAwarePaginator
|
||||||
|
{
|
||||||
|
$semuaNampan = Nampan::select('id', 'nama')->orderBy('nama')->get();
|
||||||
|
$brankasEntry = (object) ['id' => 0, 'nama' => 'Brankas'];
|
||||||
|
$semuaNampanCollection = $semuaNampan->prepend($brankasEntry);
|
||||||
|
|
||||||
|
$offset = ($page - 1) * $perPage;
|
||||||
|
$itemsForCurrentPage = $semuaNampanCollection->slice($offset, $perPage);
|
||||||
|
|
||||||
|
return new LengthAwarePaginator(
|
||||||
|
$itemsForCurrentPage,
|
||||||
|
$semuaNampanCollection->count(),
|
||||||
|
$perPage,
|
||||||
|
$page,
|
||||||
|
['path' => request()->url(), 'query' => request()->query()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function mapProductsWithSalesData($paginatedData, Collection $salesData): Collection
|
||||||
|
{
|
||||||
|
return $paginatedData->getCollection()->map(function ($item) use ($salesData) {
|
||||||
|
if ($salesData->has($item->id)) {
|
||||||
|
$dataTerjual = $salesData->get($item->id);
|
||||||
|
return [
|
||||||
|
'nama_produk' => $item->nama,
|
||||||
|
'jumlah_item_terjual' => (int) $dataTerjual->jumlah_item_terjual,
|
||||||
|
'berat_terjual' => $this->formatWeight($dataTerjual->berat_terjual),
|
||||||
|
'pendapatan' => $this->formatCurrency($dataTerjual->pendapatan),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'nama_produk' => $item->nama,
|
||||||
|
'jumlah_item_terjual' => self::DEFAULT_DISPLAY,
|
||||||
|
'berat_terjual' => self::DEFAULT_DISPLAY,
|
||||||
|
'pendapatan' => self::DEFAULT_DISPLAY,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function mapNampanWithSalesData($paginatedData, Collection $salesData): Collection
|
||||||
|
{
|
||||||
|
return $paginatedData->getCollection()->map(function ($item) use ($salesData) {
|
||||||
|
if ($salesData->has($item->id)) {
|
||||||
|
$dataTerjual = $salesData->get($item->id);
|
||||||
|
return [
|
||||||
|
'nama_nampan' => $item->nama,
|
||||||
|
'jumlah_item_terjual' => (int) $dataTerjual->jumlah_item_terjual,
|
||||||
|
'berat_terjual' => $this->formatWeight($dataTerjual->berat_terjual),
|
||||||
|
'pendapatan' => $this->formatCurrency($dataTerjual->pendapatan),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'nama_nampan' => $item->nama,
|
||||||
|
'jumlah_item_terjual' => self::DEFAULT_DISPLAY,
|
||||||
|
'berat_terjual' => self::DEFAULT_DISPLAY,
|
||||||
|
'pendapatan' => self::DEFAULT_DISPLAY,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function buildProdukFilterInfo(Carbon $carbonDate, array $params): array
|
||||||
|
{
|
||||||
|
$filterInfo = [
|
||||||
|
'tanggal' => $carbonDate->isoFormat('dddd, D MMMM Y'),
|
||||||
|
'nama_sales' => null,
|
||||||
|
'nampan' => null,
|
||||||
|
'nama_pembeli' => $params['nama_pembeli'] ?? null,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!empty($params['sales_id'])) {
|
||||||
|
$sales = Sales::find($params['sales_id']);
|
||||||
|
$filterInfo['nama_sales'] = $sales?->nama;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($params['nampan_id'])) {
|
||||||
|
if ($params['nampan_id'] == 0) {
|
||||||
|
$filterInfo['nampan'] = 'Brankas';
|
||||||
|
} else {
|
||||||
|
$nampan = Nampan::find($params['nampan_id']);
|
||||||
|
$filterInfo['nampan'] = $nampan?->nama;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $filterInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function buildNampanFilterInfo(Carbon $carbonDate, array $params): array
|
||||||
|
{
|
||||||
|
$filterInfo = [
|
||||||
|
'tanggal' => $carbonDate->isoFormat('dddd, D MMMM Y'),
|
||||||
|
'nama_sales' => null,
|
||||||
|
'produk' => null,
|
||||||
|
'nama_pembeli' => $params['nama_pembeli'] ?? null,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!empty($params['sales_id'])) {
|
||||||
|
$sales = Sales::find($params['sales_id']);
|
||||||
|
$filterInfo['nama_sales'] = $sales?->nama;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($params['produk_id'])) {
|
||||||
|
$produk = Produk::find($params['produk_id']);
|
||||||
|
$filterInfo['produk'] = $produk?->nama;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $filterInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function buildPaginationInfo($paginatedData): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'current_page' => $paginatedData->currentPage(),
|
||||||
|
'last_page' => $paginatedData->lastPage(),
|
||||||
|
'per_page' => $paginatedData->perPage(),
|
||||||
|
'total' => $paginatedData->total(),
|
||||||
|
'from' => $paginatedData->firstItem(),
|
||||||
|
'to' => $paginatedData->lastItem(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hitungDataSales(Collection $transaksisPerSales): array
|
||||||
|
{
|
||||||
|
$itemTerjual = $transaksisPerSales->sum(fn($t) => $t->itemTransaksi->count());
|
||||||
|
|
||||||
|
$beratTerjual = $transaksisPerSales->sum(
|
||||||
|
fn($t) => $t->itemTransaksi->sum(fn($it) => $it->item?->produk?->berat ?? 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
$pendapatan = $transaksisPerSales->sum('total_harga');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'nama' => $transaksisPerSales->first()->nama_sales,
|
||||||
|
'item_terjual' => $itemTerjual,
|
||||||
|
'berat_terjual_raw' => $beratTerjual,
|
||||||
|
'pendapatan_raw' => $pendapatan,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function defaultSalesData(string $namaSales): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'nama' => $namaSales,
|
||||||
|
'item_terjual' => 0,
|
||||||
|
'berat_terjual_raw' => 0,
|
||||||
|
'pendapatan_raw' => 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function formatSalesDataValues(Collection $salesData): Collection
|
||||||
|
{
|
||||||
|
return $salesData->map(function ($sale) {
|
||||||
|
$sale['item_terjual'] = $sale['item_terjual'] > 0 ? $sale['item_terjual'] : self::DEFAULT_DISPLAY;
|
||||||
|
$sale['berat_terjual'] = $sale['berat_terjual_raw'] > 0 ?
|
||||||
|
$this->formatWeight($sale['berat_terjual_raw']) : self::DEFAULT_DISPLAY;
|
||||||
|
$sale['pendapatan'] = $sale['pendapatan_raw'] > 0 ?
|
||||||
|
$this->formatCurrency($sale['pendapatan_raw']) : self::DEFAULT_DISPLAY;
|
||||||
|
|
||||||
|
unset($sale['berat_terjual_raw'], $sale['pendapatan_raw']);
|
||||||
|
return $sale;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function formatCurrency(float $amount): string
|
||||||
|
{
|
||||||
|
return self::CURRENCY_SYMBOL . number_format($amount, 2, ',', '.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function formatWeight(float $weight): string
|
||||||
|
{
|
||||||
|
return number_format($weight, 2, ',', '.') . self::WEIGHT_UNIT;
|
||||||
|
}
|
||||||
|
}
|
@ -2,34 +2,20 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Models\ItemTransaksi;
|
use App\Services\LaporanService;
|
||||||
use App\Models\Produk;
|
use App\Http\Requests\DetailLaporanRequest;
|
||||||
use App\Models\Transaksi;
|
use App\Http\Requests\ExportLaporanRequest;
|
||||||
use App\Models\Sales;
|
|
||||||
use App\Models\Nampan;
|
|
||||||
use Carbon\Carbon;
|
|
||||||
use Carbon\CarbonPeriod;
|
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Pagination\LengthAwarePaginator;
|
|
||||||
use Illuminate\Support\Collection;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use Illuminate\Support\Facades\Cache;
|
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
use Maatwebsite\Excel\Facades\Excel;
|
|
||||||
use Barryvdh\DomPDF\Facade\Pdf;
|
|
||||||
use App\Exports\RingkasanExport;
|
|
||||||
|
|
||||||
class LaporanController extends Controller
|
class LaporanController extends Controller
|
||||||
{
|
{
|
||||||
private const CURRENCY_SYMBOL = 'Rp ';
|
private LaporanService $laporanService;
|
||||||
private const WEIGHT_UNIT = ' g';
|
|
||||||
private const DEFAULT_DISPLAY = '-';
|
public function __construct(LaporanService $laporanService)
|
||||||
private const CACHE_TTL = 300; // 5 menit
|
{
|
||||||
private const DEFAULT_PER_PAGE = 15;
|
$this->laporanService = $laporanService;
|
||||||
private const MAX_PER_PAGE = 100;
|
}
|
||||||
private const DAILY_PER_PAGE = 7;
|
|
||||||
private const MONTHLY_PER_PAGE = 12;
|
|
||||||
private const PAGINATION_DAYS_LIMIT = 365;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Endpoint untuk ringkasan laporan dengan caching
|
* Endpoint untuk ringkasan laporan dengan caching
|
||||||
@ -45,18 +31,7 @@ class LaporanController extends Controller
|
|||||||
return response()->json(['error' => 'Filter harus "hari" atau "bulan"'], 400);
|
return response()->json(['error' => 'Filter harus "hari" atau "bulan"'], 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache key berdasarkan filter dan page
|
$data = $this->laporanService->getRingkasan($filter, $page);
|
||||||
$cacheKey = "laporan_ringkasan_{$filter}_page_{$page}";
|
|
||||||
|
|
||||||
$data = Cache::remember($cacheKey, self::CACHE_TTL, function () use ($filter, $page) {
|
|
||||||
$allSalesNames = $this->getAllSalesNames();
|
|
||||||
|
|
||||||
if ($filter === 'hari') {
|
|
||||||
return $this->processLaporanHarian($allSalesNames, $page, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->processLaporanBulanan($allSalesNames, $page, true);
|
|
||||||
});
|
|
||||||
|
|
||||||
return response()->json($data);
|
return response()->json($data);
|
||||||
|
|
||||||
@ -67,123 +42,28 @@ class LaporanController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detail laporan per produk dengan validasi dan error handling yang lebih baik
|
* Detail laporan per produk
|
||||||
*/
|
*/
|
||||||
public function detailPerProduk(Request $request)
|
public function detailPerProduk(DetailLaporanRequest $request)
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$validatedData = $request->validate([
|
$data = $this->laporanService->getDetailPerProduk($request->validated());
|
||||||
'tanggal' => 'required|date_format:Y-m-d|before_or_equal:today',
|
return response()->json($data);
|
||||||
'sales_id' => 'nullable|integer|exists:sales,id',
|
|
||||||
'nampan_id' => 'nullable|integer',
|
|
||||||
'nama_pembeli' => 'nullable|string|max:255',
|
|
||||||
'page' => 'nullable|integer|min:1',
|
|
||||||
'per_page' => 'nullable|integer|min:1|max:' . self::MAX_PER_PAGE,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$tanggal = $validatedData['tanggal'];
|
|
||||||
$salesId = $request->query('sales_id');
|
|
||||||
$nampanId = $request->query('nampan_id');
|
|
||||||
$namaPembeli = $request->query('nama_pembeli');
|
|
||||||
$page = (int) $request->query('page', 1);
|
|
||||||
$perPage = (int) $request->query('per_page', self::DEFAULT_PER_PAGE);
|
|
||||||
|
|
||||||
$carbonDate = Carbon::parse($tanggal);
|
|
||||||
|
|
||||||
// Validasi nampan_id jika ada
|
|
||||||
if ($nampanId && $nampanId != 0) {
|
|
||||||
if (!Nampan::where('id', $nampanId)->exists()) {
|
|
||||||
return response()->json(['error' => 'Nampan tidak ditemukan'], 404);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$produkTerjualQuery = $this->buildBaseItemQuery($carbonDate);
|
|
||||||
$this->applyFilters($produkTerjualQuery, $salesId, $nampanId, $namaPembeli);
|
|
||||||
|
|
||||||
$produkTerjual = $produkTerjualQuery
|
|
||||||
->select(
|
|
||||||
'produks.id as id_produk',
|
|
||||||
'produks.nama as nama_produk',
|
|
||||||
DB::raw('COUNT(item_transaksis.id) as jumlah_item_terjual'),
|
|
||||||
DB::raw('COALESCE(SUM(produks.berat), 0) as berat_terjual'),
|
|
||||||
DB::raw('COALESCE(SUM(item_transaksis.harga_deal), 0) as pendapatan')
|
|
||||||
)
|
|
||||||
->groupBy('produks.id', 'produks.nama')
|
|
||||||
->get()
|
|
||||||
->keyBy('id_produk');
|
|
||||||
|
|
||||||
$totals = $this->calculateTotals($produkTerjual);
|
|
||||||
$semuaProdukPaginated = Produk::select('id', 'nama')
|
|
||||||
->orderBy('nama')
|
|
||||||
->paginate($perPage, ['*'], 'page', $page);
|
|
||||||
|
|
||||||
$detailItem = $this->mapProductsWithSalesData($semuaProdukPaginated, $produkTerjual);
|
|
||||||
$filterInfo = $this->buildFilterInfo($carbonDate, $salesId, $nampanId, $namaPembeli);
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'filter' => $filterInfo,
|
|
||||||
'rekap_harian' => $totals,
|
|
||||||
'produk' => $detailItem->values(),
|
|
||||||
'pagination' => $this->buildPaginationInfo($semuaProdukPaginated),
|
|
||||||
]);
|
|
||||||
|
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
Log::error('Error in detailPerProduk method: ' . $e->getMessage());
|
Log::error('Error in detail PerProduk method: ' . $e->getMessage());
|
||||||
return response()->json(['error' => 'Terjadi kesalahan saat mengambil data produk'], 500);
|
return response()->json(['error' => 'Terjadi kesalahan saat mengambil data produk'], 500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detail laporan per nampan dengan perbaikan validasi dan error handling
|
* Detail laporan per nampan
|
||||||
*/
|
*/
|
||||||
public function detailPerNampan(Request $request)
|
public function detailPerNampan(DetailLaporanRequest $request)
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$validatedData = $request->validate([
|
$data = $this->laporanService->getDetailPerNampan($request->validated());
|
||||||
'tanggal' => 'required|date_format:Y-m-d|before_or_equal:today',
|
return response()->json($data);
|
||||||
'sales_id' => 'nullable|integer|exists:sales,id',
|
|
||||||
'produk_id' => 'nullable|integer|exists:produks,id',
|
|
||||||
'nama_pembeli' => 'nullable|string|max:255',
|
|
||||||
'page' => 'nullable|integer|min:1',
|
|
||||||
'per_page' => 'nullable|integer|min:1|max:' . self::MAX_PER_PAGE,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$tanggal = $validatedData['tanggal'];
|
|
||||||
$salesId = $request->query('sales_id');
|
|
||||||
$produkId = $request->query('produk_id');
|
|
||||||
$namaPembeli = $request->query('nama_pembeli');
|
|
||||||
$page = (int) $request->query('page', 1);
|
|
||||||
$perPage = (int) $request->query('per_page', self::DEFAULT_PER_PAGE);
|
|
||||||
|
|
||||||
$carbonDate = Carbon::parse($tanggal);
|
|
||||||
|
|
||||||
$nampanTerjualQuery = $this->buildBaseItemQuery($carbonDate);
|
|
||||||
$this->applyNampanFilters($nampanTerjualQuery, $salesId, $produkId, $namaPembeli);
|
|
||||||
|
|
||||||
$nampanTerjual = $nampanTerjualQuery
|
|
||||||
->leftJoin('nampans', 'items.id_nampan', '=', 'nampans.id')
|
|
||||||
->select(
|
|
||||||
DB::raw('COALESCE(items.id_nampan, 0) as id_nampan'),
|
|
||||||
DB::raw('COALESCE(nampans.nama, "Brankas") as nama_nampan'),
|
|
||||||
DB::raw('COUNT(item_transaksis.id) as jumlah_item_terjual'),
|
|
||||||
DB::raw('COALESCE(SUM(produks.berat), 0) as berat_terjual'),
|
|
||||||
DB::raw('COALESCE(SUM(item_transaksis.harga_deal), 0) as pendapatan')
|
|
||||||
)
|
|
||||||
->groupBy('id_nampan', 'nama_nampan')
|
|
||||||
->get()
|
|
||||||
->keyBy('id_nampan');
|
|
||||||
|
|
||||||
$totals = $this->calculateTotals($nampanTerjual);
|
|
||||||
$semuaNampanPaginated = $this->getAllNampanWithPagination($page, $perPage);
|
|
||||||
$detailItem = $this->mapNampanWithSalesData($semuaNampanPaginated, $nampanTerjual);
|
|
||||||
$filterInfo = $this->buildNampanFilterInfo($carbonDate, $salesId, $produkId, $namaPembeli);
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'filter' => $filterInfo,
|
|
||||||
'rekap_harian' => $totals,
|
|
||||||
'nampan' => $detailItem->values(),
|
|
||||||
'pagination' => $this->buildPaginationInfo($semuaNampanPaginated),
|
|
||||||
]);
|
|
||||||
|
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
Log::error('Error in detailPerNampan method: ' . $e->getMessage());
|
Log::error('Error in detailPerNampan method: ' . $e->getMessage());
|
||||||
@ -192,40 +72,12 @@ class LaporanController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Export laporan ringkasan dengan validasi format
|
* Export laporan ringkasan
|
||||||
*/
|
*/
|
||||||
public function exportRingkasan(Request $request)
|
public function exportRingkasan(ExportLaporanRequest $request)
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$validatedData = $request->validate([
|
return $this->laporanService->exportRingkasan($request->validated());
|
||||||
'filter' => 'required|in:hari,bulan',
|
|
||||||
'format' => 'required|in:pdf,xlsx,csv',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$filter = $validatedData['filter'];
|
|
||||||
$format = $validatedData['format'];
|
|
||||||
|
|
||||||
$allSalesNames = $this->getAllSalesNames();
|
|
||||||
|
|
||||||
if ($filter === 'hari') {
|
|
||||||
$data = $this->processLaporanHarian($allSalesNames, 1, false);
|
|
||||||
} else {
|
|
||||||
$data = $this->processLaporanBulanan($allSalesNames, 1, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
$fileName = "laporan_ringkasan_{$filter}_" . Carbon::now()->format('Ymd') . ".{$format}";
|
|
||||||
|
|
||||||
if ($format === 'pdf') {
|
|
||||||
$pdf = PDF::loadView('exports.ringkasan_pdf', [
|
|
||||||
'data' => $data,
|
|
||||||
'filter' => $filter
|
|
||||||
]);
|
|
||||||
$pdf->setPaper('a4', 'landscape');
|
|
||||||
return $pdf->download($fileName);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Format XLSX atau CSV
|
|
||||||
return Excel::download(new RingkasanExport($data), $fileName);
|
|
||||||
|
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
Log::error('Error in exportRingkasan method: ' . $e->getMessage());
|
Log::error('Error in exportRingkasan method: ' . $e->getMessage());
|
||||||
@ -233,413 +85,39 @@ class LaporanController extends Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public function exportDetailNampan(Request $request)
|
||||||
* Helper method untuk mendapatkan semua nama sales dengan caching
|
|
||||||
*/
|
|
||||||
private function getAllSalesNames(): Collection
|
|
||||||
{
|
{
|
||||||
return Cache::remember('all_sales_names', self::CACHE_TTL, function () {
|
try {
|
||||||
return Transaksi::select('nama_sales')->distinct()->pluck('nama_sales');
|
return $this->laporanService->exportPerNampan($request->validate([
|
||||||
});
|
'tanggal' => 'nullable|string',
|
||||||
|
'sales_id' => 'nullable|integer|exists:sales,id',
|
||||||
|
'produk_id' => 'nullable|integer|exists:produk,id',
|
||||||
|
'nama_pembeli' => 'nullable|string|max:255',
|
||||||
|
'format' => 'required|string|in:pdf,xlsx,csv',
|
||||||
|
'page' => 'nullable|integer|min:1',
|
||||||
|
]));
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('Error in exprot per nampan method: ' . $e->getMessage());
|
||||||
|
return response()->json(['error' => 'Terjadi kesalahan saat export data'], 500);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public function exportDetailProduk(Request $request)
|
||||||
* Helper method untuk mendapatkan semua nampan dengan pagination
|
|
||||||
*/
|
|
||||||
private function getAllNampanWithPagination(int $page, int $perPage): LengthAwarePaginator
|
|
||||||
{
|
{
|
||||||
$semuaNampan = Nampan::select('id', 'nama')->orderBy('nama')->get();
|
try {
|
||||||
$brankasEntry = (object) ['id' => 0, 'nama' => 'Brankas'];
|
return $this->laporanService->exportPerProduk($request->validate([
|
||||||
$semuaNampanCollection = $semuaNampan->prepend($brankasEntry);
|
'tanggal' => 'nullable|string',
|
||||||
|
'sales_id' => 'nullable|integer|exists:sales,id',
|
||||||
|
'nampan_id' => 'nullable|integer|exists:nampan,id',
|
||||||
|
'nama_pembeli' => 'nullable|string|max:255',
|
||||||
|
'format' => 'required|string|in:pdf,xlsx,csv',
|
||||||
|
'page' => 'nullable|integer|min:1',
|
||||||
|
]));
|
||||||
|
|
||||||
$offset = ($page - 1) * $perPage;
|
} catch (\Exception $e) {
|
||||||
$itemsForCurrentPage = $semuaNampanCollection->slice($offset, $perPage);
|
Log::error('Error in exprot per nampan method: ' . $e->getMessage());
|
||||||
|
return response()->json(['error' => 'Terjadi kesalahan saat export data'], 500);
|
||||||
return new LengthAwarePaginator(
|
|
||||||
$itemsForCurrentPage,
|
|
||||||
$semuaNampanCollection->count(),
|
|
||||||
$perPage,
|
|
||||||
$page,
|
|
||||||
['path' => request()->url(), 'query' => request()->query()]
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Logika inti untuk menghasilkan data laporan harian yang sudah dioptimasi
|
|
||||||
*/
|
|
||||||
private function processLaporanHarian(Collection $allSalesNames, int $page = 1, bool $limitPagination = true)
|
|
||||||
{
|
|
||||||
$perPage = self::DAILY_PER_PAGE;
|
|
||||||
|
|
||||||
if ($limitPagination) {
|
|
||||||
$endDate = Carbon::today()->subDays(($page - 1) * $perPage);
|
|
||||||
$startDate = $endDate->copy()->subDays($perPage - 1);
|
|
||||||
$totalHariUntukPaginasi = self::PAGINATION_DAYS_LIMIT;
|
|
||||||
} else {
|
|
||||||
$endDate = Carbon::today();
|
|
||||||
$startDate = $endDate->copy()->subYear()->addDay();
|
|
||||||
$totalHariUntukPaginasi = $endDate->diffInDays($startDate) + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
$transaksis = Transaksi::with(['itemTransaksi.item.produk'])
|
|
||||||
->whereBetween('created_at', [$startDate->startOfDay(), $endDate->endOfDay()])
|
|
||||||
->orderBy('created_at', 'desc')
|
|
||||||
->get();
|
|
||||||
|
|
||||||
$transaksisByDay = $transaksis->groupBy(function ($transaksi) {
|
|
||||||
return Carbon::parse($transaksi->created_at)->format('Y-m-d');
|
|
||||||
});
|
|
||||||
|
|
||||||
$period = CarbonPeriod::create($startDate, $endDate);
|
|
||||||
$laporan = [];
|
|
||||||
|
|
||||||
foreach ($period as $date) {
|
|
||||||
$dateString = $date->format('Y-m-d');
|
|
||||||
$tanggalFormatted = $date->isoFormat('dddd, D MMMM Y');
|
|
||||||
|
|
||||||
if (isset($transaksisByDay[$dateString])) {
|
|
||||||
$transaksisPerTanggal = $transaksisByDay[$dateString];
|
|
||||||
$salesDataTransaksi = $transaksisPerTanggal->groupBy('nama_sales')
|
|
||||||
->map(fn($transaksisPerSales) => $this->hitungDataSales($transaksisPerSales));
|
|
||||||
|
|
||||||
$fullSalesData = $allSalesNames->map(function ($namaSales) use ($salesDataTransaksi) {
|
|
||||||
return $salesDataTransaksi->get($namaSales) ?? $this->defaultSalesData($namaSales);
|
|
||||||
});
|
|
||||||
|
|
||||||
$totalItem = $fullSalesData->sum('item_terjual');
|
|
||||||
$totalBerat = $fullSalesData->sum('berat_terjual_raw');
|
|
||||||
$totalPendapatan = $fullSalesData->sum('pendapatan_raw');
|
|
||||||
|
|
||||||
$laporan[$dateString] = [
|
|
||||||
'tanggal' => $tanggalFormatted,
|
|
||||||
'total_item_terjual' => $totalItem > 0 ? $totalItem : self::DEFAULT_DISPLAY,
|
|
||||||
'total_berat' => $totalBerat > 0 ? $this->formatWeight($totalBerat) : self::DEFAULT_DISPLAY,
|
|
||||||
'total_pendapatan' => $totalPendapatan > 0 ? $this->formatCurrency($totalPendapatan) : self::DEFAULT_DISPLAY,
|
|
||||||
'sales' => $this->formatSalesDataValues($fullSalesData)->values(),
|
|
||||||
];
|
|
||||||
} else {
|
|
||||||
$laporan[$dateString] = [
|
|
||||||
'tanggal' => $tanggalFormatted,
|
|
||||||
'total_item_terjual' => self::DEFAULT_DISPLAY,
|
|
||||||
'total_berat' => self::DEFAULT_DISPLAY,
|
|
||||||
'total_pendapatan' => self::DEFAULT_DISPLAY,
|
|
||||||
'sales' => [],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($limitPagination) {
|
|
||||||
return new LengthAwarePaginator(
|
|
||||||
array_reverse(array_values($laporan)),
|
|
||||||
$totalHariUntukPaginasi,
|
|
||||||
$perPage,
|
|
||||||
$page,
|
|
||||||
['path' => request()->url(), 'query' => request()->query()]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return collect(array_reverse(array_values($laporan)));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Logika inti untuk menghasilkan data laporan bulanan yang sudah dioptimasi
|
|
||||||
*/
|
|
||||||
private function processLaporanBulanan(Collection $allSalesNames, int $page = 1, bool $limitPagination = true)
|
|
||||||
{
|
|
||||||
$perPage = self::MONTHLY_PER_PAGE;
|
|
||||||
|
|
||||||
$transaksis = Transaksi::with(['itemTransaksi.item.produk'])
|
|
||||||
->orderBy('created_at', 'desc')
|
|
||||||
->get();
|
|
||||||
|
|
||||||
$laporan = $transaksis->groupBy(function ($transaksi) {
|
|
||||||
return Carbon::parse($transaksi->created_at)->format('F Y');
|
|
||||||
})->map(function ($transaksisPerTanggal, $tanggal) use ($allSalesNames) {
|
|
||||||
$salesDataTransaksi = $transaksisPerTanggal
|
|
||||||
->groupBy('nama_sales')
|
|
||||||
->map(fn($transaksisPerSales) => $this->hitungDataSales($transaksisPerSales));
|
|
||||||
|
|
||||||
$fullSalesData = $allSalesNames->map(function ($namaSales) use ($salesDataTransaksi) {
|
|
||||||
return $salesDataTransaksi->get($namaSales) ?? $this->defaultSalesData($namaSales);
|
|
||||||
});
|
|
||||||
|
|
||||||
$totalItem = $fullSalesData->sum('item_terjual');
|
|
||||||
$totalBerat = $fullSalesData->sum('berat_terjual_raw');
|
|
||||||
$totalPendapatan = $fullSalesData->sum('pendapatan_raw');
|
|
||||||
|
|
||||||
return [
|
|
||||||
'tanggal' => $tanggal,
|
|
||||||
'total_item_terjual' => $totalItem > 0 ? $totalItem : self::DEFAULT_DISPLAY,
|
|
||||||
'total_berat' => $totalBerat > 0 ? $this->formatWeight($totalBerat) : self::DEFAULT_DISPLAY,
|
|
||||||
'total_pendapatan' => $totalPendapatan > 0 ? $this->formatCurrency($totalPendapatan) : self::DEFAULT_DISPLAY,
|
|
||||||
'sales' => $this->formatSalesDataValues($fullSalesData)->values(),
|
|
||||||
];
|
|
||||||
});
|
|
||||||
|
|
||||||
if ($limitPagination) {
|
|
||||||
return new LengthAwarePaginator(
|
|
||||||
$laporan->forPage($page, $perPage)->values(),
|
|
||||||
$laporan->count(),
|
|
||||||
$perPage,
|
|
||||||
$page,
|
|
||||||
['path' => request()->url(), 'query' => request()->query()]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $laporan->values();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Membangun query dasar untuk item transaksi
|
|
||||||
*/
|
|
||||||
private function buildBaseItemQuery(Carbon $carbonDate)
|
|
||||||
{
|
|
||||||
return ItemTransaksi::query()
|
|
||||||
->join('items', 'item_transaksis.id_item', '=', 'items.id')
|
|
||||||
->join('produks', 'items.id_produk', '=', 'produks.id')
|
|
||||||
->join('transaksis', 'item_transaksis.id_transaksi', '=', 'transaksis.id')
|
|
||||||
->whereDate('transaksis.created_at', $carbonDate);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Menerapkan filter untuk query produk
|
|
||||||
*/
|
|
||||||
private function applyFilters($query, $salesId, $nampanId, $namaPembeli): void
|
|
||||||
{
|
|
||||||
if ($salesId) {
|
|
||||||
$query->join('sales', 'transaksis.id_sales', '=', 'sales.id')
|
|
||||||
->where('sales.id', $salesId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($nampanId !== null) {
|
|
||||||
if ($nampanId == 0) {
|
|
||||||
$query->whereNull('items.id_nampan');
|
|
||||||
} else {
|
|
||||||
$query->where('items.id_nampan', $nampanId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($namaPembeli) {
|
|
||||||
$query->where('transaksis.nama_pembeli', 'like', "%{$namaPembeli}%");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Menerapkan filter untuk query nampan
|
|
||||||
*/
|
|
||||||
private function applyNampanFilters($query, $salesId, $produkId, $namaPembeli): void
|
|
||||||
{
|
|
||||||
if ($salesId) {
|
|
||||||
$query->join('sales', 'transaksis.id_sales', '=', 'sales.id')
|
|
||||||
->where('sales.id', $salesId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($produkId) {
|
|
||||||
$query->where('produks.id', $produkId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($namaPembeli) {
|
|
||||||
$query->where('transaksis.nama_pembeli', 'like', "%{$namaPembeli}%");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Menghitung total dari data penjualan
|
|
||||||
*/
|
|
||||||
private function calculateTotals(Collection $data): array
|
|
||||||
{
|
|
||||||
$totalPendapatan = $data->sum('pendapatan');
|
|
||||||
$totalItemTerjual = $data->sum('jumlah_item_terjual');
|
|
||||||
$totalBeratTerjual = $data->sum('berat_terjual');
|
|
||||||
|
|
||||||
return [
|
|
||||||
'total_item_terjual' => $totalItemTerjual,
|
|
||||||
'total_berat_terjual' => $this->formatWeight($totalBeratTerjual),
|
|
||||||
'total_pendapatan' => $this->formatCurrency($totalPendapatan),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Memetakan produk dengan data penjualan
|
|
||||||
*/
|
|
||||||
private function mapProductsWithSalesData($paginatedData, Collection $salesData): Collection
|
|
||||||
{
|
|
||||||
return $paginatedData->getCollection()->map(function ($item) use ($salesData) {
|
|
||||||
if ($salesData->has($item->id)) {
|
|
||||||
$dataTerjual = $salesData->get($item->id);
|
|
||||||
return [
|
|
||||||
'nama_produk' => $item->nama,
|
|
||||||
'jumlah_item_terjual' => (int) $dataTerjual->jumlah_item_terjual,
|
|
||||||
'berat_terjual' => $this->formatWeight($dataTerjual->berat_terjual),
|
|
||||||
'pendapatan' => $this->formatCurrency($dataTerjual->pendapatan),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'nama_produk' => $item->nama,
|
|
||||||
'jumlah_item_terjual' => self::DEFAULT_DISPLAY,
|
|
||||||
'berat_terjual' => self::DEFAULT_DISPLAY,
|
|
||||||
'pendapatan' => self::DEFAULT_DISPLAY,
|
|
||||||
];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Memetakan nampan dengan data penjualan
|
|
||||||
*/
|
|
||||||
private function mapNampanWithSalesData($paginatedData, Collection $salesData): Collection
|
|
||||||
{
|
|
||||||
return $paginatedData->getCollection()->map(function ($item) use ($salesData) {
|
|
||||||
if ($salesData->has($item->id)) {
|
|
||||||
$dataTerjual = $salesData->get($item->id);
|
|
||||||
return [
|
|
||||||
'nama_nampan' => $item->nama,
|
|
||||||
'jumlah_item_terjual' => (int) $dataTerjual->jumlah_item_terjual,
|
|
||||||
'berat_terjual' => $this->formatWeight($dataTerjual->berat_terjual),
|
|
||||||
'pendapatan' => $this->formatCurrency($dataTerjual->pendapatan),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'nama_nampan' => $item->nama,
|
|
||||||
'jumlah_item_terjual' => self::DEFAULT_DISPLAY,
|
|
||||||
'berat_terjual' => self::DEFAULT_DISPLAY,
|
|
||||||
'pendapatan' => self::DEFAULT_DISPLAY,
|
|
||||||
];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Membangun informasi filter untuk produk
|
|
||||||
*/
|
|
||||||
private function buildFilterInfo(Carbon $carbonDate, $salesId, $nampanId, $namaPembeli): array
|
|
||||||
{
|
|
||||||
$filterInfo = [
|
|
||||||
'tanggal' => $carbonDate->isoFormat('dddd, D MMMM Y'),
|
|
||||||
'nama_sales' => null,
|
|
||||||
'nampan' => null,
|
|
||||||
'nama_pembeli' => $namaPembeli,
|
|
||||||
];
|
|
||||||
|
|
||||||
if ($salesId) {
|
|
||||||
$sales = Sales::find($salesId);
|
|
||||||
$filterInfo['nama_sales'] = $sales?->nama;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($nampanId !== null) {
|
|
||||||
if ($nampanId == 0) {
|
|
||||||
$filterInfo['nampan'] = 'Brankas';
|
|
||||||
} else {
|
|
||||||
$nampan = Nampan::find($nampanId);
|
|
||||||
$filterInfo['nampan'] = $nampan?->nama;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $filterInfo;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Membangun informasi filter untuk nampan
|
|
||||||
*/
|
|
||||||
private function buildNampanFilterInfo(Carbon $carbonDate, $salesId, $produkId, $namaPembeli): array
|
|
||||||
{
|
|
||||||
$filterInfo = [
|
|
||||||
'tanggal' => $carbonDate->isoFormat('dddd, D MMMM Y'),
|
|
||||||
'nama_sales' => null,
|
|
||||||
'produk' => null,
|
|
||||||
'nama_pembeli' => $namaPembeli,
|
|
||||||
];
|
|
||||||
|
|
||||||
if ($salesId) {
|
|
||||||
$sales = Sales::find($salesId);
|
|
||||||
$filterInfo['nama_sales'] = $sales?->nama;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($produkId) {
|
|
||||||
$produk = Produk::find($produkId);
|
|
||||||
$filterInfo['produk'] = $produk?->nama;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $filterInfo;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Membangun informasi pagination
|
|
||||||
*/
|
|
||||||
private function buildPaginationInfo($paginatedData): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'current_page' => $paginatedData->currentPage(),
|
|
||||||
'last_page' => $paginatedData->lastPage(),
|
|
||||||
'per_page' => $paginatedData->perPage(),
|
|
||||||
'total' => $paginatedData->total(),
|
|
||||||
'from' => $paginatedData->firstItem(),
|
|
||||||
'to' => $paginatedData->lastItem(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Menghitung data sales dari transaksi
|
|
||||||
*/
|
|
||||||
private function hitungDataSales(Collection $transaksisPerSales): array
|
|
||||||
{
|
|
||||||
$itemTerjual = $transaksisPerSales->sum(fn($t) => $t->itemTransaksi->count());
|
|
||||||
$beratTerjual = $transaksisPerSales->sum(
|
|
||||||
fn($t) => $t->itemTransaksi->sum(fn($it) => $it->item->produk->berat ?? 0)
|
|
||||||
);
|
|
||||||
$pendapatan = $transaksisPerSales->sum('total_harga');
|
|
||||||
|
|
||||||
return [
|
|
||||||
'nama' => $transaksisPerSales->first()->nama_sales,
|
|
||||||
'item_terjual' => $itemTerjual,
|
|
||||||
'berat_terjual_raw' => $beratTerjual,
|
|
||||||
'pendapatan_raw' => $pendapatan,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Default data untuk sales yang tidak ada transaksi
|
|
||||||
*/
|
|
||||||
private function defaultSalesData(string $namaSales): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'nama' => $namaSales,
|
|
||||||
'item_terjual' => 0,
|
|
||||||
'berat_terjual_raw' => 0,
|
|
||||||
'pendapatan_raw' => 0,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format nilai data sales untuk tampilan
|
|
||||||
*/
|
|
||||||
private function formatSalesDataValues(Collection $salesData): Collection
|
|
||||||
{
|
|
||||||
return $salesData->map(function ($sale) {
|
|
||||||
$sale['item_terjual'] = $sale['item_terjual'] > 0 ? $sale['item_terjual'] : self::DEFAULT_DISPLAY;
|
|
||||||
$sale['berat_terjual'] = $sale['berat_terjual_raw'] > 0 ?
|
|
||||||
$this->formatWeight($sale['berat_terjual_raw']) : self::DEFAULT_DISPLAY;
|
|
||||||
$sale['pendapatan'] = $sale['pendapatan_raw'] > 0 ?
|
|
||||||
$this->formatCurrency($sale['pendapatan_raw']) : self::DEFAULT_DISPLAY;
|
|
||||||
|
|
||||||
unset($sale['berat_terjual_raw'], $sale['pendapatan_raw']);
|
|
||||||
return $sale;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format mata uang
|
|
||||||
*/
|
|
||||||
private function formatCurrency(float $amount): string
|
|
||||||
{
|
|
||||||
return self::CURRENCY_SYMBOL . number_format($amount, 2, ',', '.');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format berat
|
|
||||||
*/
|
|
||||||
private function formatWeight(float $weight): string
|
|
||||||
{
|
|
||||||
return number_format($weight, 2, ',', '.') . self::WEIGHT_UNIT;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -14,7 +14,7 @@ class TransaksiController extends Controller
|
|||||||
public function index()
|
public function index()
|
||||||
{
|
{
|
||||||
$limit = request()->query('limit', null);
|
$limit = request()->query('limit', null);
|
||||||
$query = Transaksi::with(['kasir', 'sales', 'items.item.produk'])->latest();
|
$query = Transaksi::with(['kasir', 'sales', 'itemTransaksi.item.produk'])->latest();
|
||||||
if ($limit) {
|
if ($limit) {
|
||||||
$query->limit((int)$limit);
|
$query->limit((int)$limit);
|
||||||
}
|
}
|
||||||
|
61
app/Http/Requests/DetailLaporanRequest.php
Normal file
61
app/Http/Requests/DetailLaporanRequest.php
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class DetailLaporanRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'tanggal' => 'required|date_format:Y-m-d|before_or_equal:today',
|
||||||
|
'sales_id' => 'nullable|integer|exists:sales,id',
|
||||||
|
'nampan_id' => 'nullable|integer',
|
||||||
|
'produk_id' => 'nullable|integer|exists:produks,id',
|
||||||
|
'nama_pembeli' => 'nullable|string|max:255',
|
||||||
|
'page' => 'nullable|integer|min:1',
|
||||||
|
'per_page' => 'nullable|integer|min:1|max:100',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get custom messages for validator errors.
|
||||||
|
*/
|
||||||
|
public function messages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'tanggal.required' => 'Tanggal harus diisi',
|
||||||
|
'tanggal.date_format' => 'Format tanggal harus Y-m-d',
|
||||||
|
'tanggal.before_or_equal' => 'Tanggal tidak boleh lebih dari hari ini',
|
||||||
|
'sales_id.exists' => 'Sales tidak ditemukan',
|
||||||
|
'produk_id.exists' => 'Produk tidak ditemukan',
|
||||||
|
'nama_pembeli.max' => 'Nama pembeli maksimal 255 karakter',
|
||||||
|
'page.min' => 'Page minimal 1',
|
||||||
|
'per_page.min' => 'Per page minimal 1',
|
||||||
|
'per_page.max' => 'Per page maksimal 100',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prepare the data for validation.
|
||||||
|
*/
|
||||||
|
protected function prepareForValidation(): void
|
||||||
|
{
|
||||||
|
$this->merge([
|
||||||
|
'page' => $this->query('page', 1),
|
||||||
|
'per_page' => $this->query('per_page', 15),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
41
app/Http/Requests/ExportLaporanRequest.php
Normal file
41
app/Http/Requests/ExportLaporanRequest.php
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class ExportLaporanRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'filter' => 'required|in:hari,bulan',
|
||||||
|
'format' => 'required|in:pdf,xlsx,csv',
|
||||||
|
'page' => 'nullable',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get custom messages for validator errors.
|
||||||
|
*/
|
||||||
|
public function messages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'filter.required' => 'Filter harus diisi',
|
||||||
|
'filter.in' => 'Filter harus berupa "hari" atau "bulan"',
|
||||||
|
'format.required' => 'Format export harus diisi',
|
||||||
|
'format.in' => 'Format export harus berupa "pdf", "xlsx", atau "csv"',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
@ -1,9 +1,8 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
use App\Models\itemTransaksi;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
|
||||||
class Item extends Model
|
class Item extends Model
|
||||||
@ -14,10 +13,37 @@ class Item extends Model
|
|||||||
'id_produk',
|
'id_produk',
|
||||||
'id_nampan',
|
'id_nampan',
|
||||||
'is_sold',
|
'is_sold',
|
||||||
|
'kode_item', // ✅ ditambahkan agar bisa diisi otomatis
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $hidden = ['created_at', 'updated_at', 'deleted_at'];
|
protected $hidden = ['created_at', 'updated_at', 'deleted_at'];
|
||||||
|
|
||||||
|
// ✅ Auto-generate kode_item setiap kali create
|
||||||
|
protected static function boot()
|
||||||
|
{
|
||||||
|
parent::boot();
|
||||||
|
|
||||||
|
static::creating(function ($item) {
|
||||||
|
$prefix = 'ITM';
|
||||||
|
$date = now()->format('Ymd');
|
||||||
|
|
||||||
|
// Cari item terakhir yg dibuat hari ini
|
||||||
|
$lastItem = self::whereDate('created_at', now()->toDateString())
|
||||||
|
->orderBy('id', 'desc')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
$number = 1;
|
||||||
|
if ($lastItem && $lastItem->kode_item) {
|
||||||
|
// Ambil 4 digit terakhir dari kode_item
|
||||||
|
$lastNumber = intval(substr($lastItem->kode_item, -4));
|
||||||
|
$number = $lastNumber + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format: ITM202509090001
|
||||||
|
$item->kode_item = $prefix . $date . str_pad($number, 4, '0', STR_PAD_LEFT);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public function produk()
|
public function produk()
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Produk::class, 'id_produk');
|
return $this->belongsTo(Produk::class, 'id_produk');
|
||||||
|
@ -9,7 +9,9 @@ class Transaksi extends Model
|
|||||||
{
|
{
|
||||||
/** @use HasFactory<\Database\Factories\TransaksiFactory> */
|
/** @use HasFactory<\Database\Factories\TransaksiFactory> */
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
|
'kode_transaksi', // ✅ Tambahin kolom kode transaksi
|
||||||
'id_kasir',
|
'id_kasir',
|
||||||
'id_sales',
|
'id_sales',
|
||||||
'nama_sales',
|
'nama_sales',
|
||||||
@ -23,6 +25,24 @@ class Transaksi extends Model
|
|||||||
|
|
||||||
protected $hidden = ['updated_at', 'deleted_at'];
|
protected $hidden = ['updated_at', 'deleted_at'];
|
||||||
|
|
||||||
|
// ✅ Auto-generate kode_transaksi saat create
|
||||||
|
protected static function boot()
|
||||||
|
{
|
||||||
|
parent::boot();
|
||||||
|
|
||||||
|
// Setelah transaksi berhasil dibuat (sudah punya ID)
|
||||||
|
static::created(function ($transaksi) {
|
||||||
|
if (!$transaksi->kode_transaksi) {
|
||||||
|
$prefix = "TRS";
|
||||||
|
$date = $transaksi->created_at->format('Ymd');
|
||||||
|
$number = str_pad($transaksi->id, 4, '0', STR_PAD_LEFT);
|
||||||
|
|
||||||
|
$transaksi->kode_transaksi = $prefix . $date . $number;
|
||||||
|
$transaksi->save();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public function kasir()
|
public function kasir()
|
||||||
{
|
{
|
||||||
return $this->belongsTo(User::class, 'id_kasir');
|
return $this->belongsTo(User::class, 'id_kasir');
|
||||||
|
143
app/Repositories/TransaksiRepository.php
Normal file
143
app/Repositories/TransaksiRepository.php
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Repositories;
|
||||||
|
|
||||||
|
use App\Models\Transaksi;
|
||||||
|
use App\Helpers\LaporanHelper;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Carbon\CarbonPeriod;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Pagination\LengthAwarePaginator;
|
||||||
|
|
||||||
|
class TransaksiRepository
|
||||||
|
{
|
||||||
|
private const DAILY_PER_PAGE = 7;
|
||||||
|
private const MONTHLY_PER_PAGE = 12;
|
||||||
|
private const PAGINATION_DAYS_LIMIT = 365;
|
||||||
|
|
||||||
|
private LaporanHelper $helper;
|
||||||
|
|
||||||
|
public function __construct(LaporanHelper $helper)
|
||||||
|
{
|
||||||
|
$this->helper = $helper;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function processLaporanHarian(Collection $allSalesNames, int $page = 1, bool $limitPagination = true)
|
||||||
|
{
|
||||||
|
$perPage = self::DAILY_PER_PAGE;
|
||||||
|
|
||||||
|
if ($limitPagination) {
|
||||||
|
$endDate = Carbon::today()->subDays(($page - 1) * $perPage);
|
||||||
|
$startDate = $endDate->copy()->subDays($perPage - 1);
|
||||||
|
$totalHariUntukPaginasi = self::PAGINATION_DAYS_LIMIT;
|
||||||
|
} else {
|
||||||
|
$endDate = Carbon::today();
|
||||||
|
$startDate = $endDate->copy()->subYear()->addDay();
|
||||||
|
$totalHariUntukPaginasi = $endDate->diffInDays($startDate) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
$transaksis = Transaksi::with(['itemTransaksi.item.produk'])
|
||||||
|
->whereBetween('created_at', [$startDate->startOfDay(), $endDate->endOfDay()])
|
||||||
|
->orderBy('created_at', 'desc')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$transaksisByDay = $transaksis->groupBy(function ($transaksi) {
|
||||||
|
return Carbon::parse($transaksi->created_at)->format('Y-m-d');
|
||||||
|
});
|
||||||
|
|
||||||
|
$period = CarbonPeriod::create($startDate, $endDate);
|
||||||
|
$laporan = [];
|
||||||
|
|
||||||
|
foreach ($period as $date) {
|
||||||
|
$dateString = $date->format('Y-m-d');
|
||||||
|
$tanggalFormatted = $date->isoFormat('dddd, D MMMM Y');
|
||||||
|
|
||||||
|
if (isset($transaksisByDay[$dateString])) {
|
||||||
|
$transaksisPerTanggal = $transaksisByDay[$dateString];
|
||||||
|
$salesDataTransaksi = $transaksisPerTanggal->groupBy('nama_sales')
|
||||||
|
->map(fn($transaksisPerSales) => $this->helper->hitungDataSales($transaksisPerSales));
|
||||||
|
|
||||||
|
$fullSalesData = $allSalesNames->map(function ($namaSales) use ($salesDataTransaksi) {
|
||||||
|
return $salesDataTransaksi->get($namaSales) ?? $this->helper->defaultSalesData($namaSales);
|
||||||
|
});
|
||||||
|
|
||||||
|
$totalItem = $fullSalesData->sum('item_terjual');
|
||||||
|
$totalBerat = $fullSalesData->sum('berat_terjual_raw');
|
||||||
|
$totalPendapatan = $fullSalesData->sum('pendapatan_raw');
|
||||||
|
|
||||||
|
$laporan[$dateString] = [
|
||||||
|
'tanggal' => $tanggalFormatted,
|
||||||
|
'total_item_terjual' => $totalItem > 0 ? $totalItem : LaporanHelper::DEFAULT_DISPLAY,
|
||||||
|
'total_berat' => $totalBerat > 0 ? $this->helper->formatWeight($totalBerat) : LaporanHelper::DEFAULT_DISPLAY,
|
||||||
|
'total_pendapatan' => $totalPendapatan > 0 ? $this->helper->formatCurrency($totalPendapatan) : LaporanHelper::DEFAULT_DISPLAY,
|
||||||
|
'sales' => $this->helper->formatSalesDataValues($fullSalesData)->values(),
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
$laporan[$dateString] = [
|
||||||
|
'tanggal' => $tanggalFormatted,
|
||||||
|
'total_item_terjual' => LaporanHelper::DEFAULT_DISPLAY,
|
||||||
|
'total_berat' => LaporanHelper::DEFAULT_DISPLAY,
|
||||||
|
'total_pendapatan' => LaporanHelper::DEFAULT_DISPLAY,
|
||||||
|
'sales' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($limitPagination) {
|
||||||
|
return new LengthAwarePaginator(
|
||||||
|
array_reverse(array_values($laporan)),
|
||||||
|
$totalHariUntukPaginasi,
|
||||||
|
$perPage,
|
||||||
|
$page,
|
||||||
|
['path' => request()->url(), 'query' => request()->query()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return collect(array_reverse(array_values($laporan)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function processLaporanBulanan(Collection $allSalesNames, int $page = 1, bool $limitPagination = true)
|
||||||
|
{
|
||||||
|
$perPage = self::MONTHLY_PER_PAGE;
|
||||||
|
|
||||||
|
$transaksis = Transaksi::with(['itemTransaksi.item.produk'])
|
||||||
|
->orderBy('created_at', 'desc')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$laporan = $transaksis->groupBy(function ($transaksi) {
|
||||||
|
return Carbon::parse($transaksi->created_at)->format('F Y');
|
||||||
|
})->map(function ($transaksisPerTanggal, $tanggal) use ($allSalesNames) {
|
||||||
|
$salesDataTransaksi = $transaksisPerTanggal
|
||||||
|
->groupBy('nama_sales')
|
||||||
|
->map(fn($transaksisPerSales) => $this->helper->hitungDataSales($transaksisPerSales));
|
||||||
|
|
||||||
|
$fullSalesData = $allSalesNames->map(function ($namaSales) use ($salesDataTransaksi) {
|
||||||
|
return $salesDataTransaksi->get($namaSales) ?? $this->helper->defaultSalesData($namaSales);
|
||||||
|
});
|
||||||
|
|
||||||
|
$totalItem = $fullSalesData->sum('item_terjual');
|
||||||
|
$totalBerat = $fullSalesData->sum('berat_terjual_raw');
|
||||||
|
$totalPendapatan = $fullSalesData->sum('pendapatan_raw');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'tanggal' => $tanggal,
|
||||||
|
'total_item_terjual' => $totalItem > 0 ? $totalItem : LaporanHelper::DEFAULT_DISPLAY,
|
||||||
|
'total_berat' => $totalBerat > 0 ? $this->helper->formatWeight($totalBerat) : LaporanHelper::DEFAULT_DISPLAY,
|
||||||
|
'total_pendapatan' => $totalPendapatan > 0 ? $this->helper->formatCurrency($totalPendapatan) : LaporanHelper::DEFAULT_DISPLAY,
|
||||||
|
'sales' => $this->helper->formatSalesDataValues($fullSalesData)->values(),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
if ($limitPagination) {
|
||||||
|
return new LengthAwarePaginator(
|
||||||
|
$laporan->forPage($page, $perPage)->values(),
|
||||||
|
$laporan->count(),
|
||||||
|
$perPage,
|
||||||
|
$page,
|
||||||
|
['path' => request()->url(), 'query' => request()->query()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $laporan->values();
|
||||||
|
}
|
||||||
|
}
|
422
app/Services/LaporanService.php
Normal file
422
app/Services/LaporanService.php
Normal file
@ -0,0 +1,422 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\ItemTransaksi;
|
||||||
|
use App\Models\Produk;
|
||||||
|
use App\Models\Transaksi;
|
||||||
|
use App\Models\Sales;
|
||||||
|
use App\Models\Nampan;
|
||||||
|
use App\Repositories\TransaksiRepository;
|
||||||
|
use App\Helpers\LaporanHelper;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Maatwebsite\Excel\Facades\Excel;
|
||||||
|
use Barryvdh\DomPDF\Facade\Pdf;
|
||||||
|
use App\Exports\RingkasanExport;
|
||||||
|
use App\Exports\DetailProdukExport;
|
||||||
|
use App\Exports\DetailNampanExport;
|
||||||
|
|
||||||
|
class LaporanService
|
||||||
|
{
|
||||||
|
private const CACHE_TTL = 300; // 5 menit
|
||||||
|
private const DEFAULT_PER_PAGE = 15;
|
||||||
|
private const MAX_PER_PAGE = 100;
|
||||||
|
private const DAILY_PER_PAGE = 7;
|
||||||
|
private const MONTHLY_PER_PAGE = 12;
|
||||||
|
private const PAGINATION_DAYS_LIMIT = 365;
|
||||||
|
|
||||||
|
private TransaksiRepository $transaksiRepo;
|
||||||
|
private LaporanHelper $helper;
|
||||||
|
|
||||||
|
public function __construct(TransaksiRepository $transaksiRepo, LaporanHelper $helper)
|
||||||
|
{
|
||||||
|
$this->transaksiRepo = $transaksiRepo;
|
||||||
|
$this->helper = $helper;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRingkasan(string $filter, int $page)
|
||||||
|
{
|
||||||
|
$cacheKey = "laporan_ringkasan_{$filter}_page_{$page}";
|
||||||
|
|
||||||
|
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($filter, $page) {
|
||||||
|
$allSalesNames = $this->getAllSalesNames();
|
||||||
|
|
||||||
|
if ($filter === 'hari') {
|
||||||
|
return $this->processLaporanHarian($allSalesNames, $page, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->processLaporanBulanan($allSalesNames, $page, true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get paginated sales detail aggregated by product.
|
||||||
|
*
|
||||||
|
* @param array $params Filter parameters (tanggal, sales_id, nampan_id, nama_pembeli, page, per_page)
|
||||||
|
* @return array Report data structure
|
||||||
|
* @throws \Exception
|
||||||
|
*/
|
||||||
|
public function getDetailPerProduk(array $params)
|
||||||
|
{
|
||||||
|
$tanggal = Carbon::parse($params['tanggal']);
|
||||||
|
$page = $params['page'] ?? 1;
|
||||||
|
$perPage = $params['per_page'] ?? self::DEFAULT_PER_PAGE;
|
||||||
|
|
||||||
|
// --- Step 1: Calculate overall totals for all filtered items ---
|
||||||
|
// We need a separate query for totals that is not affected by pagination.
|
||||||
|
$totalsQuery = $this->buildBaseItemQuery($tanggal);
|
||||||
|
$this->applyFilters($totalsQuery, $params);
|
||||||
|
|
||||||
|
$totalsResult = $totalsQuery->select(
|
||||||
|
DB::raw('COUNT(item_transaksis.id) as total_item_terjual'),
|
||||||
|
DB::raw('COALESCE(SUM(produks.berat), 0) as total_berat_terjual'),
|
||||||
|
DB::raw('COALESCE(SUM(item_transaksis.harga_deal), 0) as total_pendapatan')
|
||||||
|
)->first();
|
||||||
|
|
||||||
|
$rekapHarian = [
|
||||||
|
'total_item_terjual' => (int) $totalsResult->total_item_terjual,
|
||||||
|
'total_berat_terjual' => $this->helper->formatWeight($totalsResult->total_berat_terjual), // Assuming formatting helper
|
||||||
|
'total_pendapatan' => $this->helper->formatCurrency($totalsResult->total_pendapatan), // Assuming formatting helper
|
||||||
|
];
|
||||||
|
|
||||||
|
// --- Step 2: Build the filtered sales data subquery ---
|
||||||
|
$salesSubQuery = $this->buildBaseItemQuery($tanggal)
|
||||||
|
->select(
|
||||||
|
'produks.id as id_produk',
|
||||||
|
DB::raw('COUNT(item_transaksis.id) as jumlah_item_terjual'),
|
||||||
|
DB::raw('COALESCE(SUM(produks.berat), 0) as berat_terjual'),
|
||||||
|
DB::raw('COALESCE(SUM(item_transaksis.harga_deal), 0) as pendapatan')
|
||||||
|
)
|
||||||
|
->groupBy('produks.id');
|
||||||
|
// Apply filters to the subquery
|
||||||
|
$this->applyFilters($salesSubQuery, $params);
|
||||||
|
|
||||||
|
// --- Step 3: Fetch paginated products and LEFT JOIN the sales data subquery ---
|
||||||
|
$semuaProdukPaginated = Produk::select(
|
||||||
|
'produks.id',
|
||||||
|
'produks.nama as nama_produk',
|
||||||
|
'sales_data.jumlah_item_terjual',
|
||||||
|
'sales_data.berat_terjual',
|
||||||
|
'sales_data.pendapatan'
|
||||||
|
)
|
||||||
|
->leftJoinSub($salesSubQuery, 'sales_data', function ($join) {
|
||||||
|
$join->on('produks.id', '=', 'sales_data.id_produk');
|
||||||
|
})
|
||||||
|
->orderBy('produks.nama')
|
||||||
|
->paginate($perPage, ['*'], 'page', $page);
|
||||||
|
|
||||||
|
// --- Step 4: Map results for final presentation ---
|
||||||
|
$detailItem = $semuaProdukPaginated->map(function ($item) {
|
||||||
|
return [
|
||||||
|
'nama_produk' => $item->nama_produk,
|
||||||
|
'jumlah_item_terjual' => $item->jumlah_item_terjual ? (int) $item->jumlah_item_terjual : 0, // Use 0 or default display value
|
||||||
|
'berat_terjual' => $item->berat_terjual ? $this->helper->formatWeight($item->berat_terjual) : '-',
|
||||||
|
'pendapatan' => $item->pendapatan ? $this->helper->formatCurrency($item->pendapatan) : '-',
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Step 5: Assemble final response ---
|
||||||
|
$filterInfo = $this->helper->buildProdukFilterInfo($tanggal, $params);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'filter' => $filterInfo,
|
||||||
|
'rekap_harian' => $rekapHarian,
|
||||||
|
'produk' => $detailItem,
|
||||||
|
'pagination' => $this->helper->buildPaginationInfo($semuaProdukPaginated),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDetailPerNampan(array $params)
|
||||||
|
{
|
||||||
|
$tanggal = Carbon::parse($params['tanggal']);
|
||||||
|
$page = $params['page'] ?? 1;
|
||||||
|
$perPage = $params['per_page'] ?? self::DEFAULT_PER_PAGE;
|
||||||
|
|
||||||
|
$nampanTerjualQuery = $this->buildBaseItemQuery($tanggal);
|
||||||
|
$this->applyNampanFilters($nampanTerjualQuery, $params);
|
||||||
|
|
||||||
|
$nampanTerjual = $nampanTerjualQuery
|
||||||
|
->leftJoin('nampans', 'items.id_nampan', '=', 'nampans.id')
|
||||||
|
->select(
|
||||||
|
DB::raw('COALESCE(items.id_nampan, 0) as id_nampan'),
|
||||||
|
DB::raw('COALESCE(nampans.nama, "Brankas") as nama_nampan'),
|
||||||
|
DB::raw('COUNT(item_transaksis.id) as jumlah_item_terjual'),
|
||||||
|
DB::raw('COALESCE(SUM(produks.berat), 0) as berat_terjual'),
|
||||||
|
DB::raw('COALESCE(SUM(item_transaksis.harga_deal), 0) as pendapatan')
|
||||||
|
)
|
||||||
|
->groupBy('id_nampan', 'nama_nampan')
|
||||||
|
->get()
|
||||||
|
->keyBy('id_nampan');
|
||||||
|
|
||||||
|
$totals = $this->helper->calculateTotals($nampanTerjual);
|
||||||
|
$semuaNampanPaginated = $this->helper->getAllNampanWithPagination($page, $perPage);
|
||||||
|
$detailItem = $this->helper->mapNampanWithSalesData($semuaNampanPaginated, $nampanTerjual);
|
||||||
|
$filterInfo = $this->helper->buildNampanFilterInfo($tanggal, $params);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'filter' => $filterInfo,
|
||||||
|
'rekap_harian' => $totals,
|
||||||
|
'nampan' => $detailItem->values(),
|
||||||
|
'pagination' => $this->helper->buildPaginationInfo($semuaNampanPaginated),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function exportRingkasan(array $params)
|
||||||
|
{
|
||||||
|
$filter = $params['filter'];
|
||||||
|
$format = $params['format'];
|
||||||
|
$page = $params['page'] ?? 1;
|
||||||
|
|
||||||
|
$allSalesNames = $this->getAllSalesNames();
|
||||||
|
|
||||||
|
if ($filter === 'hari') {
|
||||||
|
// Tar kalau mau ubah eksport laporan setiap hari, param ke-3 jadiin false #Bagas
|
||||||
|
$data = $this->processLaporanHarian($allSalesNames, $page, true);
|
||||||
|
} else {
|
||||||
|
$data = $this->processLaporanBulanan($allSalesNames, $page, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
$fileName = "laporan_ringkasan_{$filter}_" . Carbon::now()->format('Ymd') . ".{$format}";
|
||||||
|
|
||||||
|
if ($format === 'pdf') {
|
||||||
|
$viewData = method_exists($data, 'items') ? $data->items() : $data;
|
||||||
|
|
||||||
|
$pdf = PDF::loadView('exports.ringkasan_pdf', [
|
||||||
|
'data' => $viewData,
|
||||||
|
'filter' => $filter
|
||||||
|
]);
|
||||||
|
$pdf->setPaper('a4', 'potrait');
|
||||||
|
return $pdf->download($fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Excel::download(new RingkasanExport($data, $page), $fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Method baru untuk export per produk
|
||||||
|
public function exportPerProduk(array $params)
|
||||||
|
{
|
||||||
|
$tanggal = $params['tanggal'];
|
||||||
|
$format = $params['format'];
|
||||||
|
|
||||||
|
// Get all data tanpa pagination karena untuk export
|
||||||
|
$allParams = $params;
|
||||||
|
unset($allParams['page'], $allParams['per_page']);
|
||||||
|
|
||||||
|
// Get data dengan semua produk (tanpa pagination)
|
||||||
|
$data = $this->getDetailPerProdukForExport($allParams);
|
||||||
|
|
||||||
|
$fileName = "laporan_per_produk_{$tanggal}_" . Carbon::now()->format('Ymd') . ".{$format}";
|
||||||
|
|
||||||
|
if ($format === 'pdf') {
|
||||||
|
$pdf = PDF::loadView('exports.perproduk_pdf', [
|
||||||
|
'data' => $data,
|
||||||
|
'title' => 'Laporan Detail Per Produk'
|
||||||
|
]);
|
||||||
|
$pdf->setPaper('a4', 'potrait');
|
||||||
|
return $pdf->download($fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Excel::download(new DetailProdukExport($data), $fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function exportPerNampan(array $params)
|
||||||
|
{
|
||||||
|
$tanggal = $params['tanggal'];
|
||||||
|
$format = $params['format'];
|
||||||
|
|
||||||
|
// Get all data tanpa pagination karena untuk export
|
||||||
|
$allParams = $params;
|
||||||
|
unset($allParams['page'], $allParams['per_page']);
|
||||||
|
|
||||||
|
// Get data dengan semua nampan (tanpa pagination)
|
||||||
|
$data = $this->getDetailPerNampanForExport($allParams);
|
||||||
|
|
||||||
|
$fileName = "laporan_per_nampan_{$tanggal}_" . Carbon::now()->format('Ymd') . ".{$format}";
|
||||||
|
|
||||||
|
if ($format === 'pdf') {
|
||||||
|
$pdf = PDF::loadView('exports.pernampan_pdf', [
|
||||||
|
'data' => $data,
|
||||||
|
'title' => 'Laporan Detail Per Nampan'
|
||||||
|
]);
|
||||||
|
$pdf->setPaper('a4', 'potrait');
|
||||||
|
return $pdf->download($fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Excel::download(new DetailNampanExport($data), $fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper method untuk get data produk tanpa pagination (untuk export)
|
||||||
|
private function getDetailPerProdukForExport(array $params)
|
||||||
|
{
|
||||||
|
$tanggal = Carbon::parse($params['tanggal']);
|
||||||
|
|
||||||
|
$produkTerjualQuery = $this->buildBaseItemQuery($tanggal);
|
||||||
|
$this->applyFilters($produkTerjualQuery, $params);
|
||||||
|
|
||||||
|
$produkTerjual = $produkTerjualQuery
|
||||||
|
->select(
|
||||||
|
'produks.id as id_produk',
|
||||||
|
'produks.nama as nama_produk',
|
||||||
|
DB::raw('COUNT(item_transaksis.id) as jumlah_item_terjual'),
|
||||||
|
DB::raw('COALESCE(SUM(produks.berat), 0) as berat_terjual'),
|
||||||
|
DB::raw('COALESCE(SUM(item_transaksis.harga_deal), 0) as pendapatan')
|
||||||
|
)
|
||||||
|
->groupBy('produks.id', 'produks.nama')
|
||||||
|
->get()
|
||||||
|
->keyBy('id_produk');
|
||||||
|
|
||||||
|
$totals = $this->helper->calculateTotals($produkTerjual);
|
||||||
|
|
||||||
|
// Get all products without pagination
|
||||||
|
$semuaProduk = Produk::select('id', 'nama')->orderBy('nama')->get();
|
||||||
|
|
||||||
|
$detailItem = collect($semuaProduk)->map(function ($item) use ($produkTerjual) {
|
||||||
|
if ($produkTerjual->has($item->id)) {
|
||||||
|
$dataTerjual = $produkTerjual->get($item->id);
|
||||||
|
return [
|
||||||
|
'nama_produk' => $item->nama,
|
||||||
|
'jumlah_item_terjual' => (int) $dataTerjual->jumlah_item_terjual,
|
||||||
|
'berat_terjual' => $this->helper->formatWeight($dataTerjual->berat_terjual),
|
||||||
|
'pendapatan' => $this->helper->formatCurrency($dataTerjual->pendapatan),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'nama_produk' => $item->nama,
|
||||||
|
'jumlah_item_terjual' => LaporanHelper::DEFAULT_DISPLAY,
|
||||||
|
'berat_terjual' => LaporanHelper::DEFAULT_DISPLAY,
|
||||||
|
'pendapatan' => LaporanHelper::DEFAULT_DISPLAY,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
$filterInfo = $this->helper->buildProdukFilterInfo($tanggal, $params);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'filter' => $filterInfo,
|
||||||
|
'rekap_harian' => $totals,
|
||||||
|
'produk' => $detailItem->values(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper method untuk get data nampan tanpa pagination (untuk export)
|
||||||
|
private function getDetailPerNampanForExport(array $params)
|
||||||
|
{
|
||||||
|
$tanggal = Carbon::parse($params['tanggal']);
|
||||||
|
|
||||||
|
$nampanTerjualQuery = $this->buildBaseItemQuery($tanggal);
|
||||||
|
$this->applyNampanFilters($nampanTerjualQuery, $params);
|
||||||
|
|
||||||
|
$nampanTerjual = $nampanTerjualQuery
|
||||||
|
->leftJoin('nampans', 'items.id_nampan', '=', 'nampans.id')
|
||||||
|
->select(
|
||||||
|
DB::raw('COALESCE(items.id_nampan, 0) as id_nampan'),
|
||||||
|
DB::raw('COALESCE(nampans.nama, "Brankas") as nama_nampan'),
|
||||||
|
DB::raw('COUNT(item_transaksis.id) as jumlah_item_terjual'),
|
||||||
|
DB::raw('COALESCE(SUM(produks.berat), 0) as berat_terjual'),
|
||||||
|
DB::raw('COALESCE(SUM(item_transaksis.harga_deal), 0) as pendapatan')
|
||||||
|
)
|
||||||
|
->groupBy('id_nampan', 'nama_nampan')
|
||||||
|
->get()
|
||||||
|
->keyBy('id_nampan');
|
||||||
|
|
||||||
|
$totals = $this->helper->calculateTotals($nampanTerjual);
|
||||||
|
|
||||||
|
// Get all nampan without pagination
|
||||||
|
$semuaNampan = Nampan::select('id', 'nama')->orderBy('nama')->get();
|
||||||
|
$brankasEntry = (object) ['id' => 0, 'nama' => 'Brankas'];
|
||||||
|
$semuaNampanCollection = $semuaNampan->prepend($brankasEntry);
|
||||||
|
|
||||||
|
$detailItem = $semuaNampanCollection->map(function ($item) use ($nampanTerjual) {
|
||||||
|
if ($nampanTerjual->has($item->id)) {
|
||||||
|
$dataTerjual = $nampanTerjual->get($item->id);
|
||||||
|
return [
|
||||||
|
'nama_nampan' => $item->nama,
|
||||||
|
'jumlah_item_terjual' => (int) $dataTerjual->jumlah_item_terjual,
|
||||||
|
'berat_terjual' => $this->helper->formatWeight($dataTerjual->berat_terjual),
|
||||||
|
'pendapatan' => $this->helper->formatCurrency($dataTerjual->pendapatan),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'nama_nampan' => $item->nama,
|
||||||
|
'jumlah_item_terjual' => LaporanHelper::DEFAULT_DISPLAY,
|
||||||
|
'berat_terjual' => LaporanHelper::DEFAULT_DISPLAY,
|
||||||
|
'pendapatan' => LaporanHelper::DEFAULT_DISPLAY,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
$filterInfo = $this->helper->buildNampanFilterInfo($tanggal, $params);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'filter' => $filterInfo,
|
||||||
|
'rekap_harian' => $totals,
|
||||||
|
'nampan' => $detailItem->values(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getAllSalesNames(): Collection
|
||||||
|
{
|
||||||
|
return Cache::remember('all_sales_names', self::CACHE_TTL, function () {
|
||||||
|
return Transaksi::select('nama_sales')->distinct()->pluck('nama_sales');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private function processLaporanHarian(Collection $allSalesNames, int $page = 1, bool $limitPagination = true)
|
||||||
|
{
|
||||||
|
return $this->transaksiRepo->processLaporanHarian($allSalesNames, $page, $limitPagination);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function processLaporanBulanan(Collection $allSalesNames, int $page = 1, bool $limitPagination = true)
|
||||||
|
{
|
||||||
|
return $this->transaksiRepo->processLaporanBulanan($allSalesNames, $page, $limitPagination);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildBaseItemQuery(Carbon $carbonDate)
|
||||||
|
{
|
||||||
|
return ItemTransaksi::query()
|
||||||
|
->join('items', 'item_transaksis.id_item', '=', 'items.id')
|
||||||
|
->join('produks', 'items.id_produk', '=', 'produks.id')
|
||||||
|
->join('transaksis', 'item_transaksis.id_transaksi', '=', 'transaksis.id')
|
||||||
|
->whereDate('transaksis.created_at', $carbonDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function applyFilters($query, array $params): void
|
||||||
|
{
|
||||||
|
if (!empty($params['sales_id'])) {
|
||||||
|
$query->join('sales', 'transaksis.id_sales', '=', 'sales.id')
|
||||||
|
->where('sales.id', $params['sales_id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($params['nampan_id'])) {
|
||||||
|
if ($params['nampan_id'] == 0) {
|
||||||
|
$query->whereNull('items.id_nampan');
|
||||||
|
} else {
|
||||||
|
$query->where('items.id_nampan', $params['nampan_id']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($params['nama_pembeli'])) {
|
||||||
|
$query->where('transaksis.nama_pembeli', 'like', "%{$params['nama_pembeli']}%");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function applyNampanFilters($query, array $params): void
|
||||||
|
{
|
||||||
|
if (!empty($params['sales_id'])) {
|
||||||
|
$query->join('sales', 'transaksis.id_sales', '=', 'sales.id')
|
||||||
|
->where('sales.id', $params['sales_id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($params['produk_id'])) {
|
||||||
|
$query->where('produks.id', $params['produk_id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($params['nama_pembeli'])) {
|
||||||
|
$query->where('transaksis.nama_pembeli', 'like', "%{$params['nama_pembeli']}%");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -4,30 +4,26 @@ namespace Database\Factories;
|
|||||||
|
|
||||||
use App\Models\Sales;
|
use App\Models\Sales;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Models\Transaksi;
|
||||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
/**
|
|
||||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Transaksi>
|
|
||||||
*/
|
|
||||||
class TransaksiFactory extends Factory
|
class TransaksiFactory extends Factory
|
||||||
{
|
{
|
||||||
/**
|
protected $model = Transaksi::class;
|
||||||
* Define the model's default state.
|
|
||||||
*
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public function definition(): array
|
public function definition(): array
|
||||||
{
|
{
|
||||||
|
|
||||||
$sales = Sales::inRandomOrder()->first();
|
$sales = Sales::inRandomOrder()->first();
|
||||||
$kasir = User::inRandomOrder()->first();
|
$kasir = User::inRandomOrder()->first();
|
||||||
|
|
||||||
$date = $this->faker->dateTimeBetween('-3 months');
|
$date = $this->faker->dateTimeBetween('-3 months');
|
||||||
$ongkos_bikin = $this->faker->numberBetween(8, 12) * 10000;
|
$ongkos_bikin = $this->faker->numberBetween(8, 12) * 10000;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'id_kasir' => $kasir?->id,
|
'id_kasir' => $kasir?->id,
|
||||||
'id_sales' => $sales?->id,
|
'id_sales' => $sales?->id,
|
||||||
'nama_sales' => $sales?->nama,
|
'nama_sales' => $sales?->nama,
|
||||||
|
'kode_transaksi' => 'bwabwa' . $this->faker->unique()->numberBetween(1, 9999), // temporary, will be updated in configure()
|
||||||
'nama_pembeli' => $this->faker->name(),
|
'nama_pembeli' => $this->faker->name(),
|
||||||
'no_hp' => $this->faker->phoneNumber(),
|
'no_hp' => $this->faker->phoneNumber(),
|
||||||
'alamat' => $this->faker->address(),
|
'alamat' => $this->faker->address(),
|
||||||
@ -37,4 +33,17 @@ class TransaksiFactory extends Factory
|
|||||||
'updated_at' => $date,
|
'updated_at' => $date,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function configure()
|
||||||
|
{
|
||||||
|
return $this->afterCreating(function (Transaksi $transaksi) {
|
||||||
|
// generate kode transaksi TRS202509090001
|
||||||
|
$prefix = "TRS";
|
||||||
|
$date = $transaksi->created_at->format('Ymd');
|
||||||
|
$number = str_pad($transaksi->id, 4, '0', STR_PAD_LEFT);
|
||||||
|
|
||||||
|
$transaksi->kode_transaksi = $prefix . $date . $number;
|
||||||
|
$transaksi->save();
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up()
|
||||||
|
{
|
||||||
|
Schema::table('items', function (Blueprint $table) {
|
||||||
|
$table->string('kode_item')->unique()->after('id');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down()
|
||||||
|
{
|
||||||
|
Schema::table('items', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('kode_item');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up()
|
||||||
|
{
|
||||||
|
Schema::table('transaksis', function (Blueprint $table) {
|
||||||
|
$table->string('kode_transaksi')->unique()->after('id');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down()
|
||||||
|
{
|
||||||
|
Schema::table('transaksis', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('kode_transaksi');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
};
|
@ -20,7 +20,7 @@ class DatabaseSeeder extends Seeder
|
|||||||
public function run(): void
|
public function run(): void
|
||||||
{
|
{
|
||||||
User::factory()->create([
|
User::factory()->create([
|
||||||
'nama' => 'iwan',
|
'nama' => 'andre',
|
||||||
'role' => 'owner',
|
'role' => 'owner',
|
||||||
'password' => bcrypt('123123'),
|
'password' => bcrypt('123123'),
|
||||||
]);
|
]);
|
||||||
|
@ -40,10 +40,20 @@
|
|||||||
<div class="font-semibold text-D">{{ data.rekap_harian.total_pendapatan }}</div>
|
<div class="font-semibold text-D">{{ data.rekap_harian.total_pendapatan }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<div class="flex items-center justify-center w-full h-30">
|
||||||
|
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-D"></div>
|
||||||
|
<span class="ml-2 text-gray-600">Memuat data...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Export Dropdown -->
|
<!-- Export Dropdown -->
|
||||||
<div class="relative w-40" ref="exportDropdownRef">
|
<div class="relative w-40" ref="exportDropdownRef">
|
||||||
<button @click="isExportOpen = !isExportOpen" type="button"
|
<button v-if="loadingExport" type="button"
|
||||||
|
class="flex items-center w-full px-3 py-2 text-sm text-left bg-C/80 border rounded-md border-C/80" disabled>
|
||||||
|
<i class="fas fa-spinner fa-spin mr-2"></i> Memproses...
|
||||||
|
</button>
|
||||||
|
<button v-else @click="isExportOpen = !isExportOpen" type="button"
|
||||||
class="flex items-center justify-between w-full px-3 py-2 text-sm text-left bg-C border rounded-md border-C hover:bg-C/80 focus:outline-none">
|
class="flex items-center justify-between w-full px-3 py-2 text-sm text-left bg-C border rounded-md border-C hover:bg-C/80 focus:outline-none">
|
||||||
<span :class="{ 'text-D': !exportFormat }">{{ selectedExportLabel }}</span>
|
<span :class="{ 'text-D': !exportFormat }">{{ selectedExportLabel }}</span>
|
||||||
<i class="fas fa-chevron-down"></i>
|
<i class="fas fa-chevron-down"></i>
|
||||||
@ -113,8 +123,7 @@
|
|||||||
<td class="border-x border-C px-3 py-2">{{ item.berat_terjual }}</td>
|
<td class="border-x border-C px-3 py-2">{{ item.berat_terjual }}</td>
|
||||||
<td class="border-x border-C px-3 py-2">
|
<td class="border-x border-C px-3 py-2">
|
||||||
<div class="flex justify-center">
|
<div class="flex justify-center">
|
||||||
<div :ref="el => { if (el) pendapatanElements.push(el) }"
|
<div :ref="el => { if (el) pendapatanElements.push(el) }" :style="pendapatanStyle"
|
||||||
:style="pendapatanStyle"
|
|
||||||
:class="item.pendapatan == '-' ? 'text-center' : 'text-right'">
|
:class="item.pendapatan == '-' ? 'text-center' : 'text-right'">
|
||||||
{{ item.pendapatan }}
|
{{ item.pendapatan }}
|
||||||
</div>
|
</div>
|
||||||
@ -127,8 +136,7 @@
|
|||||||
|
|
||||||
<!-- Pagination -->
|
<!-- Pagination -->
|
||||||
<div v-if="pagination.total > 0 && pagination.last_page > 1" class="flex items-center justify-end gap-2 mt-4">
|
<div v-if="pagination.total > 0 && pagination.last_page > 1" class="flex items-center justify-end gap-2 mt-4">
|
||||||
<button @click="goToPage(pagination.current_page - 1)"
|
<button @click="goToPage(pagination.current_page - 1)" :disabled="pagination.current_page === 1 || loading"
|
||||||
:disabled="pagination.current_page === 1 || loading"
|
|
||||||
class="px-2 py-1 text-sm font-medium border rounded-md bg-C border-C disabled:opacity-50 disabled:cursor-not-allowed hover:bg-C/80">
|
class="px-2 py-1 text-sm font-medium border rounded-md bg-C border-C disabled:opacity-50 disabled:cursor-not-allowed hover:bg-C/80">
|
||||||
Sebelumnya
|
Sebelumnya
|
||||||
</button>
|
</button>
|
||||||
@ -157,7 +165,7 @@ const exportDropdownRef = ref(null);
|
|||||||
|
|
||||||
const exportOptions = ref([
|
const exportOptions = ref([
|
||||||
{ value: 'pdf', label: 'Pdf' },
|
{ value: 'pdf', label: 'Pdf' },
|
||||||
{ value: 'xls', label: 'Excel' },
|
{ value: 'xlsx', label: 'Excel' },
|
||||||
{ value: 'csv', label: 'Csv' }
|
{ value: 'csv', label: 'Csv' }
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@ -165,6 +173,7 @@ const exportFormat = ref(null);
|
|||||||
const tanggalDipilih = ref('');
|
const tanggalDipilih = ref('');
|
||||||
const data = ref(null);
|
const data = ref(null);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
const loadingExport = ref(false);
|
||||||
|
|
||||||
// Sorting state
|
// Sorting state
|
||||||
const sortBy = ref(null);
|
const sortBy = ref(null);
|
||||||
@ -342,7 +351,7 @@ const fetchData = async (page = 1) => {
|
|||||||
if (namaPembeli.value) queryParams += `&nama_pembeli=${encodeURIComponent(namaPembeli.value)}`;
|
if (namaPembeli.value) queryParams += `&nama_pembeli=${encodeURIComponent(namaPembeli.value)}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(`/api/detail-per-nampan?${queryParams}`, {
|
const response = await axios.get(`/api/laporan/detail-per-nampan?${queryParams}`, {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||||
}
|
}
|
||||||
@ -385,10 +394,43 @@ const goToPage = (page) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectExport = (option) => {
|
const selectExport = async (option) => {
|
||||||
exportFormat.value = option.value;
|
exportFormat.value = option.value;
|
||||||
isExportOpen.value = false;
|
isExportOpen.value = false;
|
||||||
alert(`Fitur Belum dikerjakan. Laporan akan diekspor dalam format ${option.label}`);
|
loadingExport.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.get('/api/laporan/export/detail-pernampan', {
|
||||||
|
params: {
|
||||||
|
tanggal: tanggalDipilih.value,
|
||||||
|
sales_id: salesDipilih.value,
|
||||||
|
produk_id: produkDipilih.value,
|
||||||
|
nama_pembeli: namaPembeli.value,
|
||||||
|
format: exportFormat.value,
|
||||||
|
page: pagination.value.current_page,
|
||||||
|
},
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||||
|
},
|
||||||
|
responseType: 'blob',
|
||||||
|
});
|
||||||
|
|
||||||
|
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||||
|
const link = document.createElement('a');
|
||||||
|
const fileName = `laporan_${tanggalDipilih.value}_${new Date().toISOString().split('T')[0]}.${exportFormat.value}`;
|
||||||
|
|
||||||
|
link.href = url;
|
||||||
|
link.setAttribute('download', fileName);
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
|
||||||
|
link.remove();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Gagal mengekspor laporan:", e);
|
||||||
|
} finally {
|
||||||
|
loadingExport.value = false;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeDropdownsOnClickOutside = (event) => {
|
const closeDropdownsOnClickOutside = (event) => {
|
||||||
|
@ -2,30 +2,27 @@
|
|||||||
<div class="my-6">
|
<div class="my-6">
|
||||||
<hr class="border-B mb-5" />
|
<hr class="border-B mb-5" />
|
||||||
|
|
||||||
<!-- Filter Section -->
|
|
||||||
<div class="flex flex-row my-3 overflow-x-auto gap-1 md:gap-5 lg:gap-8">
|
<div class="flex flex-row my-3 overflow-x-auto gap-1 md:gap-5 lg:gap-8">
|
||||||
<div class="mb-3 w-full">
|
<div class="mb-3 w-full min-w-fit">
|
||||||
<label class="text-D/80" for="pilihTanggal">Filter Tanggal:</label>
|
<label class="text-D/80" for="pilihTanggal">Filter Tanggal:</label>
|
||||||
<input type="date" v-model="tanggalDipilih" id="pilihTanggal"
|
<input type="date" v-model="tanggalDipilih" id="pilihTanggal"
|
||||||
class="mt-1 block w-full rounded-md shadow-sm sm:text-sm bg-A text-D border-B focus:border-C focus:ring focus:outline-none focus:ring-D focus:ring-opacity-50 p-2" />
|
class="mt-1 block w-full rounded-md shadow-sm sm:text-sm bg-A text-D border-B focus:border-C focus:ring focus:outline-none focus:ring-D focus:ring-opacity-50 p-2" />
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3 w-full">
|
<div class="mb-3 w-full min-w-fit">
|
||||||
<label class="text-D/80" for="pilihSales">Filter Sales:</label>
|
<label class="text-D/80" for="pilihSales">Filter Sales:</label>
|
||||||
<InputSelect :options="opsiSales" v-model="salesDipilih" id="pilihSales" />
|
<InputSelect :options="opsiSales" v-model="salesDipilih" id="pilihSales" />
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3 w-full">
|
<div class="mb-3 w-full min-w-fit">
|
||||||
<label class="text-D/80" for="pilihPelanggan">Nama Pembeli:</label>
|
<label class="text-D/80" for="pilihPelanggan">Nama Pembeli:</label>
|
||||||
<InputField placeholder="Nama pelanggan" v-model="namaPembeli" id="pilihPelanggan" />
|
<InputField placeholder="Cari nama pelanggan" v-model="namaPembeli" id="pilihPelanggan" />
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3 w-full">
|
<div class="mb-3 w-full min-w-fit">
|
||||||
<label class="text-D/80" for="pilihNampan">Filter Nampan:</label>
|
<label class="text-D/80" for="pilihNampan">Filter Nampan:</label>
|
||||||
<InputSelect :options="opsiNampan" v-model="nampanDipilih" id="pilihNampan" />
|
<InputSelect :options="opsiNampan" v-model="nampanDipilih" id="pilihNampan" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Export Section -->
|
|
||||||
<div class="flex flex-row items-center justify-between mt-5 gap-3">
|
<div class="flex flex-row items-center justify-between mt-5 gap-3">
|
||||||
<!-- Summary Cards -->
|
|
||||||
<div class="flex gap-4" v-if="data?.rekap_harian">
|
<div class="flex gap-4" v-if="data?.rekap_harian">
|
||||||
<div class="bg-A p-3 rounded-md border border-C">
|
<div class="bg-A p-3 rounded-md border border-C">
|
||||||
<div class="text-xs text-D/60">Total Item</div>
|
<div class="text-xs text-D/60">Total Item</div>
|
||||||
@ -41,9 +38,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Export Dropdown -->
|
<div v-else>
|
||||||
|
<div class="flex items-center justify-center w-full h-30">
|
||||||
|
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-D"></div>
|
||||||
|
<span class="ml-2 text-gray-600">Memuat data...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="relative w-40" ref="exportDropdownRef">
|
<div class="relative w-40" ref="exportDropdownRef">
|
||||||
<button @click="isExportOpen = !isExportOpen" type="button"
|
<button v-if="loadingExport" type="button"
|
||||||
|
class="flex items-center w-full px-3 py-2 text-sm text-left bg-C/80 border rounded-md border-C/80" disabled>
|
||||||
|
<i class="fas fa-spinner fa-spin mr-2"></i> Memproses...
|
||||||
|
</button>
|
||||||
|
<button v-else @click="isExportOpen = !isExportOpen" type="button"
|
||||||
class="flex items-center justify-between w-full px-3 py-2 text-sm text-left bg-C border rounded-md border-C hover:bg-C/80 focus:outline-none">
|
class="flex items-center justify-between w-full px-3 py-2 text-sm text-left bg-C border rounded-md border-C hover:bg-C/80 focus:outline-none">
|
||||||
<span :class="{ 'text-D': !exportFormat }">{{ selectedExportLabel }}</span>
|
<span :class="{ 'text-D': !exportFormat }">{{ selectedExportLabel }}</span>
|
||||||
<i class="fas fa-chevron-down"></i>
|
<i class="fas fa-chevron-down"></i>
|
||||||
@ -59,7 +66,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Table Section -->
|
|
||||||
<div class="mt-5 overflow-x-auto">
|
<div class="mt-5 overflow-x-auto">
|
||||||
<table class="w-full border-collapse border border-C rounded-md">
|
<table class="w-full border-collapse border border-C rounded-md">
|
||||||
<thead>
|
<thead>
|
||||||
@ -113,8 +119,7 @@
|
|||||||
<td class="border-x border-C px-3 py-2">{{ item.berat_terjual }}</td>
|
<td class="border-x border-C px-3 py-2">{{ item.berat_terjual }}</td>
|
||||||
<td class="border-x border-C px-3 py-2">
|
<td class="border-x border-C px-3 py-2">
|
||||||
<div class="flex justify-center">
|
<div class="flex justify-center">
|
||||||
<div :ref="el => { if (el) pendapatanElements.push(el) }"
|
<div :ref="el => { if (el) pendapatanElements.push(el) }" :style="pendapatanStyle"
|
||||||
:style="pendapatanStyle"
|
|
||||||
:class="item.pendapatan == '-' ? 'text-center' : 'text-right'">
|
:class="item.pendapatan == '-' ? 'text-center' : 'text-right'">
|
||||||
{{ item.pendapatan }}
|
{{ item.pendapatan }}
|
||||||
</div>
|
</div>
|
||||||
@ -125,10 +130,8 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<!-- Pagination -->
|
|
||||||
<div v-if="pagination.total > 0 && pagination.last_page > 1" class="flex items-center justify-end gap-2 mt-4">
|
<div v-if="pagination.total > 0 && pagination.last_page > 1" class="flex items-center justify-end gap-2 mt-4">
|
||||||
<button @click="goToPage(pagination.current_page - 1)"
|
<button @click="goToPage(pagination.current_page - 1)" :disabled="pagination.current_page === 1 || loading"
|
||||||
:disabled="pagination.current_page === 1 || loading"
|
|
||||||
class="px-2 py-1 text-sm font-medium border rounded-md bg-C border-C disabled:opacity-50 disabled:cursor-not-allowed hover:bg-C/80">
|
class="px-2 py-1 text-sm font-medium border rounded-md bg-C border-C disabled:opacity-50 disabled:cursor-not-allowed hover:bg-C/80">
|
||||||
Sebelumnya
|
Sebelumnya
|
||||||
</button>
|
</button>
|
||||||
@ -157,7 +160,7 @@ const exportDropdownRef = ref(null);
|
|||||||
|
|
||||||
const exportOptions = ref([
|
const exportOptions = ref([
|
||||||
{ value: 'pdf', label: 'Pdf' },
|
{ value: 'pdf', label: 'Pdf' },
|
||||||
{ value: 'xls', label: 'Excel' },
|
{ value: 'xlsx', label: 'Excel' },
|
||||||
{ value: 'csv', label: 'Csv' }
|
{ value: 'csv', label: 'Csv' }
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@ -165,6 +168,7 @@ const exportFormat = ref(null);
|
|||||||
const tanggalDipilih = ref('');
|
const tanggalDipilih = ref('');
|
||||||
const data = ref(null);
|
const data = ref(null);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
const loadingExport = ref(false);
|
||||||
|
|
||||||
// Sorting state
|
// Sorting state
|
||||||
const sortBy = ref(null);
|
const sortBy = ref(null);
|
||||||
@ -269,10 +273,8 @@ watch(produk, async (newValue) => {
|
|||||||
// --- Methods ---
|
// --- Methods ---
|
||||||
const handleSort = (column) => {
|
const handleSort = (column) => {
|
||||||
if (sortBy.value === column) {
|
if (sortBy.value === column) {
|
||||||
// If same column, toggle sort order
|
|
||||||
sortOrder.value = sortOrder.value === 'asc' ? 'desc' : 'asc';
|
sortOrder.value = sortOrder.value === 'asc' ? 'desc' : 'asc';
|
||||||
} else {
|
} else {
|
||||||
// If different column, set new column and default to ascending
|
|
||||||
sortBy.value = column;
|
sortBy.value = column;
|
||||||
sortOrder.value = 'asc';
|
sortOrder.value = 'asc';
|
||||||
}
|
}
|
||||||
@ -320,6 +322,7 @@ const fetchNampan = async () => {
|
|||||||
const nampanData = response.data;
|
const nampanData = response.data;
|
||||||
opsiNampan.value = [
|
opsiNampan.value = [
|
||||||
{ label: 'Semua Nampan', value: null },
|
{ label: 'Semua Nampan', value: null },
|
||||||
|
{ label: 'Brankas', value: 0 },
|
||||||
...nampanData.map(nampan => ({
|
...nampanData.map(nampan => ({
|
||||||
label: nampan.nama,
|
label: nampan.nama,
|
||||||
value: nampan.id,
|
value: nampan.id,
|
||||||
@ -337,12 +340,12 @@ const fetchData = async (page = 1) => {
|
|||||||
pendapatanElements.value = [];
|
pendapatanElements.value = [];
|
||||||
|
|
||||||
let queryParams = `tanggal=${tanggalDipilih.value}&page=${page}`;
|
let queryParams = `tanggal=${tanggalDipilih.value}&page=${page}`;
|
||||||
if (salesDipilih.value) queryParams += `&sales_id=${salesDipilih.value}`;
|
if (salesDipilih.value != null) queryParams += `&sales_id=${salesDipilih.value}`;
|
||||||
if (nampanDipilih.value) queryParams += `&nampan_id=${nampanDipilih.value}`;
|
if (nampanDipilih.value != null) queryParams += `&nampan_id=${nampanDipilih.value}`;
|
||||||
if (namaPembeli.value) queryParams += `&nama_pembeli=${encodeURIComponent(namaPembeli.value)}`;
|
if (namaPembeli.value != null || namaPembeli.value != '') queryParams += `&nama_pembeli=${encodeURIComponent(namaPembeli.value)}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(`/api/detail-per-produk?${queryParams}`, {
|
const response = await axios.get(`/api/laporan/detail-per-produk?${queryParams}`, {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||||
}
|
}
|
||||||
@ -350,7 +353,6 @@ const fetchData = async (page = 1) => {
|
|||||||
|
|
||||||
data.value = response.data;
|
data.value = response.data;
|
||||||
|
|
||||||
// Handle pagination data if provided by backend
|
|
||||||
if (response.data.pagination) {
|
if (response.data.pagination) {
|
||||||
pagination.value = {
|
pagination.value = {
|
||||||
current_page: response.data.pagination.current_page,
|
current_page: response.data.pagination.current_page,
|
||||||
@ -358,7 +360,6 @@ const fetchData = async (page = 1) => {
|
|||||||
total: response.data.pagination.total,
|
total: response.data.pagination.total,
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
// Reset pagination if no pagination data
|
|
||||||
pagination.value = {
|
pagination.value = {
|
||||||
current_page: 1,
|
current_page: 1,
|
||||||
last_page: 1,
|
last_page: 1,
|
||||||
@ -366,7 +367,7 @@ const fetchData = async (page = 1) => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Gagal mengambil data laporan:', error);
|
console.error('Gagal mengambil data laporan produk:', error);
|
||||||
data.value = null;
|
data.value = null;
|
||||||
pagination.value = {
|
pagination.value = {
|
||||||
current_page: 1,
|
current_page: 1,
|
||||||
@ -385,10 +386,42 @@ const goToPage = (page) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectExport = (option) => {
|
const selectExport = async (option) => {
|
||||||
exportFormat.value = option.value;
|
exportFormat.value = option.value;
|
||||||
isExportOpen.value = false;
|
isExportOpen.value = false;
|
||||||
alert(`Fitur Belum dikerjakan. Laporan akan diekspor dalam format ${option.label}`);
|
loadingExport.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.get('/api/laporan/export/detail-perproduk', {
|
||||||
|
params: {
|
||||||
|
tanggal: tanggalDipilih.value,
|
||||||
|
sales_id: salesDipilih.value,
|
||||||
|
nampan_id: nampanDipilih.value,
|
||||||
|
nama_pembeli: namaPembeli.value,
|
||||||
|
format: exportFormat.value,
|
||||||
|
},
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||||
|
},
|
||||||
|
responseType: 'blob',
|
||||||
|
});
|
||||||
|
|
||||||
|
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||||
|
const link = document.createElement('a');
|
||||||
|
const fileName = `laporan_per_produk_${tanggalDipilih.value}_${new Date().toISOString().split('T')[0]}.${exportFormat.value}`;
|
||||||
|
|
||||||
|
link.href = url;
|
||||||
|
link.setAttribute('download', fileName);
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
|
||||||
|
link.remove();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Gagal mengekspor laporan per produk:", e);
|
||||||
|
} finally {
|
||||||
|
loadingExport.value = false
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeDropdownsOnClickOutside = (event) => {
|
const closeDropdownsOnClickOutside = (event) => {
|
||||||
@ -403,7 +436,7 @@ onMounted(() => {
|
|||||||
tanggalDipilih.value = today;
|
tanggalDipilih.value = today;
|
||||||
|
|
||||||
fetchSales();
|
fetchSales();
|
||||||
fetchNampan();
|
fetchNampan(); // Changed from fetchProduk to fetchNampan
|
||||||
|
|
||||||
document.addEventListener('click', closeDropdownsOnClickOutside);
|
document.addEventListener('click', closeDropdownsOnClickOutside);
|
||||||
});
|
});
|
||||||
@ -413,8 +446,8 @@ onUnmounted(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Watch for filter changes
|
// Watch for filter changes
|
||||||
watch([tanggalDipilih, salesDipilih, nampanDipilih, namaPembeli], () => {
|
watch([tanggalDipilih, salesDipilih, nampanDipilih, namaPembeli], () => { // Changed from produkDipilih to nampanDipilih
|
||||||
pagination.value.current_page = 1; // Reset to first page when filters change
|
pagination.value.current_page = 1;
|
||||||
fetchData(1);
|
fetchData(1);
|
||||||
}, { immediate: true });
|
}, { immediate: true });
|
||||||
</script>
|
</script>
|
@ -1,19 +1,21 @@
|
|||||||
<template>
|
<template>
|
||||||
<ConfirmDeleteModal
|
<ConfirmDeleteModal
|
||||||
|
v-if="showDeleteModal"
|
||||||
:isOpen="showDeleteModal"
|
:isOpen="showDeleteModal"
|
||||||
title="Konfirmasi"
|
title="Konfirmasi"
|
||||||
message="Yakin ingin menghapus item ini?"
|
message="Yakin ingin menghapus item ini?"
|
||||||
@confirm="hapusPesanan"
|
@confirm="hapusPesanan"
|
||||||
@cancel="closeDeleteModal"
|
@cancel="closeDeleteModal"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div class="p-2 sm:p-4">
|
||||||
|
<!-- Grid Form & Total -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||||
|
<!-- Input Form -->
|
||||||
|
<div class="order-2 md:order-1 flex flex-col gap-4">
|
||||||
|
<!-- Input Kode Item -->
|
||||||
<div>
|
<div>
|
||||||
<div class="grid grid-cols-2 h-full gap-4 mb-4">
|
<label class="block text-sm font-medium text-D">Kode Item *</label>
|
||||||
<div class="flex flex-col gap-4">
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-D"
|
|
||||||
>Kode Item *</label
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
class="flex flex-row justify-between mt-1 w-full rounded-md bg-A shadow-sm sm:text-sm border-B"
|
class="flex flex-row justify-between mt-1 w-full rounded-md bg-A shadow-sm sm:text-sm border-B"
|
||||||
>
|
>
|
||||||
@ -31,10 +33,7 @@
|
|||||||
>
|
>
|
||||||
<i class="fas fa-arrow-right"></i>
|
<i class="fas fa-arrow-right"></i>
|
||||||
</button>
|
</button>
|
||||||
<div
|
<div v-else class="flex items-center justify-center px-3">
|
||||||
v-else
|
|
||||||
class="flex items-center justify-center px-3"
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
class="rounded-full h-5 w-5 border-b-2 border-A flex items-center justify-center"
|
class="rounded-full h-5 w-5 border-b-2 border-A flex items-center justify-center"
|
||||||
>
|
>
|
||||||
@ -43,10 +42,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Input Harga Jual -->
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-D"
|
<label class="block text-sm font-medium text-D">Harga Jual</label>
|
||||||
>Harga Jual</label
|
|
||||||
>
|
|
||||||
<InputField
|
<InputField
|
||||||
v-model="hargaJual"
|
v-model="hargaJual"
|
||||||
type="number"
|
type="number"
|
||||||
@ -54,31 +53,35 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-between gap-4">
|
<!-- Tombol Aksi -->
|
||||||
|
<div class="flex flex-col sm:flex-row justify-between gap-2">
|
||||||
<button
|
<button
|
||||||
@click="tambahItem"
|
@click="tambahItem"
|
||||||
class="px-4 py-2 rounded-md bg-C text-D font-medium hover:bg-C/80 transition"
|
class="w-full sm:w-auto px-4 py-2 rounded-md bg-C text-D font-medium hover:bg-C/80 transition"
|
||||||
>
|
>
|
||||||
Tambah Item
|
Tambah Item
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@click="konfirmasiPenjualan"
|
@click="konfirmasiPenjualan"
|
||||||
class="px-6 py-2 rounded-md bg-D text-A font-semibold hover:bg-D/80 transition"
|
class="w-full sm:w-auto px-6 py-2 rounded-md bg-D text-A font-semibold hover:bg-D/80 transition"
|
||||||
>
|
>
|
||||||
Lanjut
|
Lanjut
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex pt-10 justify-center">
|
|
||||||
<div class="text-start">
|
<!-- Total -->
|
||||||
|
<div class="order-1 md:order-2 flex flex-col md:flex-row md:items-center md:justify-center gap-1">
|
||||||
|
<div class="text-left md:text-start">
|
||||||
<span class="block text-gray-600 font-medium">Total:</span>
|
<span class="block text-gray-600 font-medium">Total:</span>
|
||||||
<span class="text-3xl font-bold text-D">
|
<span class="text-2xl sm:text-3xl font-bold text-D">
|
||||||
Rp{{ total.toLocaleString() }},-
|
Rp{{ total.toLocaleString() }},-
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Error & Info -->
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<p
|
<p
|
||||||
v-if="error"
|
v-if="error"
|
||||||
@ -90,21 +93,23 @@
|
|||||||
<p v-if="info" class="text-sm text-C mt-1">{{ info }}</p>
|
<p v-if="info" class="text-sm text-C mt-1">{{ info }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Table Responsive -->
|
||||||
|
<div class="overflow-x-auto">
|
||||||
<table
|
<table
|
||||||
class="w-full border border-B text-sm rounded-lg overflow-hidden"
|
class="w-full border border-B rounded-lg overflow-hidden text-xs sm:text-sm"
|
||||||
>
|
>
|
||||||
<thead class="bg-A text-D">
|
<thead class="bg-A text-D">
|
||||||
<tr>
|
<tr>
|
||||||
<th class="border border-B p-2">No</th>
|
<th class="border border-B p-2 w-8">No</th>
|
||||||
<th class="border border-B p-2">Nam Produk</th>
|
<th class="border border-B p-2">Nama Produk</th>
|
||||||
<th class="border border-B p-2">Posisi</th>
|
<th class="border border-B p-2">Posisi</th>
|
||||||
<th class="border border-B p-2">Harga</th>
|
<th class="border border-B p-2">Harga</th>
|
||||||
<th class="border border-B p-2"></th>
|
<th class="border border-B p-2 w-10"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-if="pesanan.length == 0" class="text-center text-D/70">
|
<tr v-if="pesanan.length == 0" class="text-center text-D/70">
|
||||||
<td colspan="5" class="h-20 border border-B">
|
<td colspan="5" class="h-16 border border-B text-xs sm:text-sm">
|
||||||
Belum ada item dipesan
|
Belum ada item dipesan
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -115,13 +120,13 @@
|
|||||||
class="hover:bg-gray-50 text-center"
|
class="hover:bg-gray-50 text-center"
|
||||||
>
|
>
|
||||||
<td class="border border-B p-2">{{ index + 1 }}</td>
|
<td class="border border-B p-2">{{ index + 1 }}</td>
|
||||||
<td class="border border-B p-2 text-left">
|
<td class="border border-B p-2 text-left truncate max-w-[120px] sm:max-w-none">
|
||||||
{{ item.produk.nama }}
|
{{ item.produk.nama }}
|
||||||
</td>
|
</td>
|
||||||
<td class="border border-B p-2">
|
<td class="border border-B p-2 truncate max-w-[80px]">
|
||||||
{{ item.posisi ? item.posisi : "Brankas" }}
|
{{ item.posisi ? item.posisi : "Brankas" }}
|
||||||
</td>
|
</td>
|
||||||
<td class="border border-B p-2">
|
<td class="border border-B p-2 whitespace-nowrap">
|
||||||
Rp{{ item.harga_deal.toLocaleString() }}
|
Rp{{ item.harga_deal.toLocaleString() }}
|
||||||
</td>
|
</td>
|
||||||
<td class="border border-B p-2 text-center">
|
<td class="border border-B p-2 text-center">
|
||||||
@ -135,9 +140,12 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed } from "vue";
|
import { ref, computed } from "vue";
|
||||||
import InputField from "./InputField.vue";
|
import InputField from "./InputField.vue";
|
||||||
@ -222,7 +230,7 @@ const tambahItem = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// harga deal
|
// harga deal
|
||||||
item.value.harga_deal = hargaJual.value;
|
item.value.harga_deal = Number(hargaJual.value);
|
||||||
|
|
||||||
pesanan.value.push(item.value);
|
pesanan.value.push(item.value);
|
||||||
|
|
||||||
|
@ -1,12 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
<h3 class="text-lg font-semibold mb-4 text-gray-800">Transaksi</h3>
|
<h3 class="text-lg font-semibold mb-4 text-gray-800">Transaksi</h3>
|
||||||
<table class="w-full border border-B rounded-lg text-sm">
|
<table class="w-full min-w-[500px] border border-B rounded-lg text-sm">
|
||||||
<thead class="bg-A text-D">
|
<thead class="bg-A text-D">
|
||||||
<tr>
|
<tr>
|
||||||
<th class="border border-B p-2">Tanggal</th>
|
<th class="border border-B p-2 text-left">Tanggal</th>
|
||||||
<th class="border border-B p-2">Kode Transaksi</th>
|
<th class="border border-B p-2 text-left">Kode Transaksi</th>
|
||||||
<th class="border border-B p-2">Pendapatan</th>
|
<th class="border border-B p-2 text-left">Pendapatan</th>
|
||||||
<th class="border border-B p-2">Detail Item</th>
|
<th class="border border-B p-2 text-center">Detail Item</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@ -15,12 +16,16 @@
|
|||||||
<td class="border border-B p-2">{{ trx.kode }}</td>
|
<td class="border border-B p-2">{{ trx.kode }}</td>
|
||||||
<td class="border border-B p-2">Rp{{ (trx.pendapatan || 0).toLocaleString() }}</td>
|
<td class="border border-B p-2">Rp{{ (trx.pendapatan || 0).toLocaleString() }}</td>
|
||||||
<td class="border border-B p-2 text-center">
|
<td class="border border-B p-2 text-center">
|
||||||
<button @click="$emit('detail', trx)"
|
<button
|
||||||
class="px-3 py-1 rounded-md bg-D text-A hover:bg-D/80 transition">Detail</button>
|
@click="$emit('detail', trx)"
|
||||||
|
class="px-3 py-1 rounded-md bg-D text-A hover:bg-D/80 transition">
|
||||||
|
Detail
|
||||||
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
@ -17,19 +17,16 @@ const {
|
|||||||
<template>
|
<template>
|
||||||
<div class="md:hidden">
|
<div class="md:hidden">
|
||||||
<div class="bg-D h-5 shadow-lg"></div>
|
<div class="bg-D h-5 shadow-lg"></div>
|
||||||
<div class="px-4 fixed flex items-center mt-2">
|
|
||||||
<button @click="toggleMobileMenu"
|
<button @click="toggleMobileMenu"
|
||||||
class="text-D bg-C hover:bg-B transition-colors duration-200 p-0.5 rounded-sm z-50">
|
:class="{ 'hidden': isMobileMenuOpen, 'block': !isMobileMenuOpen }"
|
||||||
<svg :class="{ 'hidden': isMobileMenuOpen, 'block': !isMobileMenuOpen }" class="w-7 h-7" fill="none"
|
class="fixed top-4 left-4 text-D bg-C hover:bg-B transition-colors duration-200 p-0.5 rounded-sm z-[9999]">
|
||||||
|
<svg class="w-7 h-7" fill="none"
|
||||||
stroke="currentColor" viewBox="0 0 24 24">
|
stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
|
||||||
</svg>
|
</svg>
|
||||||
<svg :class="{ 'block': isMobileMenuOpen, 'hidden': !isMobileMenuOpen }" class="w-6 h-6" fill="none"
|
|
||||||
stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div :class="{ 'translate-x-0': isMobileMenuOpen, '-translate-x-full': !isMobileMenuOpen }"
|
<div :class="{ 'translate-x-0': isMobileMenuOpen, '-translate-x-full': !isMobileMenuOpen }"
|
||||||
class="fixed inset-y-0 left-0 w-64 bg-A transform transition-transform duration-300 ease-in-out z-50 shadow-xl">
|
class="fixed inset-y-0 left-0 w-64 bg-A transform transition-transform duration-300 ease-in-out z-50 shadow-xl">
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="relative border border-C rounded-md aspect-square flex items-center justify-center hover:shadow-md transition cursor-pointer overflow-hidden"
|
class="relative z-0 border border-C rounded-md aspect-square flex items-center justify-center hover:shadow-md transition cursor-pointer overflow-hidden"
|
||||||
@click="$emit('click', product.id)"
|
@click="$emit('click', product.id)"
|
||||||
>
|
>
|
||||||
<!-- Foto Produk -->
|
<!-- Foto Produk -->
|
||||||
|
@ -17,7 +17,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="relative w-40" ref="exportDropdownRef">
|
<div class="relative w-40" ref="exportDropdownRef">
|
||||||
<button @click="isExportOpen = !isExportOpen" type="button"
|
<button v-if="loadingExport" type="button"
|
||||||
|
class="flex items-center w-full px-3 py-2 text-sm text-left bg-C/80 border rounded-md border-C/80" disabled>
|
||||||
|
<i class="fas fa-spinner fa-spin mr-2"></i> Memproses...
|
||||||
|
</button>
|
||||||
|
<button v-else @click="isExportOpen = !isExportOpen" type="button"
|
||||||
class="flex items-center justify-between w-full px-3 py-2 text-sm text-left bg-C border rounded-md border-C hover:bg-C/80 focus:outline-none">
|
class="flex items-center justify-between w-full px-3 py-2 text-sm text-left bg-C border rounded-md border-C hover:bg-C/80 focus:outline-none">
|
||||||
<span :class="{ 'text-D': !exportFormat }">{{ selectedExportLabel }}</span>
|
<span :class="{ 'text-D': !exportFormat }">{{ selectedExportLabel }}</span>
|
||||||
<i class="fas fa-chevron-down"></i>
|
<i class="fas fa-chevron-down"></i>
|
||||||
@ -135,7 +139,7 @@ const filterOptions = ref([
|
|||||||
]);
|
]);
|
||||||
const exportOptions = ref([
|
const exportOptions = ref([
|
||||||
{ value: 'pdf', label: 'Pdf' },
|
{ value: 'pdf', label: 'Pdf' },
|
||||||
{ value: 'xls', label: 'Excel' },
|
{ value: 'xlsx', label: 'Excel' },
|
||||||
{ value: 'csv', label: 'Csv' }
|
{ value: 'csv', label: 'Csv' }
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@ -186,7 +190,7 @@ const fetchRingkasan = async (page = 1) => {
|
|||||||
loading.value = true;
|
loading.value = true;
|
||||||
pendapatanElements.value = [];
|
pendapatanElements.value = [];
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(`/api/laporan?filter=${filterRingkasan.value}&page=${page}`, {
|
const response = await axios.get(`/api/laporan/ringkasan?filter=${filterRingkasan.value}&page=${page}`, {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||||
},
|
},
|
||||||
@ -226,15 +230,16 @@ const triggerDownload = async (format) => {
|
|||||||
loadingExport.value = true;
|
loadingExport.value = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await axios.get('/api/laporan/ringkasan/export', {
|
const response = await axios.get('/api/laporan/export/ringkasan', {
|
||||||
params: {
|
|
||||||
filter: filterRingkasan.value,
|
|
||||||
format: format
|
|
||||||
},
|
|
||||||
responseType: 'blob',
|
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||||
}
|
},
|
||||||
|
responseType: 'blob',
|
||||||
|
params: {
|
||||||
|
filter: filterRingkasan.value,
|
||||||
|
format: format,
|
||||||
|
page: pagination.value.current_page,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
const url = window.URL.createObjectURL(new Blob([response.data]));
|
@ -1,245 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="flex flex-row items-center justify-end mt-5 gap-3">
|
|
||||||
<div class="relative w-32" ref="filterDropdownRef">
|
|
||||||
<button @click="isFilterOpen = !isFilterOpen" type="button"
|
|
||||||
class="flex items-center justify-between w-full px-3 py-2 text-sm text-left bg-C border rounded-md border-C hover:bg-C/80 focus:outline-none">
|
|
||||||
<span>{{ selectedFilterLabel }}</span>
|
|
||||||
<i class="fas fa-chevron-down"></i>
|
|
||||||
</button>
|
|
||||||
<div v-if="isFilterOpen" class="absolute z-10 w-full mt-1 bg-C border rounded-md shadow-lg border-C">
|
|
||||||
<ul class="py-1">
|
|
||||||
<li v-for="option in filterOptions" :key="option.value" @click="selectFilter(option)"
|
|
||||||
class="px-3 py-2 text-sm cursor-pointer text-D hover:bg-B">
|
|
||||||
{{ option.label }}
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="relative w-40" ref="exportDropdownRef">
|
|
||||||
<button @click="isExportOpen = !isExportOpen" type="button"
|
|
||||||
class="flex items-center justify-between w-full px-3 py-2 text-sm text-left bg-C border rounded-md border-C hover:bg-C/80 focus:outline-none">
|
|
||||||
<span :class="{ 'text-D': !exportFormat }">{{ selectedExportLabel }}</span>
|
|
||||||
<i class="fas fa-chevron-down"></i>
|
|
||||||
</button>
|
|
||||||
<div v-if="isExportOpen" class="absolute z-10 w-full mt-1 bg-C border rounded-md shadow-lg border-C">
|
|
||||||
<ul class="py-1">
|
|
||||||
<li v-for="option in exportOptions" :key="option.value" @click="selectExport(option)"
|
|
||||||
class="px-3 py-2 text-sm cursor-pointer text-D hover:bg-B">
|
|
||||||
{{ option.label }}
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-5 overflow-x-auto">
|
|
||||||
<table class="w-full border-collapse border border-C rounded-md">
|
|
||||||
<thead>
|
|
||||||
<tr class="bg-C text-D rounded-t-md">
|
|
||||||
<th class="border-x border-C px-3 py-3">Tanggal</th>
|
|
||||||
<th class="border-x border-C px-3 py-3">Nama Sales</th>
|
|
||||||
<th class="border-x border-C px-3 py-3">Jumlah Item Terjual</th>
|
|
||||||
<th class="border-x border-C px-3 py-3">Total Berat Terjual</th>
|
|
||||||
<th class="border-x border-C px-3 py-3">Total Pendapatan</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr v-if="loading">
|
|
||||||
<td colspan="5" class="p-4">
|
|
||||||
<div class="flex items-center justify-center w-full h-30">
|
|
||||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-D"></div>
|
|
||||||
<span class="ml-2 text-gray-600">Memuat data...</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr v-else-if="!ringkasanLaporan.length">
|
|
||||||
<td colspan="5" class="p-4 text-center">Tidak ada data untuk ditampilkan.</td>
|
|
||||||
</tr>
|
|
||||||
<template v-else v-for="item in ringkasanLaporan" :key="item.tanggal">
|
|
||||||
<template v-if="item.sales && item.sales.length > 0">
|
|
||||||
<tr class="text-center border-y border-C"
|
|
||||||
:class="item.sales[0].item_terjual == 0 ? 'bg-red-200 hover:bg-red-300' : 'hover:bg-A'">
|
|
||||||
<td class="px-3 py-2 border-x border-C bg-white" :rowspan="item.sales.length">{{
|
|
||||||
item.tanggal }}</td>
|
|
||||||
<td class="px-3 py-2 border-x border-C text-left">{{ item.sales[0].nama }}</td>
|
|
||||||
<td class="px-3 py-2 border-x border-C">{{ item.sales[0].item_terjual }}</td>
|
|
||||||
<td class="px-3 py-2 border-x border-C">{{ item.sales[0].berat_terjual }}</td>
|
|
||||||
<td class="flex justify-center">
|
|
||||||
<div :ref="el => { if (el) pendapatanElements.push(el) }" :style="pendapatanStyle" :class="item.sales[0].pendapatan == '-' ? 'text-center' : 'text-right'">
|
|
||||||
{{ item.sales[0].pendapatan }}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr v-for="sales in item.sales.slice(1)" :key="sales.nama"
|
|
||||||
class="text-center border-y border-C"
|
|
||||||
:class="sales.item_terjual == '-' ? 'bg-red-200 hover:bg-red-300' : 'hover:bg-A'">
|
|
||||||
<td class="px-3 py-2 text-left border-x border-C">{{ sales.nama }}</td>
|
|
||||||
<td class="px-3 py-2 border-x border-C">{{ sales.item_terjual }}</td>
|
|
||||||
<td class="px-3 py-2 border-x border-C">{{ sales.berat_terjual }}</td>
|
|
||||||
<td class="flex justify-center">
|
|
||||||
<div :ref="el => { if (el) pendapatanElements.push(el) }" :style="pendapatanStyle" :class="sales.pendapatan == '-' ? 'text-center' : 'text-right'">
|
|
||||||
{{ sales.pendapatan }}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr class="font-semibold text-center border-y border-C bg-B hover:bg-C/80">
|
|
||||||
<td class="px-3 py-2 border-x border-C" colspan="2">Total</td>
|
|
||||||
<td class="px-3 py-2 border-x border-C">{{ item.total_item_terjual }}</td>
|
|
||||||
<td class="px-3 py-2 border-x border-C">{{ item.total_berat }}</td>
|
|
||||||
<td class="flex justify-center">
|
|
||||||
<div :ref="el => { if (el) pendapatanElements.push(el) }" :style="pendapatanStyle" :class="item.sales[0].pendapatan == '-' ? 'text-center' : 'text-right'">
|
|
||||||
{{ item.total_pendapatan }}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<tr class="text-center border-y border-C hover:bg-A">
|
|
||||||
<td class="px-3 py-2 border-x border-C">{{ item.tanggal }}</td>
|
|
||||||
<td colspan="4" class="px-3 py-2 italic text-gray-500 border-x border-C bg-yellow-50 hover:bg-yellow-100">Tidak ada transaksi
|
|
||||||
pada hari ini</td>
|
|
||||||
</tr>
|
|
||||||
</template>
|
|
||||||
</template>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<div v-if="pagination.total > 0 && pagination.last_page > 1" class="flex items-center justify-end gap-2 mt-4">
|
|
||||||
<button @click="goToPage(pagination.current_page - 1)" :disabled="pagination.current_page === 1 || loading"
|
|
||||||
class="px-2 py-1 text-sm font-medium border rounded-md bg-C border-C disabled:opacity-50 disabled:cursor-not-allowed hover:bg-C/80">
|
|
||||||
Sebelumnya
|
|
||||||
</button>
|
|
||||||
<span class="text-sm text-D">
|
|
||||||
Halaman {{ pagination.current_page }} dari {{ pagination.last_page }}
|
|
||||||
</span>
|
|
||||||
<button @click="goToPage(pagination.current_page + 1)"
|
|
||||||
:disabled="(pagination.current_page === pagination.last_page) || loading"
|
|
||||||
class="px-2 py-1 text-sm font-medium border rounded-md bg-C border-C disabled:opacity-50 disabled:cursor-not-allowed hover:bg-C/80">
|
|
||||||
Berikutnya
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, onMounted, onUnmounted, watch, computed, nextTick } from "vue";
|
|
||||||
import axios from "axios";
|
|
||||||
|
|
||||||
// --- State ---
|
|
||||||
const isFilterOpen = ref(false);
|
|
||||||
const isExportOpen = ref(false);
|
|
||||||
const filterDropdownRef = ref(null);
|
|
||||||
const exportDropdownRef = ref(null);
|
|
||||||
|
|
||||||
const filterOptions = ref([
|
|
||||||
{ value: 'bulan', label: 'Bulanan' },
|
|
||||||
{ value: 'hari', label: 'Harian' }
|
|
||||||
]);
|
|
||||||
const exportOptions = ref([
|
|
||||||
{ value: 'pdf', label: 'Pdf' },
|
|
||||||
{ value: 'xls', label: 'Excel' },
|
|
||||||
{ value: 'csv', label: 'Csv' }
|
|
||||||
]);
|
|
||||||
|
|
||||||
const filterRingkasan = ref("bulan");
|
|
||||||
const exportFormat = ref(null);
|
|
||||||
const ringkasanLaporan = ref([]);
|
|
||||||
const loading = ref(false);
|
|
||||||
const pagination = ref({
|
|
||||||
current_page: 1,
|
|
||||||
last_page: 1,
|
|
||||||
total: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const pendapatanWidth = ref(0);
|
|
||||||
const pendapatanElements = ref([]);
|
|
||||||
|
|
||||||
// --- Computed ---
|
|
||||||
const selectedFilterLabel = computed(() => {
|
|
||||||
return filterOptions.value.find(opt => opt.value === filterRingkasan.value)?.label;
|
|
||||||
});
|
|
||||||
|
|
||||||
const selectedExportLabel = computed(() => {
|
|
||||||
return exportOptions.value.find(opt => opt.value === exportFormat.value)?.label || 'Export Laporan';
|
|
||||||
});
|
|
||||||
|
|
||||||
const pendapatanStyle = computed(() => ({
|
|
||||||
minWidth: `${pendapatanWidth.value}px`,
|
|
||||||
padding: '0.5rem 0.75rem'
|
|
||||||
}));
|
|
||||||
|
|
||||||
// --- Watchers ---
|
|
||||||
watch(ringkasanLaporan, async (newValue) => {
|
|
||||||
if (newValue && newValue.length > 0) {
|
|
||||||
await nextTick();
|
|
||||||
let maxWidth = 0;
|
|
||||||
pendapatanElements.value.forEach(el => {
|
|
||||||
if (el && el.scrollWidth > maxWidth) {
|
|
||||||
maxWidth = el.scrollWidth;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
pendapatanWidth.value = maxWidth;
|
|
||||||
}
|
|
||||||
}, { deep: true });
|
|
||||||
|
|
||||||
// --- Methods ---
|
|
||||||
const fetchRingkasan = async (page = 1) => {
|
|
||||||
loading.value = true;
|
|
||||||
pendapatanElements.value = [];
|
|
||||||
try {
|
|
||||||
const response = await axios.get(`/api/laporan?filter=${filterRingkasan.value}&page=${page}`, {
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
|
||||||
},
|
|
||||||
});;
|
|
||||||
ringkasanLaporan.value = response.data.data;
|
|
||||||
pagination.value = {
|
|
||||||
current_page: response.data.current_page,
|
|
||||||
last_page: response.data.last_page,
|
|
||||||
total: response.data.total,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching laporan:", error);
|
|
||||||
ringkasanLaporan.value = [];
|
|
||||||
} finally {
|
|
||||||
loading.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const goToPage = (page) => {
|
|
||||||
if (page >= 1 && page <= pagination.value.last_page) {
|
|
||||||
fetchRingkasan(page);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const selectFilter = (option) => {
|
|
||||||
filterRingkasan.value = option.value;
|
|
||||||
isFilterOpen.value = false;
|
|
||||||
goToPage(1);
|
|
||||||
};
|
|
||||||
|
|
||||||
const selectExport = (option) => {
|
|
||||||
exportFormat.value = option.value;
|
|
||||||
isExportOpen.value = false;
|
|
||||||
alert(`Fitur Belum dikerjakan. Laporan akan diekspor dalam format ${option.label}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeDropdownsOnClickOutside = (event) => {
|
|
||||||
if (filterDropdownRef.value && !filterDropdownRef.value.contains(event.target)) {
|
|
||||||
isFilterOpen.value = false;
|
|
||||||
}
|
|
||||||
if (exportDropdownRef.value && !exportDropdownRef.value.contains(event.target)) {
|
|
||||||
isExportOpen.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- Lifecycle Hooks ---
|
|
||||||
onMounted(() => {
|
|
||||||
fetchRingkasan(pagination.value.current_page);
|
|
||||||
document.addEventListener('click', closeDropdownsOnClickOutside);
|
|
||||||
});
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
document.removeEventListener('click', closeDropdownsOnClickOutside);
|
|
||||||
});
|
|
||||||
</script>
|
|
@ -52,17 +52,16 @@
|
|||||||
<!-- Nomor Transaksi -->
|
<!-- Nomor Transaksi -->
|
||||||
<p class="mt-1 text-sm">TRS-XXX-XXX</p>
|
<p class="mt-1 text-sm">TRS-XXX-XXX</p>
|
||||||
|
|
||||||
<!-- Table Barang -->
|
<table class="w-full border-D mt-0 text-sm table-fixed">
|
||||||
<table class="w-full border-D mt-0 text-sm">
|
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="border-b border-D">
|
<tr class="border-b border-D">
|
||||||
<th class="w-32 py-2 border-r border-D">Item</th>
|
<th class="w-[260px] py-2 border-r border-D">Item</th>
|
||||||
<th class="w-32 py-2 border-r border-D">Posisi</th>
|
<th class="w-[70px] border-r border-D">Posisi</th>
|
||||||
<th class="w-20 border-r border-D">Berat</th>
|
<th class="w-[60px] border-r border-D">Berat</th>
|
||||||
<th class="w-20 border-r border-D">Kadar</th>
|
<th class="w-[60px] border-r border-D">Kadar</th>
|
||||||
<th class="w-32 border-r border-D">Harga Satuan</th>
|
<th class="w-[140px] border-r border-D">Harga Satuan</th>
|
||||||
<th class="w-20 border-r border-D">Jumlah</th>
|
<th class="w-[60px] border-r border-D">Jumlah</th>
|
||||||
<th class="w-32">Total Harga</th>
|
<th class="w-[140px]">Total Harga</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@ -94,25 +93,19 @@
|
|||||||
<td>Rp3.000.000</td>
|
<td>Rp3.000.000</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
|
<!-- Baris Ongkos + Total -->
|
||||||
<tr class="align-top">
|
<tr class="align-top">
|
||||||
<td colspan="2" rowspan="2" class="p-2 text-left align-top">
|
<td colspan="2" rowspan="2" class="p-2 text-left align-top">
|
||||||
<p class="font-semibold">PERHATIAN</p>
|
<p class="font-semibold">PERHATIAN</p>
|
||||||
<ol class="list-decimal ml-4 text-xs space-y-1">
|
<ol class="list-decimal ml-4 text-xs space-y-1">
|
||||||
<li>Berat barang telah ditimbang dan disaksikan oleh pembeli.</li>
|
<li>Berat barang telah ditimbang dan disaksikan oleh pembeli.</li>
|
||||||
<li>
|
<li>Barang yang dikembalikan menurut harga pasaran dan <br> dipotong ongkos bikin, barang rusak lain harga.</li>
|
||||||
Barang yang dikembalikan menurut harga pasaran dan dipotong
|
<li>Barang yang sudah dibeli berarti sudah diperiksa dan disetujui.</li>
|
||||||
ongkos bikin, barang rusak lain harga.
|
<li>Surat ini harap dibawa pada saat menjual kembali.</li>
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Barang yang sudah dibeli berarti sudah diperiksa dan
|
|
||||||
disetujui.
|
|
||||||
</li>
|
|
||||||
<li>Surat ini harus dibawa pada saat menjual kembali.</li>
|
|
||||||
</ol>
|
</ol>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
|
<td colspan="2" rowspan="2" class="p-2 text-center align-top">
|
||||||
<td colspan="3" rowspan="2" class="p-2 text-center align-top">
|
|
||||||
<div class="flex flex-col items-center justify-center h-full">
|
<div class="flex flex-col items-center justify-center h-full">
|
||||||
<p><strong>Sales</strong></p>
|
<p><strong>Sales</strong></p>
|
||||||
<inputSelect
|
<inputSelect
|
||||||
@ -121,13 +114,13 @@
|
|||||||
{ value: 'Timothy', label: 'Timothy' },
|
{ value: 'Timothy', label: 'Timothy' },
|
||||||
{ value: 'Iwan', label: 'Iwan' }
|
{ value: 'Iwan', label: 'Iwan' }
|
||||||
]"
|
]"
|
||||||
class="mt-16 text-sm rounded bg-B text-center cursor-pointer !w-[160px]"
|
class="mt-16 text-sm rounded bg-B cursor-pointer !w-[160px] text-center [option]:text-left"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
|
<td colspan="2" class="p-2 text-right text-sm font-semibold align-top border-r">
|
||||||
<td class="p-2 text-right text-sm font-semibold align-top border-r">
|
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<p>Ongkos bikin</p>
|
<p>Ongkos bikin</p>
|
||||||
<p class="text-red-500 text-xs">diluar harga jual</p>
|
<p class="text-red-500 text-xs">diluar harga jual</p>
|
||||||
@ -135,7 +128,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
|
|
||||||
<td class="p-2 text-sm align-top">
|
<td class="p-2 text-sm align-top">
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
@ -152,21 +144,27 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
|
<!-- Baris Tombol -->
|
||||||
<tr>
|
<tr>
|
||||||
|
<td></td>
|
||||||
|
<td></td>
|
||||||
<td class="p-2 text-center">
|
<td class="p-2 text-center">
|
||||||
|
<div class="flex gap-2">
|
||||||
<button class="bg-gray-400 text-white px-6 py-2 rounded w-full">
|
<button class="bg-gray-400 text-white px-6 py-2 rounded w-full">
|
||||||
Batal
|
Batal
|
||||||
</button>
|
</button>
|
||||||
</td>
|
|
||||||
<td class="p-2 text-center">
|
|
||||||
<button class="bg-C text-white px-6 py-2 rounded w-full">
|
<button class="bg-C text-white px-6 py-2 rounded w-full">
|
||||||
Simpan
|
Simpan
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Pesan bawah -->
|
<!-- Pesan bawah -->
|
||||||
@ -179,6 +177,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import logo from '@/../images/logo.png'
|
import logo from '@/../images/logo.png'
|
||||||
|
@ -8,11 +8,11 @@
|
|||||||
Nampan tidak ditemukan.
|
Nampan tidak ditemukan.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 ">
|
||||||
<div
|
<div
|
||||||
v-for="tray in filteredTrays"
|
v-for="tray in filteredTrays"
|
||||||
:key="tray.id"
|
:key="tray.id"
|
||||||
class="border rounded-xl p-4 shadow-sm hover:shadow-md transition"
|
class="border border-C rounded-xl p-4 shadow-sm hover:shadow-md transition"
|
||||||
>
|
>
|
||||||
<div class="flex justify-between items-center mb-3">
|
<div class="flex justify-between items-center mb-3">
|
||||||
<h2 class="font-bold text-lg text-[#102C57]">{{ tray.nama }}</h2>
|
<h2 class="font-bold text-lg text-[#102C57]">{{ tray.nama }}</h2>
|
||||||
@ -26,7 +26,7 @@
|
|||||||
<div
|
<div
|
||||||
v-for="item in tray.items"
|
v-for="item in tray.items"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
class="flex justify-between items-center border rounded-lg p-2 cursor-pointer hover:bg-gray-50"
|
class="flex justify-between items-center border border-C rounded-lg p-2 cursor-pointer hover:bg-gray-50"
|
||||||
@click="openMovePopup(item)"
|
@click="openMovePopup(item)"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
@ -53,7 +53,7 @@
|
|||||||
Masuk ke menu <b>Brankas</b> untuk memindahkan item ke nampan.
|
Masuk ke menu <b>Brankas</b> untuk memindahkan item ke nampan.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="border-t mt-3 pt-2 text-right font-semibold">
|
<div class="border-t border-C mt-3 pt-2 text-right font-semibold">
|
||||||
Berat Total: {{ totalWeight(tray) }}g
|
Berat Total: {{ totalWeight(tray) }}g
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,14 +1,19 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="min-h-screen max-w-screen">
|
<div class="min-h-screen flex flex-col">
|
||||||
|
<!-- Navbar -->
|
||||||
<NavigationComponent />
|
<NavigationComponent />
|
||||||
<div class="mx-2 md:mx-4 lg:mx-6 xl:mx-7 my-6">
|
|
||||||
|
<!-- Konten utama -->
|
||||||
|
<div class="flex-1 mx-2 md:mx-4 lg:mx-6 xl:mx-7 my-6">
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
<Footer class="bottom-0 w-full" />
|
|
||||||
|
<!-- Footer selalu di bawah -->
|
||||||
|
<Footer class="w-full" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import Footer from '../components/Footer.vue'
|
import Footer from '../components/Footer.vue'
|
||||||
import NavigationComponent from '../components/NavigationComponent.vue';
|
import NavigationComponent from '../components/NavigationComponent.vue'
|
||||||
</script>
|
</script>
|
||||||
|
@ -1,11 +1,15 @@
|
|||||||
<template>
|
<template>
|
||||||
<mainLayout>
|
<mainLayout>
|
||||||
<div class="lg:p-2 pt-6">
|
<div class="lg:p-2 pt-6">
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-5 gap-3 sm:gap-2 mx-auto min-h-[75vh]">
|
<div
|
||||||
|
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-3 sm:gap-2 mx-auto min-h-[75vh]"
|
||||||
|
>
|
||||||
<!-- Left Section - Form Kasir -->
|
<!-- Left Section - Form Kasir -->
|
||||||
<div class="lg:col-span-3">
|
<div class="lg:col-span-3">
|
||||||
<div class="bg-white rounded-xl shadow-lg border border-B overflow-hidden h-full">
|
<div
|
||||||
<div class="p-2 md:p-4 h-full">
|
class="bg-white rounded-xl shadow-lg border border-B overflow-hidden h-auto lg:h-full"
|
||||||
|
>
|
||||||
|
<div class="p-2 sm:p-3 md:p-4 h-auto lg:h-full">
|
||||||
<KasirForm />
|
<KasirForm />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -13,22 +17,44 @@
|
|||||||
|
|
||||||
<!-- Right Section - Transaction List -->
|
<!-- Right Section - Transaction List -->
|
||||||
<div class="lg:col-span-2">
|
<div class="lg:col-span-2">
|
||||||
<div class="bg-white rounded-xl shadow-lg border border-B overflow-hidden lg:h-fit sticky top-4">
|
<div
|
||||||
<!-- Transaction List Content -->
|
class="bg-white rounded-xl shadow-lg border border-B overflow-hidden lg:h-fit sticky top-4 max-h-[70vh] overflow-y-auto"
|
||||||
<div class="p-4 sm:p-6 overflow-y-auto">
|
>
|
||||||
<!-- Loading State -->
|
<div class="p-3 sm:p-4 md:p-6">
|
||||||
<div v-if="loading" class="flex items-center justify-center py-8">
|
<!-- Loading -->
|
||||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-C"></div>
|
<div
|
||||||
<span class="ml-3 text-D/70">Memuat transaksi...</span>
|
v-if="loading"
|
||||||
|
class="flex items-center justify-center py-8"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="animate-spin rounded-full h-8 w-8 border-b-2 border-C"
|
||||||
|
></div>
|
||||||
|
<span class="ml-3 text-D/70"
|
||||||
|
>Memuat transaksi...</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Empty State -->
|
<!-- Empty -->
|
||||||
<div v-else-if="!transaksi.length" class="text-center py-8">
|
<div
|
||||||
<svg class="w-16 h-16 mx-auto text-B mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
v-else-if="!transaksi.length"
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1"
|
class="text-center py-8"
|
||||||
d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
>
|
||||||
|
<svg
|
||||||
|
class="w-16 h-16 mx-auto text-B mb-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="1"
|
||||||
|
d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<p class="text-[var(--color-D)]/60 text-sm">Belum ada transaksi</p>
|
<p class="text-[var(--color-D)]/60 text-sm">
|
||||||
|
Belum ada transaksi
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Transaction List -->
|
<!-- Transaction List -->
|
||||||
@ -46,34 +72,34 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from "vue"
|
import { ref, onMounted } from "vue";
|
||||||
import axios from "axios"
|
import axios from "axios";
|
||||||
|
|
||||||
import mainLayout from '../layouts/mainLayout.vue'
|
import mainLayout from "../layouts/mainLayout.vue";
|
||||||
import KasirForm from '../components/KasirForm.vue'
|
import KasirForm from "../components/KasirForm.vue";
|
||||||
import KasirTransaksiList from '../components/KasirTransaksiList.vue'
|
import KasirTransaksiList from "../components/KasirTransaksiList.vue";
|
||||||
|
|
||||||
const transaksi = ref([])
|
const transaksi = ref([]);
|
||||||
const loading = ref(true)
|
const loading = ref(true);
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
loading.value = true
|
loading.value = true;
|
||||||
const res = await axios.get("/api/transaksi?limit=10", {
|
const res = await axios.get("/api/transaksi?limit=10", {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
transaksi.value = res.data
|
transaksi.value = res.data;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Gagal fetch transaksi:", err)
|
console.error("Gagal fetch transaksi:", err);
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false;
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
const lihatDetail = (trx) => {
|
const lihatDetail = (trx) => {
|
||||||
alert(`Detail transaksi: ${trx.kode}`)
|
alert(`Detail transaksi: ${trx.kode}`);
|
||||||
}
|
};
|
||||||
</script>
|
</script>
|
||||||
|
@ -21,7 +21,7 @@
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div v-if="activeTab === 'ringkasan'" id="ringkasan-content" role="tabpanel">
|
<div v-if="activeTab === 'ringkasan'" id="ringkasan-content" role="tabpanel">
|
||||||
<RingkasanLaporanB />
|
<RingkasanLaporan />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="activeTab === 'detail-nampan'" id="detail-content" role="tabpanel">
|
<div v-if="activeTab === 'detail-nampan'" id="detail-content" role="tabpanel">
|
||||||
@ -38,8 +38,7 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import RingkasanLaporanA from '../components/RingkasanLaporanA.vue';
|
import RingkasanLaporan from '../components/RingkasanLaporan.vue';
|
||||||
import RingkasanLaporanB from '../components/RingkasanLaporanB.vue';
|
|
||||||
import mainLayout from '../layouts/mainLayout.vue';
|
import mainLayout from '../layouts/mainLayout.vue';
|
||||||
import DetailPerNampan from '../components/DetailPerNampan.vue';
|
import DetailPerNampan from '../components/DetailPerNampan.vue';
|
||||||
import DetailPerProduk from '../components/DetailPerProduk.vue';
|
import DetailPerProduk from '../components/DetailPerProduk.vue';
|
||||||
|
@ -16,27 +16,34 @@
|
|||||||
message="Apakah Anda yakin ingin menghapus produk ini?"
|
message="Apakah Anda yakin ingin menghapus produk ini?"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="p-6">
|
<div class="p-6 min-h-[75vh]">
|
||||||
<!-- Judul -->
|
<!-- Judul -->
|
||||||
<p class="font-serif italic text-[25px] text-D">PRODUK</p>
|
<p class="font-serif italic text-[25px] text-D">PRODUK</p>
|
||||||
|
|
||||||
<!-- Filter -->
|
<!-- Wrapper -->
|
||||||
<div
|
<div class="mt-3">
|
||||||
class="mt-3 flex flex-col md:flex-row md:items-center md:justify-between gap-3"
|
<!-- Mobile Layout -->
|
||||||
>
|
<div class="flex flex-col gap-3 sm:hidden">
|
||||||
<!-- Dropdown Kategori -->
|
<!-- Search -->
|
||||||
|
<div class="w-full">
|
||||||
|
<searchbar
|
||||||
|
v-model:search="searchQuery"
|
||||||
|
class="searchbar-mobile"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filter + Tombol -->
|
||||||
|
<div class="flex flex-row justify-between items-center">
|
||||||
|
<!-- Filter Kategori -->
|
||||||
|
<div class="w-40 shrink-0">
|
||||||
<InputSelect
|
<InputSelect
|
||||||
v-model="selectedCategory"
|
v-model="selectedCategory"
|
||||||
:options="kategori"
|
:options="kategori"
|
||||||
class="w-full md:w-48"
|
class="w-full"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Search -->
|
|
||||||
<searchbar v-model:search="searchQuery" class="flex-1" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tombol Tambah Produk -->
|
<!-- Tombol Tambah Produk -->
|
||||||
<div class="mt-3 flex justify-end">
|
|
||||||
<router-link
|
<router-link
|
||||||
to="/produk/baru"
|
to="/produk/baru"
|
||||||
class="bg-C text-[#0a1a3c] px-4 py-2 rounded-md shadow hover:bg-C transition"
|
class="bg-C text-[#0a1a3c] px-4 py-2 rounded-md shadow hover:bg-C transition"
|
||||||
@ -44,10 +51,46 @@
|
|||||||
Tambah Produk
|
Tambah Produk
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Grid Produk -->
|
<!-- Desktop Layout -->
|
||||||
|
<div class="hidden sm:flex flex-row gap-3 items-start">
|
||||||
|
<!-- Filter -->
|
||||||
|
<div class="w-40 sm:w-48 shrink-0">
|
||||||
|
<InputSelect
|
||||||
|
v-model="selectedCategory"
|
||||||
|
:options="kategori"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search -->
|
||||||
|
<div class="flex-1 mt-[2px]">
|
||||||
|
<searchbar v-model:search="searchQuery" class="w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tombol Tambah Produk (desktop) -->
|
||||||
|
<div class="hidden sm:flex justify-end mt-3">
|
||||||
|
<router-link
|
||||||
|
to="/produk/baru"
|
||||||
|
class="bg-C text-[#0a1a3c] px-4 py-2 rounded-md shadow hover:bg-C transition"
|
||||||
|
>
|
||||||
|
Tambah Produk
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 🔵 Loading State (sama persis dengan kategori) -->
|
||||||
|
<div v-if="loading" class="flex justify-center items-center h-screen">
|
||||||
|
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-C"></div>
|
||||||
|
<span class="ml-2 text-gray-600">Memuat data...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 🔵 Grid Produk -->
|
||||||
<div
|
<div
|
||||||
class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4 mt-4"
|
v-else
|
||||||
|
class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4 mt-4 relative z-0"
|
||||||
>
|
>
|
||||||
<ProductCard
|
<ProductCard
|
||||||
v-for="item in filteredProducts"
|
v-for="item in filteredProducts"
|
||||||
@ -55,6 +98,27 @@
|
|||||||
:product="item"
|
:product="item"
|
||||||
@click="openOverlay(item.id)"
|
@click="openOverlay(item.id)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- 🔵 Empty State (sama kayak kategori) -->
|
||||||
|
<div
|
||||||
|
v-if="filteredProducts.length === 0"
|
||||||
|
class="col-span-full flex flex-col items-center py-10 text-gray-500"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="w-12 h-12 text-gray-400 mb-2"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2 2v-5m16 0h-2M4 13h2"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<p>Tidak ada data produk</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -178,7 +242,9 @@ const showOverlay = ref(false);
|
|||||||
const currentFotoIndex = ref(0);
|
const currentFotoIndex = ref(0);
|
||||||
|
|
||||||
const kategori = ref([]);
|
const kategori = ref([]);
|
||||||
|
const loading = ref(false); // 🔥 Loading persis kategori
|
||||||
|
|
||||||
|
// Load kategori
|
||||||
const loadKategori = async () => {
|
const loadKategori = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.get("/api/kategori", {
|
const response = await axios.get("/api/kategori", {
|
||||||
@ -200,7 +266,9 @@ const loadKategori = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Load produk
|
||||||
const loadProduk = async () => {
|
const loadProduk = async () => {
|
||||||
|
loading.value = true; // 🔵 start loading
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(`/api/produk`, {
|
const response = await axios.get(`/api/produk`, {
|
||||||
headers: {
|
headers: {
|
||||||
@ -213,10 +281,12 @@ const loadProduk = async () => {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error loading products:", error);
|
console.error("Error loading products:", error);
|
||||||
|
} finally {
|
||||||
|
loading.value = false; // 🔵 stop loading
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Buka modal item
|
// Modal item
|
||||||
const openItemModal = () => {
|
const openItemModal = () => {
|
||||||
creatingItem.value = true;
|
creatingItem.value = true;
|
||||||
};
|
};
|
||||||
@ -224,13 +294,13 @@ const closeItemModal = () => {
|
|||||||
creatingItem.value = false;
|
creatingItem.value = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Fetch data awal
|
// Fetch awal
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
loadKategori();
|
await loadKategori();
|
||||||
loadProduk();
|
await loadProduk();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Filter produk (kategori + search)
|
// Filter produk
|
||||||
const filteredProducts = computed(() => {
|
const filteredProducts = computed(() => {
|
||||||
let hasil = products.value;
|
let hasil = products.value;
|
||||||
|
|
||||||
@ -247,7 +317,7 @@ const filteredProducts = computed(() => {
|
|||||||
return hasil;
|
return hasil;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Buka overlay detail
|
// Overlay detail
|
||||||
function openOverlay(id) {
|
function openOverlay(id) {
|
||||||
const produk = products.value.find((p) => p.id === id);
|
const produk = products.value.find((p) => p.id === id);
|
||||||
if (produk) {
|
if (produk) {
|
||||||
@ -256,8 +326,6 @@ function openOverlay(id) {
|
|||||||
showOverlay.value = true;
|
showOverlay.value = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tutup overlay detail
|
|
||||||
function closeOverlay() {
|
function closeOverlay() {
|
||||||
showOverlay.value = false;
|
showOverlay.value = false;
|
||||||
currentFotoIndex.value = 0;
|
currentFotoIndex.value = 0;
|
||||||
@ -286,7 +354,11 @@ function formatNumber(num) {
|
|||||||
// Hapus produk
|
// Hapus produk
|
||||||
async function deleteProduk() {
|
async function deleteProduk() {
|
||||||
try {
|
try {
|
||||||
await axios.delete(`/api/produk/${detail.value.id}`);
|
await axios.delete(`/api/produk/${detail.value.id}`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
products.value = products.value.filter((p) => p.id !== detail.value.id);
|
products.value = products.value.filter((p) => p.id !== detail.value.id);
|
||||||
deleting.value = false;
|
deleting.value = false;
|
||||||
showOverlay.value = false;
|
showOverlay.value = false;
|
||||||
@ -297,3 +369,14 @@ async function deleteProduk() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* 🔥 Tambahan agar searchbar mobile full */
|
||||||
|
.searchbar-mobile:deep(div) {
|
||||||
|
width: 100% !important;
|
||||||
|
justify-content: flex-start !important;
|
||||||
|
}
|
||||||
|
.searchbar-mobile:deep(input) {
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
171
resources/views/exports/pernampan_pdf.blade.php
Normal file
171
resources/views/exports/pernampan_pdf.blade.php
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>{{ $title ?? 'Laporan Detail Per Nampan' }}</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: sans-serif;
|
||||||
|
font-size: 10px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
border-bottom: 2px solid #333;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
}
|
||||||
|
.header h2 {
|
||||||
|
margin: 0 0 10px 0;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
.filter-info {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
border-left: 4px solid #007bff;
|
||||||
|
}
|
||||||
|
.filter-info h3 {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
.filter-item {
|
||||||
|
margin: 3px 0;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
.filter-label {
|
||||||
|
font-weight: bold;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
font-size: 9px;
|
||||||
|
}
|
||||||
|
th, td {
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
padding: 8px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
th {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
.text-right { text-align: right; }
|
||||||
|
.text-center { text-align: center; }
|
||||||
|
.rekap-section {
|
||||||
|
background-color: #e9ecef;
|
||||||
|
padding: 10px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
.rekap-title {
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
.rekap-item {
|
||||||
|
display: inline-block;
|
||||||
|
margin-right: 20px;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
.rekap-label {
|
||||||
|
font-weight: bold;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
.no-data {
|
||||||
|
text-align: center;
|
||||||
|
font-style: italic;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
.page-footer {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 10px;
|
||||||
|
right: 10px;
|
||||||
|
font-size: 8px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<h2>{{ $title ?? 'Laporan Detail Per Nampan' }}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if(isset($data['filter']))
|
||||||
|
<div class="filter-info">
|
||||||
|
<h3>Informasi Filter</h3>
|
||||||
|
<div class="filter-item">
|
||||||
|
<span class="filter-label">Tanggal:</span> {{ $data['filter']['tanggal'] }}
|
||||||
|
</div>
|
||||||
|
@if($data['filter']['nama_sales'])
|
||||||
|
<div class="filter-item">
|
||||||
|
<span class="filter-label">Sales:</span> {{ $data['filter']['nama_sales'] }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
@if($data['filter']['produk'])
|
||||||
|
<div class="filter-item">
|
||||||
|
<span class="filter-label">Produk:</span> {{ $data['filter']['produk'] }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
@if($data['filter']['nama_pembeli'])
|
||||||
|
<div class="filter-item">
|
||||||
|
<span class="filter-label">Nama Pembeli:</span> {{ $data['filter']['nama_pembeli'] }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if(isset($data['rekap_harian']))
|
||||||
|
<div class="rekap-section">
|
||||||
|
<div class="rekap-title">Rekap Harian</div>
|
||||||
|
<div class="rekap-item">
|
||||||
|
<span class="rekap-label">Total Item Terjual:</span> {{ $data['rekap_harian']['total_item_terjual'] }}
|
||||||
|
</div>
|
||||||
|
<div class="rekap-item">
|
||||||
|
<span class="rekap-label">Total Berat:</span> {{ $data['rekap_harian']['total_berat_terjual'] }}
|
||||||
|
</div>
|
||||||
|
<div class="rekap-item">
|
||||||
|
<span class="rekap-label">Total Pendapatan:</span> {{ $data['rekap_harian']['total_pendapatan'] }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 40%;">Nama Nampan</th>
|
||||||
|
<th style="width: 20%;" class="text-center">Jumlah Item Terjual</th>
|
||||||
|
<th style="width: 20%;" class="text-right">Berat Terjual</th>
|
||||||
|
<th style="width: 20%;" class="text-right">Pendapatan</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@if(isset($data['nampan']) && count($data['nampan']) > 0)
|
||||||
|
@foreach($data['nampan'] as $item)
|
||||||
|
<tr>
|
||||||
|
<td>{{ $item['nama_nampan'] }}</td>
|
||||||
|
<td class="text-center">{{ $item['jumlah_item_terjual'] }}</td>
|
||||||
|
<td class="text-right">{{ $item['berat_terjual'] }}</td>
|
||||||
|
<td class="text-right">{{ $item['pendapatan'] }}</td>
|
||||||
|
</tr>
|
||||||
|
@endforeach
|
||||||
|
@else
|
||||||
|
<tr>
|
||||||
|
<td colspan="4" class="no-data">Tidak ada data untuk ditampilkan</td>
|
||||||
|
</tr>
|
||||||
|
@endif
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="page-footer">
|
||||||
|
Dicetak pada: {{ \Carbon\Carbon::now()->isoFormat('dddd, D MMMM Y - HH:mm') }} WIB
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
171
resources/views/exports/perproduk_pdf.blade.php
Normal file
171
resources/views/exports/perproduk_pdf.blade.php
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>{{ $title ?? 'Laporan Detail Per Produk' }}</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: sans-serif;
|
||||||
|
font-size: 10px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
border-bottom: 2px solid #333;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
}
|
||||||
|
.header h2 {
|
||||||
|
margin: 0 0 10px 0;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
.filter-info {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
border-left: 4px solid #007bff;
|
||||||
|
}
|
||||||
|
.filter-info h3 {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
.filter-item {
|
||||||
|
margin: 3px 0;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
.filter-label {
|
||||||
|
font-weight: bold;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
font-size: 9px;
|
||||||
|
}
|
||||||
|
th, td {
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
padding: 8px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
th {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
.text-right { text-align: right; }
|
||||||
|
.text-center { text-align: center; }
|
||||||
|
.rekap-section {
|
||||||
|
background-color: #e9ecef;
|
||||||
|
padding: 10px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
.rekap-title {
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
.rekap-item {
|
||||||
|
display: inline-block;
|
||||||
|
margin-right: 20px;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
.rekap-label {
|
||||||
|
font-weight: bold;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
.no-data {
|
||||||
|
text-align: center;
|
||||||
|
font-style: italic;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
.page-footer {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 10px;
|
||||||
|
right: 10px;
|
||||||
|
font-size: 8px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<h2>{{ $title ?? 'Laporan Detail Per Produk' }}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if(isset($data['filter']))
|
||||||
|
<div class="filter-info">
|
||||||
|
<h3>Informasi Filter</h3>
|
||||||
|
<div class="filter-item">
|
||||||
|
<span class="filter-label">Tanggal:</span> {{ $data['filter']['tanggal'] }}
|
||||||
|
</div>
|
||||||
|
@if($data['filter']['nama_sales'])
|
||||||
|
<div class="filter-item">
|
||||||
|
<span class="filter-label">Sales:</span> {{ $data['filter']['nama_sales'] }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
@if($data['filter']['nampan'])
|
||||||
|
<div class="filter-item">
|
||||||
|
<span class="filter-label">Nampan:</span> {{ $data['filter']['nampan'] }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
@if($data['filter']['nama_pembeli'])
|
||||||
|
<div class="filter-item">
|
||||||
|
<span class="filter-label">Nama Pembeli:</span> {{ $data['filter']['nama_pembeli'] }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if(isset($data['rekap_harian']))
|
||||||
|
<div class="rekap-section">
|
||||||
|
<div class="rekap-title">Rekap Harian</div>
|
||||||
|
<div class="rekap-item">
|
||||||
|
<span class="rekap-label">Total Item Terjual:</span> {{ $data['rekap_harian']['total_item_terjual'] }}
|
||||||
|
</div>
|
||||||
|
<div class="rekap-item">
|
||||||
|
<span class="rekap-label">Total Berat:</span> {{ $data['rekap_harian']['total_berat_terjual'] }}
|
||||||
|
</div>
|
||||||
|
<div class="rekap-item">
|
||||||
|
<span class="rekap-label">Total Pendapatan:</span> {{ $data['rekap_harian']['total_pendapatan'] }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 40%;">Nama Produk</th>
|
||||||
|
<th style="width: 20%;" class="text-center">Jumlah Item Terjual</th>
|
||||||
|
<th style="width: 20%;" class="text-right">Berat Terjual</th>
|
||||||
|
<th style="width: 20%;" class="text-right">Pendapatan</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@if(isset($data['produk']) && count($data['produk']) > 0)
|
||||||
|
@foreach($data['produk'] as $item)
|
||||||
|
<tr>
|
||||||
|
<td>{{ $item['nama_produk'] }}</td>
|
||||||
|
<td class="text-center">{{ $item['jumlah_item_terjual'] }}</td>
|
||||||
|
<td class="text-right">{{ $item['berat_terjual'] }}</td>
|
||||||
|
<td class="text-right">{{ $item['pendapatan'] }}</td>
|
||||||
|
</tr>
|
||||||
|
@endforeach
|
||||||
|
@else
|
||||||
|
<tr>
|
||||||
|
<td colspan="4" class="no-data">Tidak ada data untuk ditampilkan</td>
|
||||||
|
</tr>
|
||||||
|
@endif
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="page-footer">
|
||||||
|
Dicetak pada: {{ \Carbon\Carbon::now()->isoFormat('dddd, D MMMM Y - HH:mm') }} WIB
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
@ -2,40 +2,173 @@
|
|||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<title>Laporan Ringkasan</title>
|
<title>{{ $title ?? 'Laporan Ringkasan' }}</title>
|
||||||
<style>
|
<style>
|
||||||
body { font-family: sans-serif; font-size: 10px; }
|
body {
|
||||||
table { width: 100%; border-collapse: collapse; margin-bottom: 15px; }
|
font-family: sans-serif;
|
||||||
th, td { border: 1px solid #ccc; padding: 4px; text-align: left; }
|
font-size: 10px;
|
||||||
th { background-color: #f0f0f0; }
|
margin: 0;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
border-bottom: 2px solid #333;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
}
|
||||||
|
.header h2 {
|
||||||
|
margin: 0 0 10px 0;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
.filter-info {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
border-left: 4px solid #007bff;
|
||||||
|
}
|
||||||
|
.filter-info h3 {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
.filter-item {
|
||||||
|
margin: 3px 0;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
.filter-label {
|
||||||
|
font-weight: bold;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
font-size: 9px;
|
||||||
|
}
|
||||||
|
th, td {
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
padding: 8px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
th {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
.text-right { text-align: right; }
|
.text-right { text-align: right; }
|
||||||
.text-center { text-align: center; }
|
.text-center { text-align: center; }
|
||||||
tr.total-row td { background-color: #f9f9f9; font-weight: bold; }
|
.total-row td {
|
||||||
|
background-color: #e9ecef;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
.rekap-section {
|
||||||
|
background-color: #e9ecef;
|
||||||
|
padding: 10px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
.rekap-title {
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
.rekap-item {
|
||||||
|
display: inline-block;
|
||||||
|
margin-right: 20px;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
.rekap-label {
|
||||||
|
font-weight: bold;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
.no-data {
|
||||||
|
text-align: center;
|
||||||
|
font-style: italic;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
.periode-section {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.periode-header {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 5px 5px 0 0;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-bottom: none;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #333;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.page-footer {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 10px;
|
||||||
|
right: 10px;
|
||||||
|
font-size: 8px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h2 style="text-align: center;">Laporan Ringkasan {{ ucfirst($filter) }}</h2>
|
<div class="header">
|
||||||
|
<h2>{{ $title ?? 'Laporan Ringkasan ' . ucfirst($filter) }}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
<table>
|
@if(isset($data_filter))
|
||||||
|
<div class="filter-info">
|
||||||
|
<h3>Informasi Filter</h3>
|
||||||
|
<div class="filter-item">
|
||||||
|
<span class="filter-label">Periode:</span> {{ ucfirst($filter) }}
|
||||||
|
</div>
|
||||||
|
@if($data_filter['tanggal_mulai'] && $data_filter['tanggal_selesai'])
|
||||||
|
<div class="filter-item">
|
||||||
|
<span class="filter-label">Rentang Tanggal:</span> {{ $data_filter['tanggal_mulai'] }} s/d {{ $data_filter['tanggal_selesai'] }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
@if($data_filter['sales_filter'])
|
||||||
|
<div class="filter-item">
|
||||||
|
<span class="filter-label">Sales:</span> {{ $data_filter['sales_filter'] }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if(isset($grand_total))
|
||||||
|
<div class="rekap-section">
|
||||||
|
<div class="rekap-title">Rekap {{ ucfirst($filter) }}</div>
|
||||||
|
<div class="rekap-item">
|
||||||
|
<span class="rekap-label">Total Item Terjual:</span> {{ $grand_total['total_item'] }}
|
||||||
|
</div>
|
||||||
|
<div class="rekap-item">
|
||||||
|
<span class="rekap-label">Total Berat:</span> {{ $grand_total['total_berat'] }}
|
||||||
|
</div>
|
||||||
|
<div class="rekap-item">
|
||||||
|
<span class="rekap-label">Total Pendapatan:</span> {{ $grand_total['total_pendapatan'] }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@foreach($data as $item)
|
||||||
|
<div class="periode-section">
|
||||||
|
<div class="periode-header">
|
||||||
|
{{ $item['tanggal'] }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table style="margin-bottom: 0;">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Tanggal</th>
|
<th style="width: 30%;">Nama Sales</th>
|
||||||
<th>Nama Sales</th>
|
<th style="width: 20%;" class="text-center">Item Terjual</th>
|
||||||
<th>Item Terjual</th>
|
<th style="width: 25%;" class="text-right">Berat Terjual</th>
|
||||||
<th>Berat Terjual</th>
|
<th style="width: 25%;" class="text-right">Pendapatan</th>
|
||||||
<th>Pendapatan</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@foreach($data as $item)
|
|
||||||
@php $rowCount = count($item['sales']) > 0 ? count($item['sales']) : 1; @endphp
|
|
||||||
|
|
||||||
@if(count($item['sales']) > 0)
|
@if(count($item['sales']) > 0)
|
||||||
@foreach($item['sales'] as $index => $sales)
|
@foreach($item['sales'] as $sales)
|
||||||
<tr>
|
<tr>
|
||||||
@if($index == 0)
|
|
||||||
<td rowspan="{{ $rowCount }}">{{ $item['tanggal'] }}</td>
|
|
||||||
@endif
|
|
||||||
<td>{{ $sales['nama'] }}</td>
|
<td>{{ $sales['nama'] }}</td>
|
||||||
<td class="text-center">{{ $sales['item_terjual'] }}</td>
|
<td class="text-center">{{ $sales['item_terjual'] }}</td>
|
||||||
<td class="text-right">{{ $sales['berat_terjual'] }}</td>
|
<td class="text-right">{{ $sales['berat_terjual'] }}</td>
|
||||||
@ -44,20 +177,24 @@
|
|||||||
@endforeach
|
@endforeach
|
||||||
@else
|
@else
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ $item['tanggal'] }}</td>
|
<td colspan="4" class="no-data">Tidak ada data transaksi</td>
|
||||||
<td colspan="4" class="text-center" style="font-style: italic;">Tidak ada data transaksi</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
{{-- Baris Total --}}
|
{{-- Baris Total --}}
|
||||||
<tr class="total-row">
|
<tr class="total-row">
|
||||||
<td colspan="2" class="text-right"><strong>Total Periode Ini</strong></td>
|
<td class="text-right"><strong>Total Periode Ini</strong></td>
|
||||||
<td class="text-center"><strong>{{ $item['total_item_terjual'] }}</strong></td>
|
<td class="text-center"><strong>{{ $item['total_item_terjual'] }}</strong></td>
|
||||||
<td class="text-right"><strong>{{ $item['total_berat'] }}</strong></td>
|
<td class="text-right"><strong>{{ $item['total_berat'] }}</strong></td>
|
||||||
<td class="text-right"><strong>{{ $item['total_pendapatan'] }}</strong></td>
|
<td class="text-right"><strong>{{ $item['total_pendapatan'] }}</strong></td>
|
||||||
</tr>
|
</tr>
|
||||||
@endforeach
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
|
|
||||||
|
<div class="page-footer">
|
||||||
|
Dicetak pada: {{ \Carbon\Carbon::now()->isoFormat('dddd, D MMMM Y - HH:mm') }} WIB
|
||||||
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
@ -39,11 +39,15 @@ Route::prefix('api')->group(function () {
|
|||||||
Route::delete('foto/reset/{user_id}', [FotoSementaraController::class, 'reset']);
|
Route::delete('foto/reset/{user_id}', [FotoSementaraController::class, 'reset']);
|
||||||
|
|
||||||
// Laporan
|
// Laporan
|
||||||
Route::get('laporan', [LaporanController::class, 'ringkasan']);
|
Route::prefix('laporan')->group(function () {
|
||||||
|
Route::get('ringkasan', [LaporanController::class, 'ringkasan']);
|
||||||
Route::get('detail-per-produk', [LaporanController::class, 'detailPerProduk']);
|
Route::get('detail-per-produk', [LaporanController::class, 'detailPerProduk']);
|
||||||
Route::get('detail-per-nampan', [LaporanController::class, 'detailPerNampan']);
|
Route::get('detail-per-nampan', [LaporanController::class, 'detailPerNampan']);
|
||||||
|
|
||||||
Route::get('/laporan/ringkasan/export', [LaporanController::class, 'exportRingkasan']);
|
Route::get('export/ringkasan', [LaporanController::class, 'exportRingkasan']);
|
||||||
|
Route::get('export/detail-pernampan', [LaporanController::class, 'exportDetailNampan']);
|
||||||
|
Route::get('export/detail-perproduk', [LaporanController::class, 'exportDetailProduk']);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
Route::middleware(['auth:sanctum', 'role:owner,kasir'])->group(function () {
|
Route::middleware(['auth:sanctum', 'role:owner,kasir'])->group(function () {
|
||||||
|
Loading…
Reference in New Issue
Block a user