Compare commits
107 Commits
main
...
production
Author | SHA1 | Date | |
---|---|---|---|
|
e80c26ac2f | ||
|
3313ae13c8 | ||
|
fc21772679 | ||
|
156671a21b | ||
|
cf8f456fb4 | ||
|
a345dd1229 | ||
|
b578faedd0 | ||
|
8e6aa4242b | ||
|
d32e659076 | ||
|
420cf47f20 | ||
|
634c0683b5 | ||
|
c28be3706e | ||
|
b9c562d0a2 | ||
|
7b1fdc30f6 | ||
|
1cd2aa60d4 | ||
|
4f880d44e4 | ||
|
7f4b41b904 | ||
|
c6cebf145d | ||
|
7083d585f1 | ||
|
10e666a9ce | ||
|
ebb17c2a43 | ||
|
86f3e101c8 | ||
|
7766fd8938 | ||
|
9b02e00a72 | ||
|
ae225ce5c7 | ||
|
6f87bde474 | ||
|
20c844a98b | ||
|
9b2d50ac65 | ||
|
ae4b8a3449 | ||
|
600c87d9ca | ||
|
8e59b1f1f1 | ||
|
b1babd6c26 | ||
|
e1a0711082 | ||
|
4afdcada62 | ||
|
b2b34a5f76 | ||
|
644d6fb222 | ||
|
2cce89b6c4 | ||
|
923f5c5c7f | ||
|
ae259cc273 | ||
|
1f8f11a7ca | ||
|
bb487a4c09 | ||
|
fd328b6e35 | ||
|
d58368389e | ||
|
bdf3a72c15 | ||
|
982f99ed7b | ||
|
396baa6444 | ||
|
4755dc66fc | ||
|
26e1ee751e | ||
|
bb7d6e7a32 | ||
|
26644df501 | ||
|
fcd7719826 | ||
|
3174b84c0a | ||
|
11954568ae | ||
|
a99996940e | ||
|
3f654c6c7a | ||
|
ae5507fda4 | ||
|
91b4010531 | ||
|
55213cee64 | ||
|
bfbe5d69a9 | ||
|
d51b73c347 | ||
|
cca9aeaaf0 | ||
|
baff04f6a5 | ||
|
937f24a5ff | ||
|
8ab48b4e7d | ||
|
21c96c54c5 | ||
|
b991551687 | ||
|
22e91d72b4 | ||
|
5b4d7ac6f5 | ||
|
394b885deb | ||
|
cfeae67dd2 | ||
|
538c96e6b0 | ||
|
e615058a51 | ||
|
7153d79316 | ||
|
4c4dd5d635 | ||
|
2eb29d6dc9 | ||
|
b2f93c4537 | ||
|
8a0ded4b3e | ||
|
c7812ea0fb | ||
|
99fe5322db | ||
|
fd3565fd64 | ||
|
c8d2e10a87 | ||
|
87b064850c | ||
|
d231ebe909 | ||
|
32bab1f01a | ||
|
d8348f203b | ||
|
e96d973b03 | ||
|
87d38dffb8 | ||
|
5217e2d703 | ||
|
4e06c25082 | ||
|
8046360f6e | ||
|
1a25501579 | ||
|
1d6bee91e1 | ||
|
2f72c40788 | ||
|
eebabfd919 | ||
|
f0f570be21 | ||
|
4dd3c5188f | ||
|
12192c536d | ||
|
0bb5b23ead | ||
|
e5f2c9920b | ||
|
773cc1516f | ||
|
65923ec59c | ||
|
ffe0039391 | ||
|
311605bd5f | ||
|
15917b4c52 | ||
|
71cd60981b | ||
|
fc5541b8b0 | ||
|
7f72921758 |
@ -1,8 +1,8 @@
|
||||
APP_NAME=Laravel
|
||||
APP_NAME=Abbauf-Kasir
|
||||
APP_ENV=local
|
||||
APP_KEY=
|
||||
APP_DEBUG=true
|
||||
APP_URL=http://localhost
|
||||
APP_URL=http://localhost:8000
|
||||
|
||||
APP_LOCALE=en
|
||||
APP_FALLBACK_LOCALE=en
|
||||
|
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;
|
||||
}
|
||||
}
|
81
app/Exports/RingkasanExport.php
Normal file
81
app/Exports/RingkasanExport.php
Normal file
@ -0,0 +1,81 @@
|
||||
<?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 RingkasanExport implements FromCollection, WithHeadings, WithTitle, WithStyles
|
||||
{
|
||||
private $data;
|
||||
private $page;
|
||||
|
||||
public function __construct(iterable $data, $page = 1)
|
||||
{
|
||||
$this->data = $data;
|
||||
$this->page = $page;
|
||||
}
|
||||
|
||||
|
||||
public function collection()
|
||||
{
|
||||
$collection = collect();
|
||||
$items = method_exists($this->data, 'items') ? $this->data->items() : $this->data;
|
||||
|
||||
foreach ($items as $item) {
|
||||
$collection->push([
|
||||
'Tanggal' => $item['tanggal'] ?? '-',
|
||||
'Total Item Terjual' => $item['total_item_terjual'] ?? 0,
|
||||
'Total Berat' => $item['total_berat'] ?? 0,
|
||||
'Total Pendapatan' => $item['total_pendapatan'] ?? 0,
|
||||
'Detail Sales' => $this->formatSalesData($item['sales'] ?? []),
|
||||
]);
|
||||
}
|
||||
|
||||
return $collection;
|
||||
}
|
||||
|
||||
public function headings(): array
|
||||
{
|
||||
return [
|
||||
'Tanggal',
|
||||
'Total Item Terjual',
|
||||
'Total Berat',
|
||||
'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;
|
||||
}
|
||||
}
|
51
app/Http/Controllers/AuthController.php
Normal file
51
app/Http/Controllers/AuthController.php
Normal file
@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
class AuthController extends Controller
|
||||
{
|
||||
|
||||
public function login(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'nama' => 'required',
|
||||
'password' => 'required',
|
||||
]);
|
||||
|
||||
// cari user berdasarkan nama
|
||||
$user = User::where('nama', $request->nama)->first();
|
||||
|
||||
if (!$user || !Hash::check($request->password, $user->password)) {
|
||||
return response()->json([
|
||||
'message' => 'Nama atau password salah'
|
||||
], 401);
|
||||
}
|
||||
|
||||
// buat token Sanctum
|
||||
$token = $user->createToken('auth_token')->plainTextToken;
|
||||
|
||||
$redirectUrl = $user->role === 'owner' ? '/brankas' : '/kasir';
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Login berhasil',
|
||||
'user' => $user,
|
||||
'token' => $token,
|
||||
'redirect' => $redirectUrl,
|
||||
'role' => $user->role
|
||||
]);
|
||||
}
|
||||
|
||||
public function logout(Request $request)
|
||||
{
|
||||
$request->user()->currentAccessToken()->delete();
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Logout berhasil'
|
||||
]);
|
||||
}
|
||||
}
|
@ -11,7 +11,7 @@ class FotoSementaraController extends Controller
|
||||
public function upload(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'id_produk' => 'required|exists:produk,id',
|
||||
'id_user' => 'required|exists:users,id',
|
||||
'foto' => 'required|image|mimes:jpg,jpeg,png|max:2048',
|
||||
]);
|
||||
|
||||
@ -19,11 +19,11 @@ class FotoSementaraController extends Controller
|
||||
$url = asset('storage/' . $path);
|
||||
|
||||
$foto = FotoSementara::create([
|
||||
'id_produk' => $request->id_produk,
|
||||
'id_user' => $request->id_user,
|
||||
'url' => $url,
|
||||
]);
|
||||
|
||||
return response()->json(['message' => 'Foto berhasil disimpan'], 201);
|
||||
return response()->json($foto, 201);
|
||||
}
|
||||
|
||||
public function hapus($id)
|
||||
@ -45,6 +45,9 @@ class FotoSementaraController extends Controller
|
||||
public function getAll($user_id)
|
||||
{
|
||||
$data = FotoSementara::where('id_user', $user_id);
|
||||
if (!$data->exists()) {
|
||||
return response()->json(['message' => 'Tidak ada foto ditemukan'], 404);
|
||||
}
|
||||
return response()->json($data);
|
||||
}
|
||||
|
||||
|
@ -23,11 +23,10 @@ class ItemController extends Controller
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'id_produk' => 'required|in:produks.id',
|
||||
'id_nampan' => 'nullable|in:nampans.id'
|
||||
'id_produk' => 'required',
|
||||
'id_nampan' => 'nullable'
|
||||
],[
|
||||
'id_produk' => 'Id produk tidak valid.',
|
||||
'id_nampan' => 'Id nampan tidak valid'
|
||||
]);
|
||||
|
||||
$item = Item::create($validated);
|
||||
@ -43,7 +42,7 @@ class ItemController extends Controller
|
||||
*/
|
||||
public function show(int $id)
|
||||
{
|
||||
$item = Item::with('produk.foto','nampan')->findOrFail($id);
|
||||
$item = Item::with('produk.foto','nampan','itemTransaksi.transaksi')->findOrFail($id);
|
||||
return response()->json($item);
|
||||
}
|
||||
|
||||
@ -53,8 +52,8 @@ class ItemController extends Controller
|
||||
public function update(Request $request, int $id)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'id_produk' => 'required|in:produks.id',
|
||||
'id_nampan' => 'nullable|in:nampans.id'
|
||||
'id_produk' => 'required|exists:produks,id',
|
||||
'id_nampan' => 'nullable|exists:nampans,id'
|
||||
],[
|
||||
'id_produk' => 'Id produk tidak valid.',
|
||||
'id_nampan' => 'Id nampan tidak valid'
|
||||
|
82
app/Http/Controllers/KategoriController.php
Normal file
82
app/Http/Controllers/KategoriController.php
Normal file
@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Kategori;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class KategoriController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display a listing of the resource.
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
return response()->json(
|
||||
Kategori::withCount('produk')->get()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a newly created resource in storage.
|
||||
*/
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'nama' => 'required|string|max:100',
|
||||
],
|
||||
[
|
||||
'nama' => 'Nama kategori harus diisi.'
|
||||
]);
|
||||
|
||||
Kategori::create($validated);
|
||||
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Kategori berhasil dibuat'
|
||||
],201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the specified resource.
|
||||
*/
|
||||
public function show(int $id)
|
||||
{
|
||||
return response()->json(
|
||||
Kategori::with('items.produk.foto')->find($id)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the specified resource in storage.
|
||||
*/
|
||||
public function update(Request $request, int $id)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'nama' => 'required|string|max:100',
|
||||
],
|
||||
[
|
||||
'nama' => 'Nama Kategori harus diisi.'
|
||||
]);
|
||||
|
||||
$Kategori = Kategori::findOrFail($id);
|
||||
|
||||
$Kategori->update($validated);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Kategori berhasil diupdate'
|
||||
],200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the specified resource from storage.
|
||||
*/
|
||||
public function destroy(int $id)
|
||||
{
|
||||
Kategori::findOrFail($id)->delete();
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Kategori berhasil dihapus'
|
||||
], 204);
|
||||
}
|
||||
}
|
123
app/Http/Controllers/LaporanController.php
Normal file
123
app/Http/Controllers/LaporanController.php
Normal file
@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\LaporanService;
|
||||
use App\Http\Requests\DetailLaporanRequest;
|
||||
use App\Http\Requests\ExportLaporanRequest;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class LaporanController extends Controller
|
||||
{
|
||||
private LaporanService $laporanService;
|
||||
|
||||
public function __construct(LaporanService $laporanService)
|
||||
{
|
||||
$this->laporanService = $laporanService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Endpoint untuk ringkasan laporan dengan caching
|
||||
*/
|
||||
public function ringkasan(Request $request)
|
||||
{
|
||||
try {
|
||||
$filter = $request->query('filter', 'bulan');
|
||||
$page = (int) $request->query('page', 1);
|
||||
|
||||
// Validasi filter
|
||||
if (!in_array($filter, ['hari', 'bulan'])) {
|
||||
return response()->json(['error' => 'Filter harus "hari" atau "bulan"'], 400);
|
||||
}
|
||||
|
||||
$data = $this->laporanService->getRingkasan($filter, $page);
|
||||
|
||||
return response()->json($data);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error in ringkasan method: ' . $e->getMessage());
|
||||
return response()->json(['error' => 'Terjadi kesalahan saat mengambil data'], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detail laporan per produk
|
||||
*/
|
||||
public function detailPerProduk(DetailLaporanRequest $request)
|
||||
{
|
||||
try {
|
||||
$data = $this->laporanService->getDetailPerProduk($request->validated());
|
||||
return response()->json($data);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error in detail PerProduk method: ' . $e->getMessage());
|
||||
return response()->json(['error' => 'Terjadi kesalahan saat mengambil data produk'], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detail laporan per nampan
|
||||
*/
|
||||
public function detailPerNampan(DetailLaporanRequest $request)
|
||||
{
|
||||
try {
|
||||
$data = $this->laporanService->getDetailPerNampan($request->validated());
|
||||
return response()->json($data);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error in detailPerNampan method: ' . $e->getMessage());
|
||||
return response()->json(['error' => 'Terjadi kesalahan saat mengambil data nampan'], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Export laporan ringkasan
|
||||
*/
|
||||
public function exportRingkasan(ExportLaporanRequest $request)
|
||||
{
|
||||
try {
|
||||
return $this->laporanService->exportRingkasan($request->validated());
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error in exportRingkasan method: ' . $e->getMessage());
|
||||
return response()->json(['error' => 'Terjadi kesalahan saat export data'], 500);
|
||||
}
|
||||
}
|
||||
|
||||
public function exportDetailNampan(Request $request)
|
||||
{
|
||||
try {
|
||||
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)
|
||||
{
|
||||
try {
|
||||
return $this->laporanService->exportPerProduk($request->validate([
|
||||
'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',
|
||||
]));
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error in exprot per nampan method: ' . $e->getMessage());
|
||||
return response()->json(['error' => 'Terjadi kesalahan saat export data'], 500);
|
||||
}
|
||||
}
|
||||
}
|
@ -3,6 +3,7 @@
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Nampan;
|
||||
use App\Models\Item;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class NampanController extends Controller
|
||||
@ -13,7 +14,7 @@ class NampanController extends Controller
|
||||
public function index()
|
||||
{
|
||||
return response()->json(
|
||||
Nampan::withCount('items')->get()
|
||||
Nampan::with('items.produk.foto')->withCount('items')->get()
|
||||
);
|
||||
}
|
||||
|
||||
@ -43,7 +44,7 @@ class NampanController extends Controller
|
||||
public function show(int $id)
|
||||
{
|
||||
return response()->json(
|
||||
Nampan::with('items')->find($id)
|
||||
Nampan::with('items.produk.foto')->find($id)
|
||||
);
|
||||
}
|
||||
|
||||
@ -85,4 +86,14 @@ class NampanController extends Controller
|
||||
'message' => 'Nampan berhasil dihapus'
|
||||
], 204);
|
||||
}
|
||||
|
||||
public function kosongkan()
|
||||
{
|
||||
Item::query()->update(['id_nampan' => null]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Semua nampan berhasil dikosongkan'
|
||||
], 200);
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -17,7 +17,7 @@ class ProdukController extends Controller
|
||||
public function index()
|
||||
{
|
||||
return response()->json(
|
||||
Produk::withCount('items')->with('foto')->get()
|
||||
Produk::withCount('items')->with('foto', 'kategori')->get()
|
||||
);
|
||||
}
|
||||
|
||||
@ -28,16 +28,16 @@ class ProdukController extends Controller
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'nama' => 'required|string|max:100',
|
||||
'kategori' => 'required|in:cincin,gelang,kalung,anting',
|
||||
'id_kategori' => 'required|exists:kategoris,id',
|
||||
'berat' => 'required|numeric',
|
||||
'kadar' => 'required|integer',
|
||||
'harga_per_gram' => 'required|numeric',
|
||||
'harga_jual' => 'required|numeric',
|
||||
'id_user' => 'nullable|exists:users,id', // untuk mengambil foto sementara
|
||||
'id_user' => 'nullable|exists:users,id',
|
||||
],
|
||||
[
|
||||
'nama.required' => 'Nama produk harus diisi.',
|
||||
'kategori.in' => 'Kategori harus salah satu dari cincin, gelang, kalung, atau anting.',
|
||||
'id_kategori' => 'Kategori tidak valid.',
|
||||
'berat.required' => 'Berat harus diisi.',
|
||||
'kadar.required' => 'Kadar harus diisi',
|
||||
'harga_per_gram.required' => 'Harga per gram harus diisi',
|
||||
@ -49,7 +49,7 @@ class ProdukController extends Controller
|
||||
// Create produk
|
||||
$produk = Produk::create([
|
||||
'nama' => $validated['nama'],
|
||||
'kategori' => $validated['kategori'],
|
||||
'id_kategori' => $validated['id_kategori'],
|
||||
'berat' => $validated['berat'],
|
||||
'kadar' => $validated['kadar'],
|
||||
'harga_per_gram' => $validated['harga_per_gram'],
|
||||
@ -92,7 +92,7 @@ class ProdukController extends Controller
|
||||
*/
|
||||
public function show(int $id)
|
||||
{
|
||||
$produk = Produk::with('foto', 'items')->findOrFail($id);
|
||||
$produk = Produk::with('foto', 'items', 'kategori')->findOrFail($id);
|
||||
return response()->json($produk);
|
||||
}
|
||||
|
||||
@ -103,7 +103,7 @@ class ProdukController extends Controller
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'nama' => 'required|string|max:100',
|
||||
'kategori' => 'required|in:cincin,gelang,kalung,anting',
|
||||
'id_kategori' => 'required|exists:kategoris,id',
|
||||
'berat' => 'required|numeric',
|
||||
'kadar' => 'required|integer',
|
||||
'harga_per_gram' => 'required|numeric',
|
||||
@ -113,7 +113,7 @@ class ProdukController extends Controller
|
||||
],
|
||||
[
|
||||
'nama.required' => 'Nama produk harus diisi.',
|
||||
'kategori.in' => 'Kategori harus salah satu dari cincin, gelang, kalung, atau anting.',
|
||||
'id_kategori' => 'Kategori tidak valid.',
|
||||
'berat.required' => 'Berat harus diisi.',
|
||||
'kadar.required' => 'Kadar harus diisi',
|
||||
'harga_per_gram.required' => 'Harga per gram harus diisi',
|
||||
@ -127,7 +127,7 @@ class ProdukController extends Controller
|
||||
// Update data produk
|
||||
$produk->update([
|
||||
'nama' => $validated['nama'],
|
||||
'kategori' => $validated['kategori'],
|
||||
'id_kategori' => $validated['id_kategori'],
|
||||
'berat' => $validated['berat'],
|
||||
'kadar' => $validated['kadar'],
|
||||
'harga_per_gram' => $validated['harga_per_gram'],
|
||||
|
@ -82,7 +82,9 @@ class SalesController extends Controller
|
||||
*/
|
||||
public function destroy(int $id)
|
||||
{
|
||||
Sales::findOrFail($id)->delete();
|
||||
$sales = Sales::findOrFail($id);
|
||||
$sales->transaksi()->update(['id_sales' => null]);
|
||||
$sales->delete();
|
||||
return response()->json([
|
||||
'message' => 'Sales berhasil dihapus'
|
||||
], 200);
|
||||
|
@ -13,14 +13,31 @@ class TransaksiController extends Controller
|
||||
// List semua transaksi
|
||||
public function index()
|
||||
{
|
||||
$transaksi = Transaksi::with(['kasir', 'sales', 'items.item.produk'])->get();
|
||||
return response()->json($transaksi);
|
||||
$limit = request()->query('limit', null);
|
||||
$query = Transaksi::with(['kasir', 'sales', 'itemTransaksi.item.produk'])->latest();
|
||||
if ($limit) {
|
||||
$query->limit((int)$limit);
|
||||
}
|
||||
$transaksi = $query->get();
|
||||
|
||||
// Mapping agar sesuai dengan kebutuhan frontend
|
||||
$mapped = $transaksi->map(function ($trx) {
|
||||
return [
|
||||
'id' => $trx->id,
|
||||
'tanggal' => $trx->created_at->format('d/m/Y'),
|
||||
'kode' => 'TRX-' . str_pad($trx->id, 6, '0', STR_PAD_LEFT),
|
||||
'pendapatan'=> $trx->total_harga,
|
||||
];
|
||||
});
|
||||
|
||||
return response()->json($mapped);
|
||||
}
|
||||
|
||||
|
||||
// Detail transaksi by ID
|
||||
public function show($id)
|
||||
{
|
||||
$transaksi = Transaksi::with(['kasir', 'sales', 'items.item.produk'])->findOrFail($id);
|
||||
$transaksi = Transaksi::with(['kasir', 'sales', 'items.item.produk.foto'])->findOrFail($id);
|
||||
return response()->json($transaksi);
|
||||
}
|
||||
|
||||
|
@ -19,14 +19,14 @@ class UserController extends Controller
|
||||
public function store(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'nama' => 'required|nama|unique:users',
|
||||
'nama' => 'required|string|unique:users',
|
||||
'password' => 'required|min:6',
|
||||
'role' => 'required|in:owner, kasir',
|
||||
'role' => 'required|in:owner,kasir',
|
||||
]);
|
||||
|
||||
User::create([
|
||||
'nama' => $request->nama,
|
||||
'password' => bcrypt($request->password),
|
||||
'password' => $request->password,
|
||||
'role' => $request->role,
|
||||
]);
|
||||
|
||||
@ -41,22 +41,26 @@ class UserController extends Controller
|
||||
$user = User::findOrFail($id);
|
||||
|
||||
$request->validate([
|
||||
'nama' => 'required|nama|unique:users,nama,' . $id,
|
||||
'password' => 'required|min:6',
|
||||
'role' => 'required|in:owner, kasir',
|
||||
'nama' => 'required|string|unique:users,nama,' . $id,
|
||||
'password' => 'nullable|min:6',
|
||||
'role' => 'required|in:owner,kasir',
|
||||
]);
|
||||
|
||||
$user->update([
|
||||
$data = [
|
||||
'nama' => $request->nama,
|
||||
'password' => $request->password,
|
||||
'role' => $request->role,
|
||||
]);
|
||||
];
|
||||
|
||||
return response()->json([
|
||||
'message' => 'User berhasil diupdate'
|
||||
],200);
|
||||
if ($request->filled('password')) {
|
||||
$data['password'] = $request->password;
|
||||
}
|
||||
|
||||
$user->update($data);
|
||||
|
||||
return response()->json(['message' => 'User berhasil diupdate', 'user' => $user], 200);
|
||||
}
|
||||
|
||||
|
||||
public function destroy($id)
|
||||
{
|
||||
$user = User::findOrFail($id);
|
||||
|
30
app/Http/Middleware/RoleMiddleware.php
Normal file
30
app/Http/Middleware/RoleMiddleware.php
Normal file
@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class RoleMiddleware
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next, ...$roles): Response
|
||||
{
|
||||
// cek apakah user login
|
||||
if (!$request->user()) {
|
||||
return response()->json(['message' => 'Unauthenticated'], 401);
|
||||
}
|
||||
|
||||
// cek role user
|
||||
if (!in_array($request->user()->role, $roles)) {
|
||||
return response()->json(['message' => 'Forbidden'], 403);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
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"',
|
||||
];
|
||||
}
|
||||
}
|
@ -15,6 +15,8 @@ class Foto extends Model
|
||||
'url',
|
||||
];
|
||||
|
||||
protected $hidden = ['created_at', 'updated_at', 'deleted_at'];
|
||||
|
||||
public function produk()
|
||||
{
|
||||
return $this->belongsTo(Produk::class, 'id_produk');
|
||||
|
@ -10,4 +10,6 @@ class FotoSementara extends Model
|
||||
'id_user',
|
||||
'url',
|
||||
];
|
||||
|
||||
protected $hidden = ['created_at', 'updated_at', 'deleted_at'];
|
||||
}
|
||||
|
@ -1,9 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\itemTransaksi;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
|
||||
class Item extends Model
|
||||
@ -14,8 +13,37 @@ class Item extends Model
|
||||
'id_produk',
|
||||
'id_nampan',
|
||||
'is_sold',
|
||||
'kode_item', // ✅ ditambahkan agar bisa diisi otomatis
|
||||
];
|
||||
|
||||
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()
|
||||
{
|
||||
return $this->belongsTo(Produk::class, 'id_produk');
|
||||
@ -33,6 +61,6 @@ class Item extends Model
|
||||
|
||||
public function itemTransaksi()
|
||||
{
|
||||
return $this->hasMany(ItemTransaksi::class, 'id_item');
|
||||
return $this->hasOne(ItemTransaksi::class, 'id_item');
|
||||
}
|
||||
}
|
||||
|
@ -13,9 +13,12 @@ class ItemTransaksi extends Model
|
||||
protected $fillable = [
|
||||
'id_transaksi',
|
||||
'id_item',
|
||||
'harga_deal'
|
||||
'harga_deal',
|
||||
'posisi_asal'
|
||||
];
|
||||
|
||||
protected $hidden = ['created_at', 'updated_at', 'deleted_at'];
|
||||
|
||||
public function transaksi()
|
||||
{
|
||||
return $this->belongsTo(Transaksi::class, 'id_transaksi');
|
||||
|
22
app/Models/Kategori.php
Normal file
22
app/Models/Kategori.php
Normal file
@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class Kategori extends Model
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\KategoriFactory> */
|
||||
use HasFactory, SoftDeletes;
|
||||
|
||||
protected $fillable = ['nama'];
|
||||
|
||||
protected $hidden = ['created_at', 'updated_at', 'deleted_at'];
|
||||
|
||||
public function produk()
|
||||
{
|
||||
return $this->hasMany(Produk::class, 'id_kategori');
|
||||
}
|
||||
}
|
@ -13,9 +13,19 @@ class Nampan extends Model
|
||||
protected $fillable = [
|
||||
'nama'
|
||||
];
|
||||
protected $appends = ['berat_total'];
|
||||
|
||||
protected $hidden = ['created_at', 'updated_at', 'deleted_at'];
|
||||
|
||||
public function items()
|
||||
{
|
||||
return $this->hasMany(Item::class, 'id_nampan');
|
||||
}
|
||||
|
||||
public function getBeratTotalAttribute()
|
||||
{
|
||||
return $this->items()
|
||||
->join('produks', 'items.id_produk', '=', 'produks.id')
|
||||
->sum('produks.berat');
|
||||
}
|
||||
}
|
||||
|
@ -12,13 +12,15 @@ class Produk extends Model
|
||||
|
||||
protected $fillable = [
|
||||
'nama',
|
||||
'kategori',
|
||||
'id_kategori',
|
||||
'berat',
|
||||
'kadar',
|
||||
'harga_per_gram',
|
||||
'harga_jual',
|
||||
];
|
||||
|
||||
protected $hidden = ['created_at', 'updated_at', 'deleted_at'];
|
||||
|
||||
public function items()
|
||||
{
|
||||
return $this->hasMany(Item::class, 'id_produk');
|
||||
@ -28,4 +30,9 @@ class Produk extends Model
|
||||
{
|
||||
return $this->hasMany(Foto::class, 'id_produk');
|
||||
}
|
||||
|
||||
public function kategori()
|
||||
{
|
||||
return $this->belongsTo(Kategori::class, 'id_kategori');
|
||||
}
|
||||
}
|
||||
|
@ -16,6 +16,8 @@ class Sales extends Model
|
||||
'alamat'
|
||||
];
|
||||
|
||||
protected $hidden = ['created_at', 'updated_at', 'deleted_at'];
|
||||
|
||||
public function transaksi()
|
||||
{
|
||||
return $this->hasMany(Transaksi::class, 'id_sales');
|
||||
|
@ -9,10 +9,13 @@ class Transaksi extends Model
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\TransaksiFactory> */
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'kode_transaksi', // ✅ Tambahin kolom kode transaksi
|
||||
'id_kasir',
|
||||
'id_sales',
|
||||
'nama_sales',
|
||||
'nama_pembeli',
|
||||
'no_hp',
|
||||
'alamat',
|
||||
'ongkos_bikin',
|
||||
@ -20,6 +23,26 @@ class Transaksi extends Model
|
||||
'created_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()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'id_kasir');
|
||||
@ -34,14 +57,4 @@ class Transaksi extends Model
|
||||
{
|
||||
return $this->hasMany(ItemTransaksi::class, 'id_transaksi');
|
||||
}
|
||||
|
||||
public function items()
|
||||
{
|
||||
return $this->hasMany(ItemTransaksi::class, 'id_transaksi');
|
||||
}
|
||||
|
||||
public function foto ()
|
||||
{
|
||||
return $this->hasMany(Foto::class, 'id_produk');
|
||||
}
|
||||
}
|
||||
|
@ -6,11 +6,13 @@ namespace App\Models;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Laravel\Sanctum\HasApiTokens;
|
||||
|
||||
|
||||
class User extends Authenticatable
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\UserFactory> */
|
||||
use HasFactory, Notifiable;
|
||||
use HasApiTokens, HasFactory, Notifiable;
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
@ -45,4 +47,9 @@ class User extends Authenticatable
|
||||
'password' => 'hashed',
|
||||
];
|
||||
}
|
||||
|
||||
public function getAuthIdentifierName()
|
||||
{
|
||||
return 'id';
|
||||
}
|
||||
}
|
||||
|
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']}%");
|
||||
}
|
||||
}
|
||||
}
|
@ -15,6 +15,10 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
$middleware->validateCsrfTokens(except: [
|
||||
'api/*'
|
||||
]);
|
||||
|
||||
$middleware->alias([
|
||||
'role' => \App\Http\Middleware\RoleMiddleware::class,
|
||||
]);
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
//
|
||||
|
@ -7,9 +7,11 @@
|
||||
"license": "MIT",
|
||||
"require": {
|
||||
"php": "^8.2",
|
||||
"barryvdh/laravel-dompdf": "^3.1",
|
||||
"laravel/framework": "^12.0",
|
||||
"laravel/sanctum": "^4.2",
|
||||
"laravel/tinker": "^2.10.1"
|
||||
"laravel/tinker": "^2.10.1",
|
||||
"maatwebsite/excel": "^3.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"fakerphp/faker": "^1.23",
|
||||
|
960
composer.lock
generated
960
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@ -40,6 +40,11 @@ return [
|
||||
'driver' => 'session',
|
||||
'provider' => 'users',
|
||||
],
|
||||
|
||||
'api' => [
|
||||
'driver' => 'sanctum',
|
||||
'provider' => 'users',
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|
23
database/factories/KategoriFactory.php
Normal file
23
database/factories/KategoriFactory.php
Normal file
@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Kategori>
|
||||
*/
|
||||
class KategoriFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'nama' => $this->faker->word(),
|
||||
];
|
||||
}
|
||||
}
|
@ -2,6 +2,7 @@
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Kategori;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
@ -16,11 +17,16 @@ class ProdukFactory extends Factory
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
$kategori = Kategori::inRandomOrder()->first();
|
||||
|
||||
$harga_per_gram = $this->faker->numberBetween(80, 120) * 10000;
|
||||
$berat = $this->faker->randomFloat(2, 1, 10);
|
||||
|
||||
return [
|
||||
'nama' => $this->faker->words(3, true),
|
||||
'kategori' => $this->faker->randomElement(['cincin', 'gelang', 'kalung', 'anting']),
|
||||
'nama' => $kategori->nama . ' ' . $this->faker->words(mt_rand(1, 2), true),
|
||||
|
||||
'id_kategori' => $kategori->id,
|
||||
|
||||
'berat' => $berat,
|
||||
'kadar' => $this->faker->numberBetween(10, 24),
|
||||
'harga_per_gram' => $harga_per_gram,
|
||||
|
@ -4,33 +4,46 @@ namespace Database\Factories;
|
||||
|
||||
use App\Models\Sales;
|
||||
use App\Models\User;
|
||||
use App\Models\Transaksi;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Transaksi>
|
||||
*/
|
||||
class TransaksiFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected $model = Transaksi::class;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
|
||||
$sales = Sales::inRandomOrder()->first();
|
||||
$kasir = User::inRandomOrder()->first();
|
||||
|
||||
$date = $this->faker->dateTimeBetween('-3 months');
|
||||
$ongkos_bikin = $this->faker->numberBetween(8, 12) * 10000;
|
||||
|
||||
return [
|
||||
'id_kasir' => $kasir?->id,
|
||||
'id_sales' => $sales?->id,
|
||||
'nama_sales' => $sales?->nama ?? $this->faker->name(),
|
||||
'nama_sales' => $sales?->nama,
|
||||
'kode_transaksi' => 'bwabwa' . $this->faker->unique()->numberBetween(1, 9999), // temporary, will be updated in configure()
|
||||
'nama_pembeli' => $this->faker->name(),
|
||||
'no_hp' => $this->faker->phoneNumber(),
|
||||
'alamat' => $this->faker->address(),
|
||||
'ongkos_bikin' => $this->faker->randomFloat(2, 0, 1000000),
|
||||
'total_harga' => $this->faker->randomFloat(2, 100000, 5000000),
|
||||
'created_at' => now(),
|
||||
'ongkos_bikin' => $ongkos_bikin,
|
||||
'total_harga' => $ongkos_bikin,
|
||||
'created_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,29 @@
|
||||
<?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(): void
|
||||
{
|
||||
Schema::create('kategoris', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('nama', 100);
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('kategoris');
|
||||
}
|
||||
};
|
@ -14,7 +14,7 @@ return new class extends Migration
|
||||
Schema::create('produks', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('nama', 100);
|
||||
$table->enum('kategori', ['cincin', 'gelang', 'kalung', 'anting']);
|
||||
$table->foreignId('id_kategori')->constrained('kategoris');
|
||||
$table->float('berat');
|
||||
$table->integer('kadar');
|
||||
$table->double('harga_per_gram');
|
||||
|
@ -16,6 +16,7 @@ return new class extends Migration
|
||||
$table->foreignId('id_kasir')->constrained('users');
|
||||
$table->foreignId('id_sales')->nullable()->constrained('sales');
|
||||
$table->string('nama_sales', 100);
|
||||
$table->string('nama_pembeli', 100);
|
||||
$table->string('no_hp', 20);
|
||||
$table->string('alamat', 100);
|
||||
$table->double('ongkos_bikin')->nullable();
|
||||
|
@ -16,6 +16,7 @@ return new class extends Migration
|
||||
$table->foreignId('id_transaksi')->constrained('transaksis')->onDelete('cascade');
|
||||
$table->foreignId('id_item')->constrained('items');
|
||||
$table->double('harga_deal');
|
||||
$table->string('posisi_asal', 100);
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
@ -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');
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
};
|
@ -3,6 +3,7 @@
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\Item;
|
||||
use App\Models\Kategori;
|
||||
use App\Models\Nampan;
|
||||
use App\Models\Produk;
|
||||
use App\Models\Sales;
|
||||
@ -19,9 +20,14 @@ class DatabaseSeeder extends Seeder
|
||||
public function run(): void
|
||||
{
|
||||
User::factory()->create([
|
||||
'nama' => 'Test User',
|
||||
'nama' => 'andre',
|
||||
'role' => 'owner',
|
||||
'password' => bcrypt('123123123'),
|
||||
'password' => bcrypt('123123'),
|
||||
]);
|
||||
User::factory()->create([
|
||||
'nama' => 'luis',
|
||||
'role' => 'kasir',
|
||||
'password' => bcrypt('123123'),
|
||||
]);
|
||||
|
||||
User::factory(2)->create();
|
||||
@ -36,13 +42,21 @@ class DatabaseSeeder extends Seeder
|
||||
}
|
||||
}
|
||||
|
||||
$kategoriList = ['Cincin', 'Gelang Rantai', 'Gelang Bulat', 'Kalung', 'Liontin', 'Anting', 'Giwang'];
|
||||
foreach ($kategoriList as $kategori) {
|
||||
Kategori::factory()->create([
|
||||
'nama' => $kategori
|
||||
]);
|
||||
}
|
||||
|
||||
Produk::factory(10)->create()->each(function ($produk) {
|
||||
// setiap produk punya 1-3 foto
|
||||
$jumlah_foto = rand(1, 3);
|
||||
$fotoData = [];
|
||||
for ($i = 0; $i < $jumlah_foto; $i++) {
|
||||
$fotoData[] = [
|
||||
'url' => 'https://random-image-pepebigotes.vercel.app/api/random-image'
|
||||
// 'url' => 'https://random-image-pepebigotes.vercel.app/api/random-image'
|
||||
'url' => 'https://static.promediateknologi.id/crop/0x0:0x0/0x0/webp/photo/p2/255/2024/12/10/Screenshot_2024-12-10-11-50-18-88_1c337646f29875672b5a61192b9010f9-1-1282380831.jpg'
|
||||
];
|
||||
}
|
||||
$produk->foto()->createMany($fotoData);
|
||||
@ -68,17 +82,24 @@ class DatabaseSeeder extends Seeder
|
||||
}
|
||||
}
|
||||
|
||||
Transaksi::factory(20)->create()->each(function ($transaksi) {
|
||||
$jumlah_item = rand(1, 5);
|
||||
Transaksi::factory(40)->create()->each(function ($transaksi) {
|
||||
$jumlah_item = rand(1, 2);
|
||||
$items = Item::where('is_sold', false)->inRandomOrder()->limit($jumlah_item)->get();
|
||||
if ($items->isEmpty()) return;
|
||||
$total_harga = $transaksi->total_harga;
|
||||
foreach ($items as $item) {
|
||||
$transaksi->itemTransaksi()->create([
|
||||
'id_item' => $item->id,
|
||||
'harga_deal' => $item->produk->harga_jual,
|
||||
'posisi_asal' => $item->id_nampan ? 'Nampan ' . $item->nampan->nama : 'Brankas',
|
||||
]);
|
||||
$item->update(['is_sold' => true]);
|
||||
$item->update([
|
||||
'id_nampan' => null,
|
||||
'is_sold' => true,
|
||||
]);
|
||||
$total_harga += $item->produk->harga_jual;
|
||||
}
|
||||
$transaksi->update(['total_harga' => $total_harga]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
1540
package-lock.json
generated
1540
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -4,16 +4,17 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"dev": "vite"
|
||||
"dev": "concurrently \"php artisan serve\" \"vite\""
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"axios": "^1.11.0",
|
||||
"concurrently": "^9.0.1",
|
||||
"concurrently": "^9.2.1",
|
||||
"laravel-vite-plugin": "^2.0.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"vite": "^7.0.4"
|
||||
"vite": "^7.0.4",
|
||||
"vite-plugin-vue-devtools": "^8.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.5.19",
|
||||
|
@ -1,3 +1,4 @@
|
||||
@import url('https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css');
|
||||
@import 'tailwindcss';
|
||||
|
||||
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
|
||||
@ -5,20 +6,33 @@
|
||||
@source '../**/*.blade.php';
|
||||
@source '../**/*.js';
|
||||
|
||||
@import url('https://fonts.googleapis.com/css2?family=Platypi:wght@400;500;600;700&display=swap');
|
||||
|
||||
html, body {
|
||||
font-family: "Platypi", sans-serif;
|
||||
}
|
||||
|
||||
@theme {
|
||||
--font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
|
||||
'Segoe UI Symbol', 'Noto Color Emoji';
|
||||
}
|
||||
|
||||
@theme {
|
||||
/* @theme {
|
||||
--color-A: #F8F0E5;
|
||||
--color-B: #EADBC8;
|
||||
--color-C: #DAC0A3;
|
||||
--color-D: #0F2C59;
|
||||
--color-D: #024768;
|
||||
} */
|
||||
|
||||
@theme {
|
||||
--color-A: #EBF1F5;
|
||||
--color-B: #AFE5FF;
|
||||
--color-C: #77C7EE;
|
||||
--color-D: #024768;
|
||||
}
|
||||
|
||||
@keyframes shake {
|
||||
0%, 100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
10%, 30%, 50%, 70%, 90% {
|
||||
transform: translateX(-5px);
|
||||
}
|
||||
20%, 40%, 60%, 80% {
|
||||
transform: translateX(5px);
|
||||
}
|
||||
}
|
BIN
resources/images/logo.png
Normal file
BIN
resources/images/logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 138 KiB |
@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div id="app">
|
||||
<div>
|
||||
<router-view />
|
||||
</div>
|
||||
</template>
|
@ -1,33 +1,94 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Daftar Item -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
<div
|
||||
v-for="item in filteredItems"
|
||||
:key="item.id"
|
||||
class="flex justify-between items-center border rounded-lg p-3 shadow-sm hover:shadow-md transition"
|
||||
class="flex justify-between items-center border rounded-lg p-3 shadow-sm hover:shadow-md transition cursor-pointer"
|
||||
@click="openMovePopup(item)"
|
||||
>
|
||||
<!-- Gambar -->
|
||||
<!-- Gambar & Info Produk -->
|
||||
<div class="flex items-center gap-3">
|
||||
<img
|
||||
:src="item.image"
|
||||
alt="Product Image"
|
||||
class="w-12 h-12 object-contain"
|
||||
v-if="item.produk.foto?.length"
|
||||
:src="item.produk.foto[0].url"
|
||||
class="size-12 object-contain"
|
||||
/>
|
||||
<!-- Info produk -->
|
||||
<div>
|
||||
<p class="font-semibold">{{ item.produk.nama }}</p>
|
||||
<p class="text-sm text-gray-500">{{ item.produk.id }}</p>
|
||||
<p class="text-sm text-gray-500">ID: {{ item.produk.id }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Berat -->
|
||||
<span class="font-medium">{{ item.berat }}g</span>
|
||||
<span class="font-medium">{{ item.produk.berat }}g</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal Pindah Nampan -->
|
||||
<div
|
||||
v-if="isPopupVisible"
|
||||
class="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50"
|
||||
>
|
||||
<div class="bg-white rounded-xl shadow-lg max-w-sm w-full p-6 relative">
|
||||
<!-- QR Code -->
|
||||
<div class="flex justify-center mb-4">
|
||||
<div class="p-2 border rounded-lg">
|
||||
<img :src="qrCodeUrl" alt="QR Code" class="size-36" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info Produk -->
|
||||
<div class="text-center text-gray-700 font-medium mb-1">
|
||||
{{ selectedItem?.produk?.nama }}
|
||||
</div>
|
||||
<div class="text-center text-gray-500 text-sm mb-4">
|
||||
{{ selectedItem?.produk?.kategori }}
|
||||
</div>
|
||||
|
||||
<!-- Dropdown pilih nampan -->
|
||||
<div class="mb-4">
|
||||
<label for="tray-select" class="block text-sm font-medium mb-1">
|
||||
Nama Nampan
|
||||
</label>
|
||||
<select
|
||||
id="tray-select"
|
||||
v-model="selectedTrayId"
|
||||
class="w-full rounded-md border shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50"
|
||||
>
|
||||
|
||||
<option v-for="tray in trays" :key="tray.id" :value="tray.id">
|
||||
{{ tray.nama }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Tombol -->
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
@click="closePopup"
|
||||
class="px-4 py-2 rounded border text-gray-700 hover:bg-gray-100 transition"
|
||||
>
|
||||
Batal
|
||||
</button>
|
||||
<button
|
||||
@click="saveMove"
|
||||
:disabled="!selectedTrayId"
|
||||
class="px-4 py-2 rounded text-white transition"
|
||||
:class="selectedTrayId ? 'bg-orange-500 hover:bg-orange-600' : 'bg-gray-400 cursor-not-allowed'"
|
||||
>
|
||||
Simpan
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
|
||||
<script setup>
|
||||
|
||||
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import axios from "axios";
|
||||
|
||||
@ -39,21 +100,81 @@ const props = defineProps({
|
||||
});
|
||||
|
||||
const items = ref([]);
|
||||
const trays = ref([]);
|
||||
const loading = ref(true);
|
||||
const error = ref(null);
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res = await axios.get("/api/item"); // ganti sesuai URL backend
|
||||
items.value = res.data; // pastikan backend return array of items
|
||||
console.log(res.data);
|
||||
// --- state modal
|
||||
const isPopupVisible = ref(false);
|
||||
const selectedItem = ref(null);
|
||||
const selectedTrayId = ref("");
|
||||
|
||||
// QR Code generator
|
||||
const qrCodeUrl = computed(() => {
|
||||
if (selectedItem.value) {
|
||||
const data = `ITM-${selectedItem.value.id}-${selectedItem.value.produk.nama.replace(/\s/g, "")}`;
|
||||
return `https://api.qrserver.com/v1/create-qr-code/?size=150x150&data=${encodeURIComponent(
|
||||
data
|
||||
)}`;
|
||||
}
|
||||
return "";
|
||||
});
|
||||
|
||||
// --- fungsi modal
|
||||
const openMovePopup = (item) => {
|
||||
selectedItem.value = item;
|
||||
selectedTrayId.value = item.id_nampan;
|
||||
isPopupVisible.value = true;
|
||||
};
|
||||
const closePopup = () => {
|
||||
isPopupVisible.value = false;
|
||||
selectedItem.value = null;
|
||||
selectedTrayId.value = "";
|
||||
};
|
||||
|
||||
const saveMove = async () => {
|
||||
if (!selectedTrayId.value || !selectedItem.value) return;
|
||||
try {
|
||||
await axios.put(
|
||||
`/api/item/${selectedItem.value.id}`,
|
||||
{
|
||||
id_nampan: selectedTrayId.value,
|
||||
id_produk: selectedItem.value.id_produk,
|
||||
},
|
||||
{
|
||||
headers: { Authorization: `Bearer ${localStorage.getItem("token")}` },
|
||||
}
|
||||
);
|
||||
|
||||
await refreshData();
|
||||
closePopup();
|
||||
} catch (err) {
|
||||
console.error("Gagal memindahkan item:", err.response?.data || err);
|
||||
alert("Gagal memindahkan item. Silakan coba lagi.");
|
||||
}
|
||||
};
|
||||
|
||||
// --- ambil data
|
||||
const refreshData = async () => {
|
||||
try {
|
||||
const [itemRes, trayRes] = await Promise.all([
|
||||
axios.get("/api/item", {
|
||||
headers: { Authorization: `Bearer ${localStorage.getItem("token")}` },
|
||||
}),
|
||||
axios.get("/api/nampan", {
|
||||
headers: { Authorization: `Bearer ${localStorage.getItem("token")}` },
|
||||
}),
|
||||
]);
|
||||
items.value = itemRes.data;
|
||||
trays.value = trayRes.data;
|
||||
} catch (err) {
|
||||
error.value = err.message || "Gagal mengambil data";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
onMounted(refreshData);
|
||||
|
||||
const filteredItems = computed(() => {
|
||||
if (!props.search) return items.value;
|
||||
|
42
resources/js/components/ConfirmDeleteModal.vue
Normal file
42
resources/js/components/ConfirmDeleteModal.vue
Normal file
@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="fixed inset-0 bg-black/50 flex items-center justify-center z-[9999]"
|
||||
>
|
||||
<div
|
||||
class="bg-white rounded-lg shadow-lg p-6 w-[350px] text-center relative"
|
||||
>
|
||||
<!-- Judul -->
|
||||
<p class="text-lg font-semibold mb-2">{{ props.title }}?</p>
|
||||
|
||||
<!-- Deskripsi tambahan -->
|
||||
<p class="text-sm text-gray-600 mb-4">
|
||||
{{ props.message }}
|
||||
</p>
|
||||
|
||||
<!-- Tombol aksi -->
|
||||
<div class="flex justify-center gap-3">
|
||||
<button
|
||||
@click="$emit('cancel')"
|
||||
class="bg-gray-300 px-4 py-2 rounded hover:bg-gray-400"
|
||||
>
|
||||
Batal
|
||||
</button>
|
||||
<button
|
||||
@click="$emit('confirm')"
|
||||
class="bg-red-500 text-white px-4 py-2 rounded hover:bg-red-600"
|
||||
>
|
||||
Hapus
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
isOpen: Boolean,
|
||||
title:String,
|
||||
message:String
|
||||
});
|
||||
</script>
|
99
resources/js/components/CreateAkun.vue
Normal file
99
resources/js/components/CreateAkun.vue
Normal file
@ -0,0 +1,99 @@
|
||||
<template>
|
||||
<div
|
||||
class="fixed inset-0 flex items-center justify-center bg-black/65 z-50"
|
||||
>
|
||||
<div class="bg-white rounded-lg p-6 w-96 shadow-lg">
|
||||
<h2 class="text-lg font-bold mb-4">Tambah Akun</h2>
|
||||
|
||||
<form @submit.prevent="createAkun" class="space-y-3">
|
||||
<!-- Nama -->
|
||||
<label for="nama">Nama</label>
|
||||
<InputField
|
||||
v-model="form.nama"
|
||||
id="nama"
|
||||
type="text"
|
||||
:required="true"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label for="password">Password</label>
|
||||
<InputField
|
||||
v-model="form.password"
|
||||
id="password"
|
||||
type="password"
|
||||
:required="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label for="peran">Peran</label>
|
||||
<InputSelect
|
||||
v-model="form.role"
|
||||
:options="[
|
||||
{ value: 'owner', label: 'Owner' },
|
||||
{ value: 'kasir', label: 'Kasir' },
|
||||
]"
|
||||
placeholder="-- Pilih Peran --"
|
||||
/>
|
||||
|
||||
<!-- Tombol -->
|
||||
<div class="flex justify-end gap-2 mt-4">
|
||||
<button
|
||||
type="button"
|
||||
@click="$emit('close')"
|
||||
class="bg-gray-300 hover:bg-gray-400 px-4 py-2 rounded"
|
||||
>
|
||||
Batal
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="bg-green-500 hover:bg-green-600 text-white px-4 py-2 rounded"
|
||||
>
|
||||
Simpan
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Error -->
|
||||
<p v-if="errorMessage" class="text-red-500 text-sm mt-3">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from "axios";
|
||||
import InputField from "@/components/InputField.vue";
|
||||
import InputSelect from "@/components/InputSelect.vue";
|
||||
|
||||
export default {
|
||||
name: "CreateAkun",
|
||||
components: { InputField, InputSelect },
|
||||
data() {
|
||||
return {
|
||||
form: { nama: "", password: "", role: "" },
|
||||
errorMessage: "",
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
async createAkun() {
|
||||
try {
|
||||
await axios.post("api/user", this.form, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem(
|
||||
"token"
|
||||
)}`,
|
||||
},
|
||||
});
|
||||
this.form = { nama: "", password: "", role: "" };
|
||||
this.$emit("refresh");
|
||||
this.$emit("close");
|
||||
} catch (err) {
|
||||
this.errorMessage =
|
||||
err.response?.data?.message || "Gagal menambah akun.";
|
||||
console.error("Gagal tambah akun:", err);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
195
resources/js/components/CreateItemModal.vue
Normal file
195
resources/js/components/CreateItemModal.vue
Normal file
@ -0,0 +1,195 @@
|
||||
<template>
|
||||
<Modal :active="isOpen" size="md" @close="handleClose" clickOutside="false">
|
||||
<div class="p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">Item {{ product?.nama }}</h3>
|
||||
|
||||
<div v-if="!success">
|
||||
<div class="mb-4">
|
||||
<label class="block text-gray-700 mb-2">Pilih Nampan</label>
|
||||
<InputSelect v-model="selectedNampan" :options="positionListOptions" :disabled="loading" />
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3">
|
||||
<button @click="handleClose" :disabled="loading"
|
||||
class="px-4 py-2 text-white bg-gray-400 hover:bg-gray-500 rounded-lg transition-colors disabled:opacity-50">
|
||||
Batal
|
||||
</button>
|
||||
<button @click="createItem" :disabled="loading"
|
||||
class="px-4 py-2 bg-C hover:bg-B text-D rounded-lg transition-colors disabled:bg-A disabled:cursor-not-allowed flex items-center gap-2">
|
||||
<svg v-if="loading" class="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4">
|
||||
</circle>
|
||||
<path class="opacity-75" fill="currentColor"
|
||||
d="m4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z">
|
||||
</path>
|
||||
</svg>
|
||||
{{ loading ? 'Membuat...' : 'Buat Item' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Success State -->
|
||||
<div v-else>
|
||||
<div class="text-center">
|
||||
<div class="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<svg class="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7">
|
||||
</path>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h4 class="text-lg font-semibold text-gray-900 mb-2">Item Berhasil Dibuat!</h4>
|
||||
<p class="text-gray-600 mb-2">
|
||||
Item dari produk "<strong>{{ product?.nama }}</strong>" telah ditambahkan ke {{
|
||||
selectedNampanName }}.
|
||||
</p>
|
||||
<p class="text-sm text-gray-500 mb-6">
|
||||
ID Item: <strong>{{ createdItem.id }}</strong>
|
||||
</p>
|
||||
|
||||
<div class="flex flex-row justify-between gap-3">
|
||||
<button @click="handleClose"
|
||||
class="flex-1 px-6 py-2 bg-gray-400 hover:bg-gray-500 text-white rounded-lg transition-colors">
|
||||
Selesai
|
||||
</button>
|
||||
<button @click="printItem"
|
||||
class="flex-1 px-6 py-2 bg-C hover:bg-B text-D rounded-lg transition-colors opacity-50 cursor-not-allowed"
|
||||
disabled>
|
||||
Print
|
||||
</button>
|
||||
<button @click="addNewItem"
|
||||
class="flex-1 px-6 py-2 bg-C hover:bg-B text-D rounded-lg transition-colors">
|
||||
Buat Lagi
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import axios from 'axios';
|
||||
import Modal from './Modal.vue';
|
||||
import InputSelect from './InputSelect.vue';
|
||||
|
||||
// Props
|
||||
const props = defineProps({
|
||||
isOpen: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
product: {
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
});
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits(['close']);
|
||||
|
||||
// State
|
||||
const selectedNampan = ref('');
|
||||
const nampanList = ref([]);
|
||||
const positionListOptions = ref([
|
||||
{ value: '', label: 'Brankas', selected: true },
|
||||
])
|
||||
const success = ref(false);
|
||||
const loading = ref(false);
|
||||
const createdItem = ref(null);
|
||||
|
||||
// Computed
|
||||
const selectedNampanName = computed(() => {
|
||||
if (!selectedNampan.value) return 'Brankas';
|
||||
|
||||
console.log("Selected nampan ID:", selectedNampan.value);
|
||||
const nampan = nampanList.value.find(n => n.id === Number(selectedNampan.value));
|
||||
console.log("All nampan:", nampanList.value);
|
||||
console.log("Selected nampan:", nampan);
|
||||
return nampan ? nampan.nama : 'Brankas';
|
||||
});
|
||||
|
||||
// Methods
|
||||
const loadNampanList = async () => {
|
||||
try {
|
||||
const response = await axios.get('/api/nampan', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||
},
|
||||
});;
|
||||
nampanList.value = response.data;
|
||||
positionListOptions.value = [
|
||||
{ value: '', label: 'Brankas', selected: !selectedNampan.value },
|
||||
...nampanList.value.map(n => ({
|
||||
value: n.id,
|
||||
label: `${n.nama} (${n.items_count} items)`,
|
||||
selected: n.id === selectedNampan.value
|
||||
}))
|
||||
];
|
||||
} catch (error) {
|
||||
console.error('Error loading nampan list:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const createItem = async () => {
|
||||
if (!props.product) return;
|
||||
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
id_produk: props.product.id
|
||||
};
|
||||
|
||||
if (selectedNampan.value) {
|
||||
payload.id_nampan = selectedNampan.value;
|
||||
}
|
||||
|
||||
const response = await axios.post('/api/item', payload, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||
},
|
||||
});;
|
||||
|
||||
success.value = true;
|
||||
createdItem.value = response.data.data
|
||||
console.log('Item created:', createdItem);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error creating item:', error);
|
||||
alert('Gagal membuat item: ' + (error.response?.data?.message || error.message));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const addNewItem = () => {
|
||||
success.value = false;
|
||||
selectedNampan.value = '';
|
||||
};
|
||||
|
||||
const printItem = () => {
|
||||
alert('Wak waw');
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
// Reset state
|
||||
selectedNampan.value = '';
|
||||
success.value = false;
|
||||
loading.value = false;
|
||||
|
||||
emit('close');
|
||||
};
|
||||
|
||||
// Watchers
|
||||
watch(() => props.isOpen, (newValue) => {
|
||||
if (newValue) {
|
||||
selectedNampan.value = '';
|
||||
success.value = false;
|
||||
loading.value = false;
|
||||
|
||||
loadNampanList();
|
||||
}
|
||||
});
|
||||
</script>
|
82
resources/js/components/CreateKategori.vue
Normal file
82
resources/js/components/CreateKategori.vue
Normal file
@ -0,0 +1,82 @@
|
||||
<template>
|
||||
|
||||
<div v-if="isOpen" class="fixed inset-0 flex items-center justify-center bg-black/65 z-50">
|
||||
<div class="bg-white rounded-lg shadow-lg w-96 p-6 relative">
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-lg font-bold text-gray-800">
|
||||
{{ product ? 'Edit Kategori' : 'Tambah Kategori Baru' }}
|
||||
</h2>
|
||||
<button @click="emit('close')" class="text-gray-400 hover:text-gray-600">✕</button>
|
||||
</div>
|
||||
|
||||
<!-- Form -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Nama Kategori</label>
|
||||
<InputField
|
||||
v-model="form.nama"
|
||||
type="text"
|
||||
placeholder="Masukkan nama kategori"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Buttons -->
|
||||
<div class="flex justify-end gap-2 mt-4">
|
||||
<button
|
||||
@click="emit('close')"
|
||||
class="px-4 py-2 bg-gray-200 text-gray-700 rounded hover:bg-gray-300"
|
||||
>
|
||||
Batal
|
||||
</button>
|
||||
<button
|
||||
@click="saveKategori"
|
||||
:disabled="!form.nama"
|
||||
class="px-4 py-2 bg-C text-black rounded hover:bg-B"
|
||||
>
|
||||
Simpan
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import axios from 'axios'
|
||||
import InputField from './InputField.vue'
|
||||
|
||||
const props = defineProps({
|
||||
isOpen: Boolean,
|
||||
product: Object
|
||||
})
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
const form = ref({ nama: '' })
|
||||
|
||||
// Sync kalau ubah kategori
|
||||
watch(() => props.product, (val) => {
|
||||
form.value.nama = val ? val.nama : ''
|
||||
}, { immediate: true })
|
||||
|
||||
const saveKategori = async () => {
|
||||
try {
|
||||
if (props.product) {
|
||||
await axios.put(`/api/kategori/${props.product.id}`, form.value, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await axios.post('/api/kategori', form.value, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
emit('close') // tutup modal
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
alert('Gagal menyimpan kategori')
|
||||
}
|
||||
}
|
||||
</script>
|
86
resources/js/components/CreateSales.vue
Normal file
86
resources/js/components/CreateSales.vue
Normal file
@ -0,0 +1,86 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="fixed inset-0 bg-black/65 flex items-center justify-center z-50"
|
||||
>
|
||||
<div class="bg-white rounded-lg shadow-lg w-full max-w-lg p-6 relative">
|
||||
<h2 class="text-xl font-bold mb-4">Tambah Sales</h2>
|
||||
|
||||
<form @submit.prevent="handleSubmit" class="space-y-4">
|
||||
<!-- Nama -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Nama Sales</label>
|
||||
<InputField v-model="form.nama" type="text" placeholder="Masukkan nama sales" required />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">No HP</label>
|
||||
<InputField v-model="form.no_hp" type="text" placeholder="Masukkan nomor HP" required />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Alamat</label>
|
||||
<textarea
|
||||
v-model="form.alamat"
|
||||
placeholder="Masukkan alamat"
|
||||
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:ring-D focus:ring-opacity-50 p-2"
|
||||
required
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2 mt-6">
|
||||
<button
|
||||
type="button"
|
||||
@click="$emit('close')"
|
||||
class="px-4 py-2 bg-gray-300 rounded hover:bg-gray-400"
|
||||
>
|
||||
Batal
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="px-4 py-2 bg-C text-D rounded hover:bg-C/80"
|
||||
>
|
||||
Simpan
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from "vue"
|
||||
import axios from "axios"
|
||||
import InputField from "./InputField.vue"
|
||||
|
||||
const props = defineProps({
|
||||
isOpen: Boolean,
|
||||
})
|
||||
|
||||
const emit = defineEmits(["close", "saved"])
|
||||
|
||||
const form = ref({
|
||||
nama: "",
|
||||
no_hp: "",
|
||||
alamat: "",
|
||||
})
|
||||
|
||||
const resetForm = () => {
|
||||
form.value = { nama: "", no_hp: "", alamat: "" }
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
await axios.post("/api/sales", form.value, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||
},
|
||||
});
|
||||
resetForm()
|
||||
emit("saved")
|
||||
emit("close")
|
||||
} catch (error) {
|
||||
console.error("Error creating sales:", error)
|
||||
}
|
||||
}
|
||||
</script>
|
462
resources/js/components/DetailPerNampan.vue
Normal file
462
resources/js/components/DetailPerNampan.vue
Normal file
@ -0,0 +1,462 @@
|
||||
<template>
|
||||
<div class="my-6">
|
||||
<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="mb-3 w-full">
|
||||
<label class="text-D/80" for="pilihTanggal">Filter Tanggal:</label>
|
||||
<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" />
|
||||
</div>
|
||||
<div class="mb-3 w-full">
|
||||
<label class="text-D/80" for="pilihSales">Filter Sales:</label>
|
||||
<InputSelect :options="opsiSales" v-model="salesDipilih" id="pilihSales" />
|
||||
</div>
|
||||
<div class="mb-3 w-full">
|
||||
<label class="text-D/80" for="pilihPelanggan">Nama Pembeli:</label>
|
||||
<InputField placeholder="Nama pelanggan" v-model="namaPembeli" id="pilihPelanggan" />
|
||||
</div>
|
||||
<div class="mb-3 w-full">
|
||||
<label class="text-D/80" for="pilihProduk">Filter Produk:</label>
|
||||
<InputSelect :options="opsiProduk" v-model="produkDipilih" id="pilihProduk" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Export Section -->
|
||||
<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="bg-A p-3 rounded-md border border-C">
|
||||
<div class="text-xs text-D/60">Total Item</div>
|
||||
<div class="font-semibold text-D">{{ data.rekap_harian.total_item_terjual }}</div>
|
||||
</div>
|
||||
<div class="bg-A p-3 rounded-md border border-C">
|
||||
<div class="text-xs text-D/60">Total Berat</div>
|
||||
<div class="font-semibold text-D">{{ data.rekap_harian.total_berat_terjual }}</div>
|
||||
</div>
|
||||
<div class="bg-A p-3 rounded-md border border-C">
|
||||
<div class="text-xs text-D/60">Total Pendapatan</div>
|
||||
<div class="font-semibold text-D">{{ data.rekap_harian.total_pendapatan }}</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 -->
|
||||
<div class="relative w-40" ref="exportDropdownRef">
|
||||
<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">
|
||||
<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 right-0">
|
||||
<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>
|
||||
|
||||
<!-- Table Section -->
|
||||
<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">
|
||||
<button @click="handleSort('nama_nampan')"
|
||||
class="flex items-center justify-between w-full text-left hover:text-D/80 transition-colors">
|
||||
<span>Nama Nampan</span>
|
||||
<i :class="getSortIcon('nama_nampan')" class="ml-2"></i>
|
||||
</button>
|
||||
</th>
|
||||
<th class="border-x border-C px-3 py-3">
|
||||
<button @click="handleSort('jumlah_item_terjual')"
|
||||
class="flex items-center justify-center w-full hover:text-D/80 transition-colors">
|
||||
<span>Item Terjual</span>
|
||||
<i :class="getSortIcon('jumlah_item_terjual')" class="ml-2"></i>
|
||||
</button>
|
||||
</th>
|
||||
<th class="border-x border-C px-3 py-3">
|
||||
<button @click="handleSort('berat_terjual')"
|
||||
class="flex items-center justify-center w-full hover:text-D/80 transition-colors">
|
||||
<span>Total Berat</span>
|
||||
<i :class="getSortIcon('berat_terjual')" class="ml-2"></i>
|
||||
</button>
|
||||
</th>
|
||||
<th class="border-x border-C px-3 py-3">
|
||||
<button @click="handleSort('pendapatan')"
|
||||
class="flex items-center justify-center w-full hover:text-D/80 transition-colors">
|
||||
<span>Total Pendapatan</span>
|
||||
<i :class="getSortIcon('pendapatan')" class="ml-2"></i>
|
||||
</button>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="loading">
|
||||
<td colspan="4" 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="!sortedNampan.length">
|
||||
<td colspan="4" class="p-4 text-center">Tidak ada data untuk ditampilkan.</td>
|
||||
</tr>
|
||||
<template v-else v-for="item in sortedNampan" :key="item.nama_nampan">
|
||||
<tr class="text-center border-y border-C hover:bg-A">
|
||||
<td class="border-x border-C px-3 py-2 text-left">{{ item.nama_nampan }}</td>
|
||||
<td class="border-x border-C px-3 py-2">{{ item.jumlah_item_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">
|
||||
<div class="flex justify-center">
|
||||
<div :ref="el => { if (el) pendapatanElements.push(el) }" :style="pendapatanStyle"
|
||||
:class="item.pendapatan == '-' ? 'text-center' : 'text-right'">
|
||||
{{ item.pendapatan }}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Pagination -->
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, watch, computed, nextTick } from 'vue';
|
||||
import InputSelect from './InputSelect.vue';
|
||||
import InputField from './InputField.vue';
|
||||
import axios from 'axios';
|
||||
|
||||
// --- State ---
|
||||
const isExportOpen = ref(false);
|
||||
const exportDropdownRef = ref(null);
|
||||
|
||||
const exportOptions = ref([
|
||||
{ value: 'pdf', label: 'Pdf' },
|
||||
{ value: 'xlsx', label: 'Excel' },
|
||||
{ value: 'csv', label: 'Csv' }
|
||||
]);
|
||||
|
||||
const exportFormat = ref(null);
|
||||
const tanggalDipilih = ref('');
|
||||
const data = ref(null);
|
||||
const loading = ref(false);
|
||||
const loadingExport = ref(false);
|
||||
|
||||
// Sorting state
|
||||
const sortBy = ref(null);
|
||||
const sortOrder = ref('asc'); // 'asc' or 'desc'
|
||||
|
||||
const pagination = ref({
|
||||
current_page: 1,
|
||||
last_page: 1,
|
||||
total: 0,
|
||||
});
|
||||
|
||||
const pendapatanWidth = ref(0);
|
||||
const pendapatanElements = ref([]);
|
||||
|
||||
const salesDipilih = ref(null);
|
||||
const opsiSales = ref([
|
||||
{ label: 'Semua Sales', value: null, selected: true },
|
||||
]);
|
||||
|
||||
const produkDipilih = ref(null);
|
||||
const opsiProduk = ref([
|
||||
{ label: 'Semua Produk', value: null, selected: true },
|
||||
]);
|
||||
|
||||
const namaPembeli = ref(null);
|
||||
|
||||
// --- Computed ---
|
||||
const nampan = computed(() => data.value?.nampan || []);
|
||||
|
||||
const sortedNampan = computed(() => {
|
||||
if (!sortBy.value || !nampan.value.length) {
|
||||
return nampan.value;
|
||||
}
|
||||
|
||||
const sorted = [...nampan.value].sort((a, b) => {
|
||||
let aValue = a[sortBy.value];
|
||||
let bValue = b[sortBy.value];
|
||||
|
||||
// Handle different data types
|
||||
if (sortBy.value === 'nama_nampan') {
|
||||
// String comparison
|
||||
aValue = aValue?.toString().toLowerCase() || '';
|
||||
bValue = bValue?.toString().toLowerCase() || '';
|
||||
} else if (sortBy.value === 'jumlah_item_terjual') {
|
||||
// Numeric comparison
|
||||
aValue = parseInt(aValue) || 0;
|
||||
bValue = parseInt(bValue) || 0;
|
||||
} else if (sortBy.value === 'berat_terjual') {
|
||||
// Handle weight values (remove unit if exists)
|
||||
aValue = parseFloat(aValue?.toString().replace(/[^\d.-]/g, '')) || 0;
|
||||
bValue = parseFloat(bValue?.toString().replace(/[^\d.-]/g, '')) || 0;
|
||||
} else if (sortBy.value === 'pendapatan') {
|
||||
// Handle currency values (remove currency symbols and commas)
|
||||
if (aValue === '-') aValue = 0;
|
||||
if (bValue === '-') bValue = 0;
|
||||
aValue = parseFloat(aValue?.toString().replace(/[^\d.-]/g, '')) || 0;
|
||||
bValue = parseFloat(bValue?.toString().replace(/[^\d.-]/g, '')) || 0;
|
||||
}
|
||||
|
||||
if (sortOrder.value === 'asc') {
|
||||
if (typeof aValue === 'string') {
|
||||
return aValue.localeCompare(bValue);
|
||||
}
|
||||
return aValue - bValue;
|
||||
} else {
|
||||
if (typeof aValue === 'string') {
|
||||
return bValue.localeCompare(aValue);
|
||||
}
|
||||
return bValue - aValue;
|
||||
}
|
||||
});
|
||||
|
||||
return sorted;
|
||||
});
|
||||
|
||||
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(nampan, async (newValue) => {
|
||||
if (newValue && newValue.length > 0) {
|
||||
await nextTick();
|
||||
pendapatanElements.value = [];
|
||||
let maxWidth = 0;
|
||||
|
||||
await nextTick();
|
||||
pendapatanElements.value.forEach(el => {
|
||||
if (el && el.scrollWidth > maxWidth) {
|
||||
maxWidth = el.scrollWidth;
|
||||
}
|
||||
});
|
||||
pendapatanWidth.value = maxWidth;
|
||||
}
|
||||
}, { deep: true });
|
||||
|
||||
// --- Methods ---
|
||||
const handleSort = (column) => {
|
||||
if (sortBy.value === column) {
|
||||
// If same column, toggle sort order
|
||||
sortOrder.value = sortOrder.value === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
// If different column, set new column and default to ascending
|
||||
sortBy.value = column;
|
||||
sortOrder.value = 'asc';
|
||||
}
|
||||
};
|
||||
|
||||
const getSortIcon = (column) => {
|
||||
if (sortBy.value !== column) {
|
||||
return 'fas fa-sort text-D/40'; // Default sort icon
|
||||
}
|
||||
|
||||
if (sortOrder.value === 'asc') {
|
||||
return 'fas fa-sort-up text-D'; // Ascending
|
||||
} else {
|
||||
return 'fas fa-sort-down text-D'; // Descending
|
||||
}
|
||||
};
|
||||
|
||||
const fetchSales = async () => {
|
||||
try {
|
||||
const response = await axios.get('/api/sales', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||
},
|
||||
});
|
||||
const salesData = response.data;
|
||||
opsiSales.value = [
|
||||
{ label: 'Semua Sales', value: null },
|
||||
...salesData.map(sales => ({
|
||||
label: sales.nama,
|
||||
value: sales.id,
|
||||
}))
|
||||
];
|
||||
} catch (error) {
|
||||
console.error('Gagal mengambil data sales:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchProduk = async () => {
|
||||
try {
|
||||
const response = await axios.get('/api/produk', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||
},
|
||||
});
|
||||
const produkData = response.data;
|
||||
opsiProduk.value = [
|
||||
{ label: 'Semua Produk', value: null },
|
||||
...produkData.map(produk => ({
|
||||
label: produk.nama,
|
||||
value: produk.id,
|
||||
}))
|
||||
];
|
||||
} catch (error) {
|
||||
console.error('Gagal mengambil data produk:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchData = async (page = 1) => {
|
||||
if (!tanggalDipilih.value) return;
|
||||
|
||||
loading.value = true;
|
||||
pendapatanElements.value = [];
|
||||
|
||||
let queryParams = `tanggal=${tanggalDipilih.value}&page=${page}`;
|
||||
if (salesDipilih.value) queryParams += `&sales_id=${salesDipilih.value}`;
|
||||
if (produkDipilih.value) queryParams += `&produk_id=${produkDipilih.value}`;
|
||||
if (namaPembeli.value) queryParams += `&nama_pembeli=${encodeURIComponent(namaPembeli.value)}`;
|
||||
|
||||
try {
|
||||
const response = await axios.get(`/api/laporan/detail-per-nampan?${queryParams}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||
}
|
||||
});
|
||||
|
||||
data.value = response.data;
|
||||
|
||||
// Handle pagination data if provided by backend
|
||||
if (response.data.pagination) {
|
||||
pagination.value = {
|
||||
current_page: response.data.pagination.current_page,
|
||||
last_page: response.data.pagination.last_page,
|
||||
total: response.data.pagination.total,
|
||||
};
|
||||
} else {
|
||||
// Reset pagination if no pagination data
|
||||
pagination.value = {
|
||||
current_page: 1,
|
||||
last_page: 1,
|
||||
total: response.data.nampan ? response.data.nampan.length : 0,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Gagal mengambil data laporan nampan:', error);
|
||||
data.value = null;
|
||||
pagination.value = {
|
||||
current_page: 1,
|
||||
last_page: 1,
|
||||
total: 0,
|
||||
};
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const goToPage = (page) => {
|
||||
if (page >= 1 && page <= pagination.value.last_page) {
|
||||
pagination.value.current_page = page;
|
||||
fetchData(page);
|
||||
}
|
||||
};
|
||||
|
||||
const selectExport = async (option) => {
|
||||
exportFormat.value = option.value;
|
||||
isExportOpen.value = false;
|
||||
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) => {
|
||||
if (exportDropdownRef.value && !exportDropdownRef.value.contains(event.target)) {
|
||||
isExportOpen.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// --- Lifecycle Hooks ---
|
||||
onMounted(() => {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
tanggalDipilih.value = today;
|
||||
|
||||
fetchSales();
|
||||
fetchProduk();
|
||||
|
||||
document.addEventListener('click', closeDropdownsOnClickOutside);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', closeDropdownsOnClickOutside);
|
||||
});
|
||||
|
||||
// Watch for filter changes
|
||||
watch([tanggalDipilih, salesDipilih, produkDipilih, namaPembeli], () => {
|
||||
pagination.value.current_page = 1; // Reset to first page when filters change
|
||||
fetchData(1);
|
||||
}, { immediate: true });
|
||||
</script>
|
453
resources/js/components/DetailPerProduk.vue
Normal file
453
resources/js/components/DetailPerProduk.vue
Normal file
@ -0,0 +1,453 @@
|
||||
<template>
|
||||
<div class="my-6">
|
||||
<hr class="border-B mb-5" />
|
||||
|
||||
<div class="flex flex-row my-3 overflow-x-auto gap-1 md:gap-5 lg:gap-8">
|
||||
<div class="mb-3 w-full min-w-fit">
|
||||
<label class="text-D/80" for="pilihTanggal">Filter Tanggal:</label>
|
||||
<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" />
|
||||
</div>
|
||||
<div class="mb-3 w-full min-w-fit">
|
||||
<label class="text-D/80" for="pilihSales">Filter Sales:</label>
|
||||
<InputSelect :options="opsiSales" v-model="salesDipilih" id="pilihSales" />
|
||||
</div>
|
||||
<div class="mb-3 w-full min-w-fit">
|
||||
<label class="text-D/80" for="pilihPelanggan">Nama Pembeli:</label>
|
||||
<InputField placeholder="Cari nama pelanggan" v-model="namaPembeli" id="pilihPelanggan" />
|
||||
</div>
|
||||
<div class="mb-3 w-full min-w-fit">
|
||||
<label class="text-D/80" for="pilihNampan">Filter Nampan:</label>
|
||||
<InputSelect :options="opsiNampan" v-model="nampanDipilih" id="pilihNampan" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row items-center justify-between mt-5 gap-3">
|
||||
<div class="flex gap-4" v-if="data?.rekap_harian">
|
||||
<div class="bg-A p-3 rounded-md border border-C">
|
||||
<div class="text-xs text-D/60">Total Item</div>
|
||||
<div class="font-semibold text-D">{{ data.rekap_harian.total_item_terjual }}</div>
|
||||
</div>
|
||||
<div class="bg-A p-3 rounded-md border border-C">
|
||||
<div class="text-xs text-D/60">Total Berat</div>
|
||||
<div class="font-semibold text-D">{{ data.rekap_harian.total_berat_terjual }}</div>
|
||||
</div>
|
||||
<div class="bg-A p-3 rounded-md border border-C">
|
||||
<div class="text-xs text-D/60">Total Pendapatan</div>
|
||||
<div class="font-semibold text-D">{{ data.rekap_harian.total_pendapatan }}</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>
|
||||
|
||||
<div class="relative w-40" ref="exportDropdownRef">
|
||||
<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">
|
||||
<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 right-0">
|
||||
<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">
|
||||
<button @click="handleSort('nama_produk')"
|
||||
class="flex items-center justify-between w-full text-left hover:text-D/80 transition-colors">
|
||||
<span>Nama Produk</span>
|
||||
<i :class="getSortIcon('nama_produk')" class="ml-2"></i>
|
||||
</button>
|
||||
</th>
|
||||
<th class="border-x border-C px-3 py-3">
|
||||
<button @click="handleSort('jumlah_item_terjual')"
|
||||
class="flex items-center justify-center w-full hover:text-D/80 transition-colors">
|
||||
<span>Item Terjual</span>
|
||||
<i :class="getSortIcon('jumlah_item_terjual')" class="ml-2"></i>
|
||||
</button>
|
||||
</th>
|
||||
<th class="border-x border-C px-3 py-3">
|
||||
<button @click="handleSort('berat_terjual')"
|
||||
class="flex items-center justify-center w-full hover:text-D/80 transition-colors">
|
||||
<span>Total Berat</span>
|
||||
<i :class="getSortIcon('berat_terjual')" class="ml-2"></i>
|
||||
</button>
|
||||
</th>
|
||||
<th class="border-x border-C px-3 py-3">
|
||||
<button @click="handleSort('pendapatan')"
|
||||
class="flex items-center justify-center w-full hover:text-D/80 transition-colors">
|
||||
<span>Total Pendapatan</span>
|
||||
<i :class="getSortIcon('pendapatan')" class="ml-2"></i>
|
||||
</button>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="loading">
|
||||
<td colspan="4" 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="!sortedProduk.length">
|
||||
<td colspan="4" class="p-4 text-center">Tidak ada data untuk ditampilkan.</td>
|
||||
</tr>
|
||||
<template v-else v-for="item in sortedProduk" :key="item.nama_produk">
|
||||
<tr class="text-center border-y border-C hover:bg-A">
|
||||
<td class="border-x border-C px-3 py-2 text-left">{{ item.nama_produk }}</td>
|
||||
<td class="border-x border-C px-3 py-2">{{ item.jumlah_item_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">
|
||||
<div class="flex justify-center">
|
||||
<div :ref="el => { if (el) pendapatanElements.push(el) }" :style="pendapatanStyle"
|
||||
:class="item.pendapatan == '-' ? 'text-center' : 'text-right'">
|
||||
{{ item.pendapatan }}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, watch, computed, nextTick } from 'vue';
|
||||
import InputSelect from './InputSelect.vue';
|
||||
import InputField from './InputField.vue';
|
||||
import axios from 'axios';
|
||||
|
||||
// --- State ---
|
||||
const isExportOpen = ref(false);
|
||||
const exportDropdownRef = ref(null);
|
||||
|
||||
const exportOptions = ref([
|
||||
{ value: 'pdf', label: 'Pdf' },
|
||||
{ value: 'xlsx', label: 'Excel' },
|
||||
{ value: 'csv', label: 'Csv' }
|
||||
]);
|
||||
|
||||
const exportFormat = ref(null);
|
||||
const tanggalDipilih = ref('');
|
||||
const data = ref(null);
|
||||
const loading = ref(false);
|
||||
const loadingExport = ref(false);
|
||||
|
||||
// Sorting state
|
||||
const sortBy = ref(null);
|
||||
const sortOrder = ref('asc'); // 'asc' or 'desc'
|
||||
|
||||
const pagination = ref({
|
||||
current_page: 1,
|
||||
last_page: 1,
|
||||
total: 0,
|
||||
});
|
||||
|
||||
const pendapatanWidth = ref(0);
|
||||
const pendapatanElements = ref([]);
|
||||
|
||||
const salesDipilih = ref(null);
|
||||
const opsiSales = ref([
|
||||
{ label: 'Semua Sales', value: null, selected: true },
|
||||
]);
|
||||
|
||||
const nampanDipilih = ref(null);
|
||||
const opsiNampan = ref([
|
||||
{ label: 'Semua Nampan', value: null, selected: true },
|
||||
]);
|
||||
|
||||
const namaPembeli = ref(null);
|
||||
|
||||
// --- Computed ---
|
||||
const produk = computed(() => data.value?.produk || []);
|
||||
|
||||
const sortedProduk = computed(() => {
|
||||
if (!sortBy.value || !produk.value.length) {
|
||||
return produk.value;
|
||||
}
|
||||
|
||||
const sorted = [...produk.value].sort((a, b) => {
|
||||
let aValue = a[sortBy.value];
|
||||
let bValue = b[sortBy.value];
|
||||
|
||||
// Handle different data types
|
||||
if (sortBy.value === 'nama_produk') {
|
||||
// String comparison
|
||||
aValue = aValue?.toString().toLowerCase() || '';
|
||||
bValue = bValue?.toString().toLowerCase() || '';
|
||||
} else if (sortBy.value === 'jumlah_item_terjual') {
|
||||
// Numeric comparison
|
||||
aValue = parseInt(aValue) || 0;
|
||||
bValue = parseInt(bValue) || 0;
|
||||
} else if (sortBy.value === 'berat_terjual') {
|
||||
// Handle weight values (remove unit if exists)
|
||||
aValue = parseFloat(aValue?.toString().replace(/[^\d.-]/g, '')) || 0;
|
||||
bValue = parseFloat(bValue?.toString().replace(/[^\d.-]/g, '')) || 0;
|
||||
} else if (sortBy.value === 'pendapatan') {
|
||||
// Handle currency values (remove currency symbols and commas)
|
||||
if (aValue === '-') aValue = 0;
|
||||
if (bValue === '-') bValue = 0;
|
||||
aValue = parseFloat(aValue?.toString().replace(/[^\d.-]/g, '')) || 0;
|
||||
bValue = parseFloat(bValue?.toString().replace(/[^\d.-]/g, '')) || 0;
|
||||
}
|
||||
|
||||
if (sortOrder.value === 'asc') {
|
||||
if (typeof aValue === 'string') {
|
||||
return aValue.localeCompare(bValue);
|
||||
}
|
||||
return aValue - bValue;
|
||||
} else {
|
||||
if (typeof aValue === 'string') {
|
||||
return bValue.localeCompare(aValue);
|
||||
}
|
||||
return bValue - aValue;
|
||||
}
|
||||
});
|
||||
|
||||
return sorted;
|
||||
});
|
||||
|
||||
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(produk, async (newValue) => {
|
||||
if (newValue && newValue.length > 0) {
|
||||
await nextTick();
|
||||
pendapatanElements.value = [];
|
||||
let maxWidth = 0;
|
||||
|
||||
await nextTick();
|
||||
pendapatanElements.value.forEach(el => {
|
||||
if (el && el.scrollWidth > maxWidth) {
|
||||
maxWidth = el.scrollWidth;
|
||||
}
|
||||
});
|
||||
pendapatanWidth.value = maxWidth;
|
||||
}
|
||||
}, { deep: true });
|
||||
|
||||
// --- Methods ---
|
||||
const handleSort = (column) => {
|
||||
if (sortBy.value === column) {
|
||||
sortOrder.value = sortOrder.value === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
sortBy.value = column;
|
||||
sortOrder.value = 'asc';
|
||||
}
|
||||
};
|
||||
|
||||
const getSortIcon = (column) => {
|
||||
if (sortBy.value !== column) {
|
||||
return 'fas fa-sort text-D/40'; // Default sort icon
|
||||
}
|
||||
|
||||
if (sortOrder.value === 'asc') {
|
||||
return 'fas fa-sort-up text-D'; // Ascending
|
||||
} else {
|
||||
return 'fas fa-sort-down text-D'; // Descending
|
||||
}
|
||||
};
|
||||
|
||||
const fetchSales = async () => {
|
||||
try {
|
||||
const response = await axios.get('/api/sales', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||
},
|
||||
});
|
||||
const salesData = response.data;
|
||||
opsiSales.value = [
|
||||
{ label: 'Semua Sales', value: null },
|
||||
...salesData.map(sales => ({
|
||||
label: sales.nama,
|
||||
value: sales.id,
|
||||
}))
|
||||
];
|
||||
} catch (error) {
|
||||
console.error('Gagal mengambil data sales:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchNampan = async () => {
|
||||
try {
|
||||
const response = await axios.get('/api/nampan', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||
},
|
||||
});
|
||||
const nampanData = response.data;
|
||||
opsiNampan.value = [
|
||||
{ label: 'Semua Nampan', value: null },
|
||||
{ label: 'Brankas', value: 0 },
|
||||
...nampanData.map(nampan => ({
|
||||
label: nampan.nama,
|
||||
value: nampan.id,
|
||||
}))
|
||||
];
|
||||
} catch (error) {
|
||||
console.error('Gagal mengambil data nampan:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchData = async (page = 1) => {
|
||||
if (!tanggalDipilih.value) return;
|
||||
|
||||
loading.value = true;
|
||||
pendapatanElements.value = [];
|
||||
|
||||
let queryParams = `tanggal=${tanggalDipilih.value}&page=${page}`;
|
||||
if (salesDipilih.value != null) queryParams += `&sales_id=${salesDipilih.value}`;
|
||||
if (nampanDipilih.value != null) queryParams += `&nampan_id=${nampanDipilih.value}`;
|
||||
if (namaPembeli.value != null || namaPembeli.value != '') queryParams += `&nama_pembeli=${encodeURIComponent(namaPembeli.value)}`;
|
||||
|
||||
try {
|
||||
const response = await axios.get(`/api/laporan/detail-per-produk?${queryParams}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||
}
|
||||
});
|
||||
|
||||
data.value = response.data;
|
||||
|
||||
if (response.data.pagination) {
|
||||
pagination.value = {
|
||||
current_page: response.data.pagination.current_page,
|
||||
last_page: response.data.pagination.last_page,
|
||||
total: response.data.pagination.total,
|
||||
};
|
||||
} else {
|
||||
pagination.value = {
|
||||
current_page: 1,
|
||||
last_page: 1,
|
||||
total: response.data.produk ? response.data.produk.length : 0,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Gagal mengambil data laporan produk:', error);
|
||||
data.value = null;
|
||||
pagination.value = {
|
||||
current_page: 1,
|
||||
last_page: 1,
|
||||
total: 0,
|
||||
};
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const goToPage = (page) => {
|
||||
if (page >= 1 && page <= pagination.value.last_page) {
|
||||
pagination.value.current_page = page;
|
||||
fetchData(page);
|
||||
}
|
||||
};
|
||||
|
||||
const selectExport = async (option) => {
|
||||
exportFormat.value = option.value;
|
||||
isExportOpen.value = false;
|
||||
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) => {
|
||||
if (exportDropdownRef.value && !exportDropdownRef.value.contains(event.target)) {
|
||||
isExportOpen.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// --- Lifecycle Hooks ---
|
||||
onMounted(() => {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
tanggalDipilih.value = today;
|
||||
|
||||
fetchSales();
|
||||
fetchNampan(); // Changed from fetchProduk to fetchNampan
|
||||
|
||||
document.addEventListener('click', closeDropdownsOnClickOutside);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', closeDropdownsOnClickOutside);
|
||||
});
|
||||
|
||||
// Watch for filter changes
|
||||
watch([tanggalDipilih, salesDipilih, nampanDipilih, namaPembeli], () => { // Changed from produkDipilih to nampanDipilih
|
||||
pagination.value.current_page = 1;
|
||||
fetchData(1);
|
||||
}, { immediate: true });
|
||||
</script>
|
118
resources/js/components/EditAkun.vue
Normal file
118
resources/js/components/EditAkun.vue
Normal file
@ -0,0 +1,118 @@
|
||||
<template>
|
||||
<div class="fixed inset-0 flex items-center justify-center bg-black/65">
|
||||
<div class="bg-white rounded-lg p-6 w-96">
|
||||
<h2 class="text-lg font-bold mb-4">Edit Akun</h2>
|
||||
|
||||
<form @submit.prevent="updateAkun" class="space-y-3">
|
||||
<label for="nama">Nama</label>
|
||||
<InputField
|
||||
v-model="form.nama"
|
||||
label="nama"
|
||||
type="text"
|
||||
:required="true"
|
||||
class="mb-3"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label for="password">Password</label>
|
||||
<InputField
|
||||
v-model="form.password"
|
||||
label="password"
|
||||
type="password"
|
||||
:required="false"
|
||||
class="mb-1"
|
||||
/>
|
||||
<p class="text-sm">Kosongkan jika tidak ingin ubah password</p>
|
||||
</div>
|
||||
|
||||
<label for="peran">Peran</label>
|
||||
<InputSelect
|
||||
v-model="form.role"
|
||||
label="peran"
|
||||
:options="[
|
||||
{ value: 'owner', label: 'Owner' },
|
||||
{ value: 'kasir', label: 'Kasir' }
|
||||
]"
|
||||
:required="true"
|
||||
class="mb-3"
|
||||
/>
|
||||
|
||||
<div class="flex justify-end gap-2 mt-4">
|
||||
<button
|
||||
type="button"
|
||||
@click="$emit('close')"
|
||||
class="bg-gray-300 px-4 py-2 rounded"
|
||||
>
|
||||
Batal
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="bg-blue-500 text-white px-4 py-2 rounded"
|
||||
>
|
||||
Ubah
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from "axios";
|
||||
import InputField from "@/components/InputField.vue";
|
||||
import InputSelect from "@/components/InputSelect.vue";
|
||||
|
||||
export default {
|
||||
props: {
|
||||
akun: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
components: { InputField, InputSelect },
|
||||
|
||||
data() {
|
||||
return {
|
||||
form: {
|
||||
nama: this.akun.nama || "",
|
||||
password: "",
|
||||
role: this.akun.role || "",
|
||||
},
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
akun: {
|
||||
handler(newVal) {
|
||||
if (newVal) {
|
||||
this.form = {
|
||||
nama: newVal.nama || "",
|
||||
password: "",
|
||||
role: newVal.role || "",
|
||||
};
|
||||
}
|
||||
},
|
||||
deep: true,
|
||||
immediate: true,
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
async updateAkun() {
|
||||
try {
|
||||
const payload = { ...this.form };
|
||||
if (!payload.password) delete payload.password;
|
||||
|
||||
await axios.put(`/api/user/${this.akun.id}`, payload, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||
},
|
||||
});;
|
||||
|
||||
this.$emit("refresh");
|
||||
this.$emit("close");
|
||||
} catch (err) {
|
||||
console.error("Gagal update akun:", err.response?.data || err.message);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
66
resources/js/components/EditKategori.vue
Normal file
66
resources/js/components/EditKategori.vue
Normal file
@ -0,0 +1,66 @@
|
||||
<template>
|
||||
<div class="fixed inset-0 flex items-center justify-center bg-black/65 z-50">
|
||||
<div class="bg-white rounded-lg shadow-lg w-[400px] p-6 relative">
|
||||
|
||||
<!-- Tombol close -->
|
||||
<button @click="$emit('close')" class="absolute top-3 right-3 text-gray-600 hover:text-black">
|
||||
✕
|
||||
</button>
|
||||
|
||||
<!-- Judul -->
|
||||
<h2 class="text-xl font-bold text-center text-D mb-4">Edit Kategori</h2>
|
||||
|
||||
<!-- Input Nama Kategori -->
|
||||
<div>
|
||||
<label for="editKategori" class="block text-sm font-medium text-D mb-1">Nama Kategori</label>
|
||||
<InputField
|
||||
v-model="editNamaKategori"
|
||||
type="text"
|
||||
id="editKategori"
|
||||
placeholder="Masukkan nama kategori"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Tombol Aksi -->
|
||||
<div class="flex justify-end gap-3">
|
||||
<button @click="$emit('close')" class="px-4 py-2 bg-gray-300 rounded-md hover:bg-gray-400">
|
||||
Batal
|
||||
</button>
|
||||
<button @click="updateKategori" class="px-4 py-2 bg-B text-D rounded-md hover:bg-A">
|
||||
Ubah
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from "vue";
|
||||
import InputField from "./InputField.vue";
|
||||
|
||||
const props = defineProps({
|
||||
kategori: { type: Object, required: true },
|
||||
});
|
||||
|
||||
const editNamaKategori = ref("");
|
||||
|
||||
watch(
|
||||
() => props.kategori,
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
editNamaKategori.value = newVal.nama;
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const updateKategori = () => {
|
||||
if (editNamaKategori.value.trim() === "") {
|
||||
alert("Nama kategori tidak boleh kosong!");
|
||||
return;
|
||||
}
|
||||
|
||||
// Emit hasil update ke parent
|
||||
emit("update", { ...props.kategori, nama: editNamaKategori.value });
|
||||
};
|
||||
</script>
|
78
resources/js/components/EditSales.vue
Normal file
78
resources/js/components/EditSales.vue
Normal file
@ -0,0 +1,78 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="fixed inset-0 bg-black/65 flex items-center justify-center z-50"
|
||||
>
|
||||
<div class="bg-white rounded-lg shadow-lg w-full max-w-lg p-6 relative">
|
||||
<h2 class="text-xl font-bold mb-4">Ubah Sales</h2>
|
||||
|
||||
<form @submit.prevent="handleSubmit" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Nama Sales</label>
|
||||
<InputField v-model="form.nama" type="text" class="w-full px-3 py-2 border rounded-md focus:ring-2 focus:ring-[#c6a77d] focus:outline-none" required />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">No HP</label>
|
||||
<InputField v-model="form.no_hp" type="text" class="w-full px-3 py-2 border rounded-md focus:ring-2 focus:ring-[#c6a77d] focus:outline-none" required />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Alamat</label>
|
||||
<textarea
|
||||
v-model="form.alamat"
|
||||
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:ring-D focus:ring-opacity-50 p-2"
|
||||
required
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2 mt-6">
|
||||
<button type="button" @click="$emit('close')" class="px-4 py-2 bg-gray-300 rounded hover:bg-gray-400">Batal</button>
|
||||
<button type="submit" class="px-4 py-2 bg-C text-D rounded hover:bg-C">Ubah</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from "vue";
|
||||
import axios from "axios";
|
||||
import InputField from "./InputField.vue";
|
||||
|
||||
const props = defineProps({
|
||||
isOpen: Boolean,
|
||||
sales: Object,
|
||||
});
|
||||
|
||||
const emit = defineEmits(["close"]);
|
||||
|
||||
const form = ref({
|
||||
nama: "",
|
||||
no_hp: "",
|
||||
alamat: "",
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.sales,
|
||||
(val) => {
|
||||
if (val) {
|
||||
form.value = { ...val };
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
await axios.put(`/api/sales/${props.sales.id}`, form.value, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||
},
|
||||
});;
|
||||
emit("close");
|
||||
} catch (error) {
|
||||
console.error("Error updating sales:", error);
|
||||
}
|
||||
};
|
||||
</script>
|
26
resources/js/components/Footer.vue
Normal file
26
resources/js/components/Footer.vue
Normal file
@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<footer class="bg-B py-4 px-6 flex flex-col md:flex-row items-center justify-between">
|
||||
<!-- Left: Logo -->
|
||||
<div class="flex items-center gap-2">
|
||||
<img :src="logo" alt="Logo" class="h-10">
|
||||
</div>
|
||||
|
||||
<!-- Center: Copyright -->
|
||||
<div class="text-sm text-D font-medium text-center">
|
||||
Abbauf Tech © 2025 Semua hak dilindungi
|
||||
</div>
|
||||
|
||||
<!-- Right: Social Icons -->
|
||||
<div class="flex items-center gap-4 text-D mt-2 md:mt-0">
|
||||
<a href="#" class="hover:text-sky-600"><i class="fab fa-facebook"></i></a>
|
||||
<a href="#" class="hover:text-sky-600"><i class="fab fa-twitter"></i></a>
|
||||
<a href="#" class="hover:text-sky-600"><i class="fab fa-instagram"></i></a>
|
||||
<a href="#" class="hover:text-sky-600"><i class="fab fa-youtube"></i></a>
|
||||
<a href="#" class="hover:text-sky-600"><i class="fab fa-vk"></i></a>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import logo from '@/../images/logo.png'
|
||||
</script>
|
@ -1,17 +0,0 @@
|
||||
<script setup>
|
||||
const items = ['Manajemen Produk', 'Kasir', 'Laporan', 'Akun'];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-25 shadow-lg shadow-D rounded-b-md">
|
||||
<div class="bg-D h-5 rounded-b-md shadow-lg">
|
||||
<div class="h-15"></div>
|
||||
<div class="w-full px-50 flex justify-between items-center h-5">
|
||||
<router-link to="/" v-for="item in items"
|
||||
class="text-center text-lg text-D hover:underline cursor-pointer">
|
||||
{{ item }}
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
28
resources/js/components/InputField.vue
Normal file
28
resources/js/components/InputField.vue
Normal file
@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<input
|
||||
:type="type"
|
||||
:value="modelValue"
|
||||
@input="$emit('update:modelValue', $event.target.value)"
|
||||
:placeholder="placeholder"
|
||||
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"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
type: {
|
||||
type: String,
|
||||
default: 'text',
|
||||
},
|
||||
modelValue: {
|
||||
type: [String, Number],
|
||||
default: '',
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
</script>
|
46
resources/js/components/InputPassword.vue
Normal file
46
resources/js/components/InputPassword.vue
Normal file
@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<div class="relative mb-8">
|
||||
<input
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
:value="modelValue"
|
||||
:placeholder="placeholder"
|
||||
@input="$emit('update:modelValue', $event.target.value)"
|
||||
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:ring-D focus:ring-opacity-50 p-2 pr-10"
|
||||
/>
|
||||
|
||||
<!-- Tombol show/hide password -->
|
||||
<button
|
||||
type="button"
|
||||
@click="togglePassword"
|
||||
class="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-500 hover:text-gray-700 focus:outline-none"
|
||||
>
|
||||
<i v-if="showPassword" class="fas fa-eye"></i>
|
||||
<i v-else class="fas fa-eye-slash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: "Password",
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:modelValue"]);
|
||||
|
||||
const showPassword = ref(false);
|
||||
|
||||
const togglePassword = () => {
|
||||
showPassword.value = !showPassword.value;
|
||||
};
|
||||
</script>
|
31
resources/js/components/InputSelect.vue
Normal file
31
resources/js/components/InputSelect.vue
Normal file
@ -0,0 +1,31 @@
|
||||
<template>
|
||||
<select
|
||||
:value="modelValue"
|
||||
@change="$emit('update:modelValue', $event.target.value)"
|
||||
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:ring-D focus:ring-opacity-50 p-2"
|
||||
>
|
||||
<option value="" :disabled="!modelValue && placeholder" v-if="placeholder" class="hover:bg-C text-D">{{ placeholder }}</option>
|
||||
<option v-for="option in options" :key="option.value" :selected="option.selected" :value="option.value" class="hover:bg-C text-D">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: [String, Number],
|
||||
default: '',
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
options: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
</script>
|
284
resources/js/components/KasirForm.vue
Normal file
284
resources/js/components/KasirForm.vue
Normal file
@ -0,0 +1,284 @@
|
||||
<template>
|
||||
<ConfirmDeleteModal
|
||||
v-if="showDeleteModal"
|
||||
:isOpen="showDeleteModal"
|
||||
title="Konfirmasi"
|
||||
message="Yakin ingin menghapus item ini?"
|
||||
@confirm="hapusPesanan"
|
||||
@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>
|
||||
<label class="block text-sm font-medium text-D">Kode Item *</label>
|
||||
<div
|
||||
class="flex flex-row justify-between mt-1 w-full rounded-md bg-A shadow-sm sm:text-sm border-B"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
v-model="kodeItem"
|
||||
@keyup.enter="inputItem"
|
||||
placeholder="Scan atau masukkan kode item"
|
||||
class="bg-A focus:outline-none focus:border-C focus:ring focus:ring-D focus:ring-opacity-50 p-2 w-full rounded-l-md"
|
||||
/>
|
||||
<button
|
||||
v-if="!loadingItem"
|
||||
@click="inputItem"
|
||||
class="px-3 bg-D hover:bg-D/80 text-A rounded-r-md"
|
||||
>
|
||||
<i class="fas fa-arrow-right"></i>
|
||||
</button>
|
||||
<div v-else class="flex items-center justify-center px-3">
|
||||
<div
|
||||
class="rounded-full h-5 w-5 border-b-2 border-A flex items-center justify-center"
|
||||
>
|
||||
<i class="fas fa-spinner"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Input Harga Jual -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-D">Harga Jual</label>
|
||||
<InputField
|
||||
v-model="hargaJual"
|
||||
type="number"
|
||||
placeholder="Masukkan Harga Jual"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Tombol Aksi -->
|
||||
<div class="flex flex-col sm:flex-row justify-between gap-2">
|
||||
<button
|
||||
@click="tambahItem"
|
||||
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
|
||||
</button>
|
||||
<button
|
||||
@click="konfirmasiPenjualan"
|
||||
class="w-full sm:w-auto px-6 py-2 rounded-md bg-D text-A font-semibold hover:bg-D/80 transition"
|
||||
>
|
||||
Lanjut
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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="text-2xl sm:text-3xl font-bold text-D">
|
||||
Rp{{ total.toLocaleString() }},-
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error & Info -->
|
||||
<div class="mb-4">
|
||||
<p
|
||||
v-if="error"
|
||||
:class="{ 'animate-shake': error }"
|
||||
class="text-sm text-red-600 mt-1"
|
||||
>
|
||||
{{ error }}
|
||||
</p>
|
||||
<p v-if="info" class="text-sm text-C mt-1">{{ info }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Table Responsive -->
|
||||
<div class="overflow-x-auto">
|
||||
<table
|
||||
class="w-full border border-B rounded-lg overflow-hidden text-xs sm:text-sm"
|
||||
>
|
||||
<thead class="bg-A text-D">
|
||||
<tr>
|
||||
<th class="border border-B p-2 w-8">No</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">Harga</th>
|
||||
<th class="border border-B p-2 w-10"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="pesanan.length == 0" class="text-center text-D/70">
|
||||
<td colspan="5" class="h-16 border border-B text-xs sm:text-sm">
|
||||
Belum ada item dipesan
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
v-else
|
||||
v-for="(item, index) in pesanan"
|
||||
:key="index"
|
||||
class="hover:bg-gray-50 text-center"
|
||||
>
|
||||
<td class="border border-B p-2">{{ index + 1 }}</td>
|
||||
<td class="border border-B p-2 text-left truncate max-w-[120px] sm:max-w-none">
|
||||
{{ item.produk.nama }}
|
||||
</td>
|
||||
<td class="border border-B p-2 truncate max-w-[80px]">
|
||||
{{ item.posisi ? item.posisi : "Brankas" }}
|
||||
</td>
|
||||
<td class="border border-B p-2 whitespace-nowrap">
|
||||
Rp{{ item.harga_deal.toLocaleString() }}
|
||||
</td>
|
||||
<td class="border border-B p-2 text-center">
|
||||
<button
|
||||
@click="openDeleteModal(index)"
|
||||
class="text-red-500 hover:text-red-700"
|
||||
>
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from "vue";
|
||||
import InputField from "./InputField.vue";
|
||||
import axios from "axios";
|
||||
import ConfirmDeleteModal from "./ConfirmDeleteModal.vue";
|
||||
|
||||
const kodeItem = ref("");
|
||||
const info = ref("");
|
||||
const error = ref("");
|
||||
const hargaJual = ref(null);
|
||||
const item = ref(null);
|
||||
const loadingItem = ref(false);
|
||||
const pesanan = ref([]);
|
||||
const showDeleteModal = ref(false)
|
||||
const deleteIndex = ref(null)
|
||||
|
||||
let errorTimeout = null;
|
||||
let infoTimeout = null;
|
||||
|
||||
const inputItem = async () => {
|
||||
if (!kodeItem.value) return;
|
||||
|
||||
info.value = "";
|
||||
error.value = "";
|
||||
clearTimeout(infoTimeout);
|
||||
clearTimeout(errorTimeout);
|
||||
|
||||
loadingItem.value = true;
|
||||
|
||||
try {
|
||||
const response = await axios.get(`/api/item/${kodeItem.value}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||
},
|
||||
});
|
||||
item.value = response.data;
|
||||
hargaJual.value = item.value.produk.harga_jual;
|
||||
|
||||
if (item.value.is_sold) {
|
||||
throw new Error("Item sudah terjual");
|
||||
}
|
||||
if (pesanan.value.some((p) => p.id === item.value.id)) {
|
||||
throw new Error("Item sedang dipesan");
|
||||
}
|
||||
info.value = `Item dipilih: ${item.value.produk.nama} dari ${
|
||||
item.value.posisi ? item.value.posisi : "Brankas"
|
||||
}`;
|
||||
|
||||
infoTimeout = setTimeout(() => {
|
||||
info.value = "";
|
||||
}, 3000);
|
||||
} catch (err) {
|
||||
if (err == "") {
|
||||
error.value = "Error: Item tidak ditemukan";
|
||||
} else {
|
||||
error.value = err;
|
||||
}
|
||||
info.value = "";
|
||||
hargaJual.value = null;
|
||||
item.value = null;
|
||||
|
||||
errorTimeout = setTimeout(() => {
|
||||
error.value = "";
|
||||
}, 3000);
|
||||
} finally {
|
||||
loadingItem.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const tambahItem = () => {
|
||||
if (!item.value || !hargaJual.value) {
|
||||
error.value = "Scan atau masukkan kode item untuk dijual.";
|
||||
if (kodeItem.value) {
|
||||
error.value =
|
||||
"Masukkan harga jual, atau input dari kode item lagi.";
|
||||
}
|
||||
clearTimeout(errorTimeout);
|
||||
errorTimeout = setTimeout(() => {
|
||||
error.value = "";
|
||||
}, 3000);
|
||||
return;
|
||||
}
|
||||
|
||||
// harga deal
|
||||
item.value.harga_deal = Number(hargaJual.value);
|
||||
|
||||
pesanan.value.push(item.value);
|
||||
|
||||
// Reset input fields
|
||||
kodeItem.value = "";
|
||||
hargaJual.value = null;
|
||||
item.value = null;
|
||||
info.value = "";
|
||||
clearTimeout(infoTimeout);
|
||||
};
|
||||
|
||||
const openDeleteModal = (index) => {
|
||||
deleteIndex.value = index
|
||||
showDeleteModal.value = true
|
||||
}
|
||||
|
||||
const closeDeleteModal = () => {
|
||||
showDeleteModal.value = false
|
||||
deleteIndex.value = null
|
||||
}
|
||||
|
||||
const hapusPesanan = () => {
|
||||
if (deleteIndex.value !== null) {
|
||||
pesanan.value.splice(deleteIndex.value, 1)
|
||||
}
|
||||
closeDeleteModal()
|
||||
}
|
||||
|
||||
|
||||
const konfirmasiPenjualan = () => {
|
||||
if (pesanan.value.length === 0) {
|
||||
error.value = "Belum ada item yang dipesan.";
|
||||
clearTimeout(errorTimeout);
|
||||
errorTimeout = setTimeout(() => {
|
||||
error.value = "";
|
||||
}, 3000);
|
||||
return;
|
||||
}
|
||||
|
||||
// Todo: Implementasi konfirmasi penjualan
|
||||
alert("Penjualan dikonfirmasi! (Implementasi lebih lanjut diperlukan)");
|
||||
};
|
||||
|
||||
const total = computed(() => {
|
||||
let sum = 0;
|
||||
pesanan.value.forEach((item) => {
|
||||
sum += item.harga_deal;
|
||||
});
|
||||
return sum;
|
||||
});
|
||||
</script>
|
40
resources/js/components/KasirTransaksiList.vue
Normal file
40
resources/js/components/KasirTransaksiList.vue
Normal file
@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<div class="overflow-x-auto">
|
||||
<h3 class="text-lg font-semibold mb-4 text-gray-800">Transaksi</h3>
|
||||
<table class="w-full min-w-[500px] border border-B rounded-lg text-sm">
|
||||
<thead class="bg-A text-D">
|
||||
<tr>
|
||||
<th class="border border-B p-2 text-left">Tanggal</th>
|
||||
<th class="border border-B p-2 text-left">Kode Transaksi</th>
|
||||
<th class="border border-B p-2 text-left">Pendapatan</th>
|
||||
<th class="border border-B p-2 text-center">Detail Item</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="trx in props.transaksi" :key="trx.id" class="hover:bg-A">
|
||||
<td class="border border-B p-2">{{ trx.tanggal }}</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 text-center">
|
||||
<button
|
||||
@click="$emit('detail', trx)"
|
||||
class="px-3 py-1 rounded-md bg-D text-A hover:bg-D/80 transition">
|
||||
Detail
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
transaksi: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
})
|
||||
|
||||
defineEmits(['detail'])
|
||||
</script>
|
96
resources/js/components/Modal.vue
Normal file
96
resources/js/components/Modal.vue
Normal file
@ -0,0 +1,96 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="modal">
|
||||
<div
|
||||
v-if="active"
|
||||
class="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"
|
||||
@click="handleOverlayClick"
|
||||
>
|
||||
<div
|
||||
class="bg-white rounded-lg shadow-2xl max-h-[90vh] overflow-y-auto relative"
|
||||
:class="sizeClass"
|
||||
@click.stop
|
||||
>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, watch, onBeforeUnmount } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
active: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: 'md',
|
||||
validator: (value) => ['xs', 'sm', 'md', 'lg', 'xl', '2xl', '3xl', '4xl', 'full'].includes(value)
|
||||
},
|
||||
clickOutside: {
|
||||
type: [Boolean, String],
|
||||
default: true
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
const sizeClass = computed(() => {
|
||||
const sizes = {
|
||||
xs: 'w-full max-w-xs',
|
||||
sm: 'w-full max-w-sm',
|
||||
md: 'w-full max-w-md',
|
||||
lg: 'w-full max-w-lg',
|
||||
xl: 'w-full max-w-xl',
|
||||
'2xl': 'w-full max-w-2xl',
|
||||
'3xl': 'w-full max-w-3xl',
|
||||
'4xl': 'w-full max-w-4xl',
|
||||
full: 'w-[95vw] h-[95vh] max-w-none max-h-none'
|
||||
}
|
||||
return sizes[props.size] || sizes.md
|
||||
})
|
||||
|
||||
const handleOverlayClick = () => {
|
||||
if (clickOutside.value) {
|
||||
emit('close')
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.active, (newVal) => {
|
||||
if (newVal) {
|
||||
document.body.style.overflow = 'hidden'
|
||||
} else {
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.body.style.overflow = ''
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal-enter-active,
|
||||
.modal-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.modal-enter-from,
|
||||
.modal-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.modal-enter-from .bg-white,
|
||||
.modal-leave-to .bg-white {
|
||||
transform: scale(0.95) translateY(-20px);
|
||||
}
|
||||
|
||||
.modal-enter-active .bg-white,
|
||||
.modal-leave-active .bg-white {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
</style>
|
70
resources/js/components/NavDesktop.vue
Normal file
70
resources/js/components/NavDesktop.vue
Normal file
@ -0,0 +1,70 @@
|
||||
<script setup>
|
||||
import { inject } from "vue";
|
||||
|
||||
const {
|
||||
logo,
|
||||
items,
|
||||
openDropdownIndex,
|
||||
toggleDropdown,
|
||||
logout
|
||||
} = inject('navigationData');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Desktop Navbar -->
|
||||
<div class="hidden md:block shadow-lg shadow-D rounded-b-md">
|
||||
<div class="bg-D h-5 rounded-b-md shadow-lg"></div>
|
||||
<div class="relative rounded-b-md shadow-lg">
|
||||
<!-- Logo Row -->
|
||||
<div class="flex justify-center items-center">
|
||||
<img :src="logo" alt="Logo" class="h-12 w-auto" />
|
||||
</div>
|
||||
|
||||
<!-- Menu Row -->
|
||||
<div class="px-8 pb-4">
|
||||
<div class="flex justify-around items-center gap-4">
|
||||
<template v-for="(item, index) in items" :key="index">
|
||||
|
||||
<div v-if="item.subItems" class="relative flex-1">
|
||||
<button @click="toggleDropdown(index)"
|
||||
class="w-full text-center text-lg text-D hover:underline cursor-pointer flex items-center justify-center gap-2 transition-colors duration-200 py-2">
|
||||
{{ item.label }}
|
||||
<svg :class="{ 'rotate-180': openDropdownIndex === index }"
|
||||
class="w-4 h-4 transition-transform duration-200" fill="none" stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div v-if="openDropdownIndex === index"
|
||||
class="absolute mt-2 w-full mx-4 bg-white border rounded-md shadow-lg z-50">
|
||||
<ul>
|
||||
<li v-for="(sub, subIndex) in item.subItems" :key="subIndex"
|
||||
class="hover:bg-A transition-colors duration-200">
|
||||
<router-link :to="sub.route"
|
||||
@click="openDropdownIndex = null"
|
||||
class="block w-full h-full px-4 py-2 text-D">
|
||||
{{ sub.label }}
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<router-link v-else :to="item.route"
|
||||
class="flex-1 text-center text-lg text-D hover:underline cursor-pointer transition-colors duration-200 py-2">
|
||||
{{ item.label }}
|
||||
</router-link>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="absolute top-4 right-8">
|
||||
<button @click="logout"
|
||||
class="text-center font-bold text-lg text-red-400 hover:underline hover:text-red-600 cursor-pointer transition-colors duration-200">
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
91
resources/js/components/NavMobile.vue
Normal file
91
resources/js/components/NavMobile.vue
Normal file
@ -0,0 +1,91 @@
|
||||
<script setup>
|
||||
import { inject } from "vue";
|
||||
|
||||
// Mengambil data dan fungsi yang disediakan dari komponen induk
|
||||
const {
|
||||
logo,
|
||||
items,
|
||||
isMobileMenuOpen,
|
||||
openDropdownIndex,
|
||||
toggleDropdown,
|
||||
toggleMobileMenu,
|
||||
closeMobileMenu,
|
||||
logout
|
||||
} = inject('navigationData');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="md:hidden">
|
||||
<div class="bg-D h-5 shadow-lg"></div>
|
||||
|
||||
<button @click="toggleMobileMenu"
|
||||
:class="{ 'hidden': isMobileMenuOpen, 'block': !isMobileMenuOpen }"
|
||||
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">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
|
||||
<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">
|
||||
<div class="px-4 py-3 flex justify-between items-center border-b border-B">
|
||||
<img :src="logo" alt="Logo" class="h-8 w-auto" />
|
||||
<button @click="closeMobileMenu" class="text-D hover:text-red-500 transition-colors duration-200">
|
||||
<svg 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>
|
||||
</div>
|
||||
|
||||
<nav class="py-4">
|
||||
<template v-for="(item, index) in items" :key="index">
|
||||
<div v-if="item.subItems" class="px-4 py-2">
|
||||
<button @click="toggleDropdown(index)"
|
||||
class="w-full flex justify-between items-center text-left text-lg text-D hover:bg-B rounded-md px-3 py-2 transition-colors duration-200">
|
||||
<span>{{ item.label }}</span>
|
||||
<svg :class="{ 'rotate-180': openDropdownIndex === index }" class="w-4 h-4 transition-transform duration-200"
|
||||
fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<transition
|
||||
enter-active-class="transition-all ease-in-out duration-300"
|
||||
enter-from-class="transform opacity-0 max-h-0"
|
||||
enter-to-class="transform opacity-100 max-h-96"
|
||||
leave-active-class="transition-all ease-in-out duration-200"
|
||||
leave-from-class="transform opacity-100 max-h-96"
|
||||
leave-to-class="transform opacity-0 max-h-0"
|
||||
>
|
||||
<div v-if="openDropdownIndex === index" class="mt-2 ml-4 space-y-1 overflow-hidden">
|
||||
<router-link v-for="(sub, subIndex) in item.subItems" :key="subIndex" :to="sub.route"
|
||||
@click="closeMobileMenu"
|
||||
class="block px-3 py-2 text-D hover:bg-B rounded-md transition-colors duration-200">
|
||||
{{ sub.label }}
|
||||
</router-link>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
|
||||
<div v-else class="px-4">
|
||||
<router-link :to="item.route" @click="closeMobileMenu"
|
||||
class="block px-3 py-2 text-lg text-D hover:bg-B rounded-md transition-colors duration-200">
|
||||
{{ item.label }}
|
||||
</router-link>
|
||||
</div>
|
||||
</template>
|
||||
</nav>
|
||||
|
||||
<div class="absolute bottom-0 w-full px-4 py-3 bg-A border-t border-B">
|
||||
<button @click="logout"
|
||||
class="block w-full text-left px-3 py-2 text-lg font-bold text-red-400 hover:text-white hover:bg-red-400 rounded-md transition-colors duration-200">
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isMobileMenuOpen" @click="closeMobileMenu" class="fixed inset-0 bg-black/75 z-40"></div>
|
||||
</div>
|
||||
</template>
|
109
resources/js/components/NavigationComponent.vue
Normal file
109
resources/js/components/NavigationComponent.vue
Normal file
@ -0,0 +1,109 @@
|
||||
<script setup>
|
||||
import { ref, provide, computed } from "vue";
|
||||
import NavDesktop from "./NavDesktop.vue";
|
||||
import NavMobile from "./NavMobile.vue";
|
||||
import logo from "../../images/logo.png";
|
||||
import axios from "axios";
|
||||
|
||||
const isOpen = ref(false);
|
||||
const isMobileMenuOpen = ref(false);
|
||||
const openDropdownIndex = ref(null);
|
||||
|
||||
const baseItems = [
|
||||
{
|
||||
label: "Manajemen Produk",
|
||||
subItems: [
|
||||
{ label: "Brankas", route: "/brankas" },
|
||||
{ label: "Nampan", route: "/nampan" },
|
||||
{ label: "Produk", route: "/produk" },
|
||||
{ label: "Kategori", route: "/kategori" },
|
||||
{ label: "Sales", route: "/sales" },
|
||||
]
|
||||
},
|
||||
{ label: "Kasir", route: "/kasir" },
|
||||
{ label: "Laporan", route: "/laporan" },
|
||||
{ label: "Akun", route: "/akun" },
|
||||
];
|
||||
|
||||
const role = localStorage.getItem("role");
|
||||
|
||||
const items = computed(() => {
|
||||
if (role === "owner") {
|
||||
return baseItems;
|
||||
}
|
||||
if (role === "kasir") {
|
||||
return baseItems.filter(item => !["Akun", "Laporan"].includes(item.label));
|
||||
}
|
||||
return baseItems;
|
||||
});
|
||||
|
||||
const toggleDropdown = (index = null) => {
|
||||
if (index !== null) {
|
||||
openDropdownIndex.value = openDropdownIndex.value === index ? null : index;
|
||||
} else {
|
||||
isOpen.value = !isOpen.value;
|
||||
}
|
||||
};
|
||||
|
||||
const toggleMobileMenu = () => {
|
||||
isMobileMenuOpen.value = !isMobileMenuOpen.value;
|
||||
isOpen.value = false;
|
||||
openDropdownIndex.value = null;
|
||||
};
|
||||
|
||||
const closeMobileMenu = () => {
|
||||
isMobileMenuOpen.value = false;
|
||||
isOpen.value = false;
|
||||
openDropdownIndex.value = null;
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
try {
|
||||
await axios.post("/api/logout", null, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||
},
|
||||
});
|
||||
localStorage.removeItem("token");
|
||||
localStorage.removeItem("role");
|
||||
|
||||
window.location.href = "/";
|
||||
} catch (error) {
|
||||
if (error.response && error.response.status === 401) {
|
||||
localStorage.removeItem("token");
|
||||
localStorage.removeItem("role");
|
||||
window.location.href = "/";
|
||||
} else {
|
||||
console.error("Logout failed:", error);
|
||||
}
|
||||
}
|
||||
closeMobileMenu();
|
||||
};
|
||||
|
||||
// Provide shared data to child components
|
||||
provide("navigationData", {
|
||||
logo,
|
||||
items,
|
||||
isOpen,
|
||||
isMobileMenuOpen,
|
||||
openDropdownIndex,
|
||||
toggleDropdown,
|
||||
toggleMobileMenu,
|
||||
closeMobileMenu,
|
||||
logout
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative">
|
||||
<!-- Desktop Navigation -->
|
||||
<NavDesktop />
|
||||
|
||||
<!-- Mobile Navigation -->
|
||||
<NavMobile />
|
||||
|
||||
<!-- Click Outside Handler for Desktop Dropdown -->
|
||||
<div v-if="openDropdownIndex !== null && !isMobileMenuOpen" @click="openDropdownIndex = null"
|
||||
class="fixed inset-0 z-10"></div>
|
||||
</div>
|
||||
</template>
|
@ -1,65 +1,31 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Card Produk -->
|
||||
<div
|
||||
class="border border-C rounded-md aspect-square flex items-center justify-center hover:shadow-md transition cursor-pointer"
|
||||
@click="showDetail = true"
|
||||
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)"
|
||||
>
|
||||
<!-- Foto Produk -->
|
||||
<img
|
||||
v-if="product.foto && product.foto.length > 0"
|
||||
:src="product.foto[0].url"
|
||||
:alt="product.nama"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
<span v-else class="text-gray-400 text-sm">[tidak ada foto]</span>
|
||||
|
||||
<!-- Nama Produk di bawah -->
|
||||
<div
|
||||
class="absolute bottom-0 w-full bg-black/60 text-white text-center text-sm py-1"
|
||||
>
|
||||
<span class="text-gray-700 font-medium text-center px-2">
|
||||
{{ product.nama }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Overlay Detail -->
|
||||
<div
|
||||
v-if="showDetail"
|
||||
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
|
||||
>
|
||||
<div
|
||||
class="bg-white rounded-lg shadow-lg w-[90%] max-w-md p-6 relative"
|
||||
>
|
||||
<!-- Tombol Close -->
|
||||
<button
|
||||
class="absolute top-3 right-3 text-gray-500 hover:text-gray-800"
|
||||
@click="showDetail = false"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
|
||||
<!-- Judul -->
|
||||
<h2 class="text-xl font-semibold text-D mb-4 text-center">
|
||||
Detail Produk
|
||||
</h2>
|
||||
|
||||
<!-- Data Produk -->
|
||||
<div class="space-y-2 text-gray-700">
|
||||
<p><span class="font-semibold">Nama:</span> {{ product.nama }}</p>
|
||||
<p><span class="font-semibold">Kategori:</span> {{ product.kategori }}</p>
|
||||
<p><span class="font-semibold">Berat:</span> {{ product.berat }} gram</p>
|
||||
<p><span class="font-semibold">Kadar:</span> {{ product.kadar }}%</p>
|
||||
<p><span class="font-semibold">Harga/gram:</span> Rp {{ formatHarga(product.harga_per_gram) }}</p>
|
||||
<p><span class="font-semibold">Harga Jual:</span> Rp {{ formatHarga(product.harga_jual) }}</p>
|
||||
<p><span class="font-semibold">Stok:</span> {{ product.items_count }} pcs</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
defineProps({
|
||||
product: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const showDetail = ref(false);
|
||||
|
||||
// Format rupiah
|
||||
function formatHarga(value) {
|
||||
return new Intl.NumberFormat("id-ID").format(value);
|
||||
}
|
||||
</script>
|
||||
|
282
resources/js/components/RingkasanLaporan.vue
Normal file
282
resources/js/components/RingkasanLaporan.vue
Normal file
@ -0,0 +1,282 @@
|
||||
<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 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">
|
||||
<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 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 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">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: 'xlsx', label: 'Excel' },
|
||||
{ value: 'csv', label: 'Csv' }
|
||||
]);
|
||||
|
||||
const filterRingkasan = ref("bulan");
|
||||
const loadingExport = ref(false);
|
||||
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/ringkasan?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) => {
|
||||
isExportOpen.value = false;
|
||||
triggerDownload(option.value);
|
||||
};
|
||||
|
||||
const triggerDownload = async (format) => {
|
||||
loadingExport.value = true;
|
||||
|
||||
try {
|
||||
const response = await axios.get('/api/laporan/export/ringkasan', {
|
||||
headers: {
|
||||
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 link = document.createElement('a');
|
||||
const fileName = `laporan_${filterRingkasan.value}_${new Date().toISOString().split('T')[0]}.${format}`;
|
||||
|
||||
link.href = url;
|
||||
link.setAttribute('download', fileName);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
|
||||
link.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
} catch (error) {
|
||||
console.error("Gagal mengunduh laporan:", error);
|
||||
alert("Terjadi kesalahan saat membuat laporan.");
|
||||
} finally {
|
||||
loadingExport.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchRingkasan(pagination.value.current_page);
|
||||
document.addEventListener('click', closeDropdownsOnClickOutside);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', closeDropdownsOnClickOutside);
|
||||
});
|
||||
</script>
|
196
resources/js/components/StrukOverlay.vue
Normal file
196
resources/js/components/StrukOverlay.vue
Normal file
@ -0,0 +1,196 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="text-D font-serif font-medium fixed inset-0 bg-black/75 flex items-center justify-center z-[9999]"
|
||||
>
|
||||
<div
|
||||
class="bg-white w-[1224px] h-[528px] rounded-md shadow-lg relative overflow-hidden"
|
||||
>
|
||||
<div class="bg-D h-8 w-full"></div>
|
||||
|
||||
<div class="p-6 text-sm flex flex-col h-full relative">
|
||||
<!-- Header -->
|
||||
<div class="relative flex items-center justify-between pb-2 mb-2">
|
||||
<!-- Sosmed -->
|
||||
<div class="flex flex-col gap-4">
|
||||
<p class="flex items-center gap-2">
|
||||
<i class="fab fa-instagram text-red-500 text-xl"></i> tokomas_Jakartacitayam
|
||||
</p>
|
||||
<p class="flex items-center gap-2">
|
||||
<i class="fab fa-tiktok text-black text-xl"></i> tokomas_Jakartacitayam
|
||||
</p>
|
||||
<p class="flex items-center gap-2">
|
||||
<i class="fab fa-whatsapp text-green-500 text-xl"></i> 08158851178
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Logo & tanggal (absolute di tengah) -->
|
||||
<div class="absolute inset-x-0 top-0 flex flex-col items-center">
|
||||
<img :src="logo" alt="Logo" class="h-15" />
|
||||
<p class="mt-1 text-center">Selasa/20-08-2025</p>
|
||||
</div>
|
||||
|
||||
<!-- Data Pembeli -->
|
||||
<div
|
||||
class="grid grid-cols-[130px_1fr] gap-y-2 items-center relative z-10"
|
||||
>
|
||||
<div class="text-right font-semibold pr-3">Nama Pembeli</div>
|
||||
<inputField
|
||||
class="h-7 px-2 text-sm rounded bg-blue-200 w-full"
|
||||
/>
|
||||
<div class="text-right font-semibold pr-3">Nomor Telepon</div>
|
||||
<inputField
|
||||
class="h-7 px-2 text-sm rounded bg-blue-200 w-full"
|
||||
/>
|
||||
<div class="text-right font-semibold pr-3">Alamat</div>
|
||||
<inputField
|
||||
class="h-7 px-2 text-sm rounded bg-blue-200 w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Nomor Transaksi -->
|
||||
<p class="mt-1 text-sm">TRS-XXX-XXX</p>
|
||||
|
||||
<table class="w-full border-D mt-0 text-sm table-fixed">
|
||||
<thead>
|
||||
<tr class="border-b border-D">
|
||||
<th class="w-[260px] py-2 border-r border-D">Item</th>
|
||||
<th class="w-[70px] border-r border-D">Posisi</th>
|
||||
<th class="w-[60px] border-r border-D">Berat</th>
|
||||
<th class="w-[60px] border-r border-D">Kadar</th>
|
||||
<th class="w-[140px] border-r border-D">Harga Satuan</th>
|
||||
<th class="w-[60px] border-r border-D">Jumlah</th>
|
||||
<th class="w-[140px]">Total Harga</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<!-- Barang 1 -->
|
||||
<tr class="text-center">
|
||||
<td class="flex items-center gap-2 p-2 border-r border-D">
|
||||
<img src="" class="w-12 h-12 object-cover" />
|
||||
Ring XXX
|
||||
</td>
|
||||
<td class="border-r border-D">A1, Brankas</td>
|
||||
<td class="border-r border-D">2,4 g</td>
|
||||
<td class="border-r border-D">23 K</td>
|
||||
<td class="border-r border-D">Rp9.000.000</td>
|
||||
<td class="border-r border-D">2</td>
|
||||
<td>Rp18.000.000</td>
|
||||
</tr>
|
||||
|
||||
<!-- Barang 2 -->
|
||||
<tr class="text-center border-b">
|
||||
<td class="flex items-center gap-2 p-2 border-r border-D">
|
||||
<img src="" class="w-12 h-12 object-cover" />
|
||||
Necklace XXX
|
||||
</td>
|
||||
<td class="border-r border-D">A2</td>
|
||||
<td class="border-r border-D">2,4 g</td>
|
||||
<td class="border-r border-D">23 K</td>
|
||||
<td class="border-r border-D">Rp3.000.000</td>
|
||||
<td class="border-r border-D">1</td>
|
||||
<td>Rp3.000.000</td>
|
||||
</tr>
|
||||
|
||||
<!-- Baris Ongkos + Total -->
|
||||
<tr class="align-top">
|
||||
<td colspan="2" rowspan="2" class="p-2 text-left align-top">
|
||||
<p class="font-semibold">PERHATIAN</p>
|
||||
<ol class="list-decimal ml-4 text-xs space-y-1">
|
||||
<li>Berat barang telah ditimbang dan disaksikan oleh pembeli.</li>
|
||||
<li>Barang yang dikembalikan menurut harga pasaran dan <br> dipotong ongkos bikin, barang rusak lain harga.</li>
|
||||
<li>Barang yang sudah dibeli berarti sudah diperiksa dan disetujui.</li>
|
||||
<li>Surat ini harap dibawa pada saat menjual kembali.</li>
|
||||
</ol>
|
||||
</td>
|
||||
|
||||
<td colspan="2" rowspan="2" class="p-2 text-center align-top">
|
||||
<div class="flex flex-col items-center justify-center h-full">
|
||||
<p><strong>Sales</strong></p>
|
||||
<inputSelect
|
||||
v-model="sales"
|
||||
:options="[
|
||||
{ value: 'Timothy', label: 'Timothy' },
|
||||
{ value: 'Iwan', label: 'Iwan' }
|
||||
]"
|
||||
class="mt-16 text-sm rounded bg-B cursor-pointer !w-[160px] text-center [option]:text-left"
|
||||
/>
|
||||
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td colspan="2" class="p-2 text-right text-sm font-semibold align-top border-r">
|
||||
<div class="space-y-2">
|
||||
<p>Ongkos bikin</p>
|
||||
<p class="text-red-500 text-xs">diluar harga jual</p>
|
||||
<p>Total</p>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td class="p-2 text-sm align-top">
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center">
|
||||
<p>Rp</p>
|
||||
<inputField
|
||||
class="h-7 px-2 text-sm rounded bg-blue-200 text-left w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<p>Rp</p>
|
||||
<p class="px-3 py-1 text-left text-sm w-full">21.200.000</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Baris Tombol -->
|
||||
<tr>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<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">
|
||||
Batal
|
||||
</button>
|
||||
<button class="bg-C text-white px-6 py-2 rounded w-full">
|
||||
Simpan
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Pesan bawah -->
|
||||
<p
|
||||
class="absolute bottom-0 left-0 text-xs bg-D text-white px-2 py-1 rounded-tr-md"
|
||||
>
|
||||
Terima kasih sudah berbelanja dengan kami
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import logo from '@/../images/logo.png'
|
||||
import inputField from '@/components/inputField.vue'
|
||||
import inputSelect from '@/components/inputSelect.vue'
|
||||
|
||||
defineProps({
|
||||
isOpen: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const sales = ref('Timothy')
|
||||
</script>
|
@ -1,118 +1,226 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Loading -->
|
||||
<div v-if="loading" class="text-center py-6">Loading...</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div v-else-if="error" class="text-center text-red-500 py-6">{{ error }}</div>
|
||||
|
||||
<!-- Kalau hasil search kosong -->
|
||||
<div
|
||||
v-else-if="filteredTrays.length === 0"
|
||||
class="text-center text-gray-500 py-6"
|
||||
>
|
||||
<div v-else-if="filteredTrays.length === 0" class="text-center text-gray-500 py-[120px]">
|
||||
Nampan tidak ditemukan.
|
||||
</div>
|
||||
|
||||
<!-- Grid nampan -->
|
||||
<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
|
||||
v-for="tray in filteredTrays"
|
||||
:key="tray.id"
|
||||
class="border rounded-lg p-4 shadow-sm hover:shadow-md transition"
|
||||
class="border border-C rounded-xl p-4 shadow-sm hover:shadow-md transition"
|
||||
>
|
||||
<!-- Header Nampan -->
|
||||
<div class="flex justify-between items-center mb-3">
|
||||
<h2 class="font-bold text-lg">{{ tray.nama }}</h2>
|
||||
<h2 class="font-bold text-lg text-[#102C57]">{{ tray.nama }}</h2>
|
||||
<div class="flex gap-2">
|
||||
<button class="bg-yellow-300 p-1 rounded">✏️</button>
|
||||
<button class="bg-red-500 text-white p-1 rounded">🗑️</button>
|
||||
<button class="p-2 rounded bg-yellow-400 hover:bg-yellow-500" @click="emit('edit', tray)">✏️</button>
|
||||
<button class="bg-red-500 text-white p-1 rounded" @click="emit('delete', tray.id)">🗑️</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Isi Nampan -->
|
||||
<div v-if="tray.items && tray.items.length > 0" class="space-y-2">
|
||||
<div v-if="tray.items && tray.items.length" class="space-y-2 max-h-64 overflow-y-auto pr-2">
|
||||
<div
|
||||
v-for="item in tray.items"
|
||||
:key="item.id"
|
||||
class="flex justify-between items-center border rounded-lg p-2"
|
||||
class="flex justify-between items-center border border-C rounded-lg p-2 cursor-pointer hover:bg-gray-50"
|
||||
@click="openMovePopup(item)"
|
||||
>
|
||||
<!-- Gambar + Info -->
|
||||
<div class="flex items-center gap-3">
|
||||
<img
|
||||
:src="item.image"
|
||||
alt="Product Image"
|
||||
class="w-12 h-12 object-contain"
|
||||
v-if="item.produk.foto && item.produk.foto.length > 0"
|
||||
:src="item.produk.foto[0].url"
|
||||
alt="foto produk"
|
||||
class="size-12 object-cover rounded"
|
||||
/>
|
||||
<div>
|
||||
<p class="font-semibold">{{ item.produk.nama }}</p>
|
||||
<p class="text-sm text-gray-500">{{ item.produk.id }}</p>
|
||||
<div class="text-[#102C57]">
|
||||
<p class="text-sm">{{ item.produk.nama }}</p>
|
||||
<p class="text-sm">{{ item.produk.kategori }}</p>
|
||||
<p class="text-sm">{{ item.produk.harga_jual.toLocaleString() }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Berat -->
|
||||
<span class="font-medium">{{ item.berat }}g</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium">{{ item.produk.berat }}g</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Kalau nampan kosong -->
|
||||
<div v-else class="text-gray-400 text-center py-4">
|
||||
Nampan kosong.<br />
|
||||
Masuk ke menu <b>Brankas</b> untuk memindahkan item ke nampan.
|
||||
</div>
|
||||
|
||||
<!-- Total Berat -->
|
||||
<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
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Pop-up pindah item -->
|
||||
<div
|
||||
v-if="isPopupVisible"
|
||||
class="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50"
|
||||
>
|
||||
<div class="bg-white rounded-xl shadow-lg max-w-sm w-full p-6 relative">
|
||||
<div class="flex justify-center mb-4">
|
||||
<div class="p-2 border rounded-lg">
|
||||
<img :src="qrCodeUrl" alt="QR Code" class="size-36" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center text-gray-700 font-medium mb-1">{{ selectedItem.produk.nama }}</div>
|
||||
<div class="text-center text-gray-500 text-sm mb-4">{{ selectedItem.produk.kategori }}</div>
|
||||
|
||||
<div class="flex justify-center mb-4">
|
||||
<button class="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600 transition">
|
||||
Cetak
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Dropdown: langsung pilih Nampan saat ini -->
|
||||
<div class="mb-4">
|
||||
<label for="tray-select" class="block text-sm font-medium mb-1">Nama Nampan</label>
|
||||
<select
|
||||
id="tray-select"
|
||||
v-model="selectedTrayId"
|
||||
class="w-full rounded-md border shadow-sm focus:outline-none focus:ring focus:ring-indigo-200"
|
||||
>
|
||||
<option
|
||||
v-for="tray in trays"
|
||||
:key="tray.id"
|
||||
:value="tray.id"
|
||||
>
|
||||
{{ tray.nama }}<span v-if="Number(tray.id) === Number(selectedItem?.id_nampan)"></span>
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
@click="closePopup"
|
||||
class="px-4 py-2 rounded border text-gray-700 hover:bg-gray-100 transition"
|
||||
>
|
||||
Batal
|
||||
</button>
|
||||
<button
|
||||
@click="saveMove"
|
||||
:disabled="!selectedTrayId"
|
||||
class="px-4 py-2 rounded text-white transition"
|
||||
:class="selectedTrayId ? 'bg-orange-500 hover:bg-orange-600' : 'bg-gray-400 cursor-not-allowed'"
|
||||
>
|
||||
Simpan
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from "vue";
|
||||
import axios from "axios";
|
||||
|
||||
// terima search dari parent
|
||||
const props = defineProps({
|
||||
search: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
search: { type: String, default: "" },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["edit", "delete"]);
|
||||
const trays = ref([]);
|
||||
const loading = ref(true);
|
||||
const error = ref(null);
|
||||
|
||||
// hitung total berat
|
||||
const totalWeight = (tray) => {
|
||||
if (!tray.items) return 0;
|
||||
return tray.items.reduce((sum, item) => sum + (item.berat || 0), 0);
|
||||
// --- State untuk Pop-up ---
|
||||
const isPopupVisible = ref(false);
|
||||
const selectedItem = ref(null);
|
||||
const selectedTrayId = ref("");
|
||||
|
||||
// QR Code generator
|
||||
const qrCodeUrl = computed(() => {
|
||||
if (selectedItem.value) {
|
||||
const data = `ITM-${selectedItem.value.id}-${selectedItem.value.produk.nama.replace(/\s/g, "")}`;
|
||||
return `https://api.qrserver.com/v1/create-qr-code/?size=150x150&data=${encodeURIComponent(data)}`;
|
||||
}
|
||||
return "";
|
||||
});
|
||||
|
||||
// --- Fungsi Pop-up ---
|
||||
const openMovePopup = (item) => {
|
||||
selectedItem.value = item;
|
||||
selectedTrayId.value = item.id_nampan; // ✅ tampilkan nampan saat ini (mis. A4)
|
||||
isPopupVisible.value = true;
|
||||
};
|
||||
|
||||
// ambil data dari backend
|
||||
onMounted(async () => {
|
||||
const closePopup = () => {
|
||||
isPopupVisible.value = false;
|
||||
selectedItem.value = null;
|
||||
selectedTrayId.value = "";
|
||||
};
|
||||
|
||||
const saveMove = async () => {
|
||||
if (!selectedTrayId.value || !selectedItem.value) return;
|
||||
try {
|
||||
const res = await axios.get("/api/nampan");
|
||||
trays.value = res.data; // harus array tray dengan items
|
||||
console.log("Data nampan:", res.data);
|
||||
await axios.put(
|
||||
`/api/item/${selectedItem.value.id}`,
|
||||
{
|
||||
id_nampan: selectedTrayId.value,
|
||||
id_produk: selectedItem.value.id_produk,
|
||||
},
|
||||
{
|
||||
headers: { Authorization: `Bearer ${localStorage.getItem("token")}` },
|
||||
}
|
||||
);
|
||||
|
||||
await refreshData();
|
||||
closePopup();
|
||||
} catch (err) {
|
||||
console.error("Gagal memindahkan item:", err.response?.data || err);
|
||||
alert("Gagal memindahkan item. Silakan coba lagi.");
|
||||
}
|
||||
};
|
||||
|
||||
// --- Ambil data nampan + item ---
|
||||
const refreshData = async () => {
|
||||
try {
|
||||
const [nampanRes, itemRes] = await Promise.all([
|
||||
axios.get("/api/nampan", {
|
||||
headers: { Authorization: `Bearer ${localStorage.getItem("token")}` },
|
||||
}),
|
||||
axios.get("/api/item", {
|
||||
headers: { Authorization: `Bearer ${localStorage.getItem("token")}` },
|
||||
}),
|
||||
]);
|
||||
const nampans = nampanRes.data;
|
||||
const items = itemRes.data;
|
||||
|
||||
trays.value = nampans.map((tray) => ({
|
||||
...tray,
|
||||
items: items.filter((item) => Number(item.id_nampan) === Number(tray.id)),
|
||||
}));
|
||||
} catch (err) {
|
||||
error.value = err.message || "Gagal mengambil data";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// filter berdasarkan nama nampan
|
||||
// Hitung total berat
|
||||
const totalWeight = (tray) => {
|
||||
if (!tray.items) return 0;
|
||||
const total = tray.items.reduce((sum, item) => sum + (item.produk.berat || 0), 0);
|
||||
return total.toFixed(2);
|
||||
};
|
||||
|
||||
// Filter nampan berdasarkan pencarian
|
||||
const filteredTrays = computed(() => {
|
||||
if (!props.search) return trays.value;
|
||||
return trays.value.filter((tray) =>
|
||||
tray.nama.toLowerCase().includes(props.search.toLowerCase())
|
||||
);
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
refreshData();
|
||||
});
|
||||
</script>
|
@ -4,7 +4,7 @@
|
||||
v-model="searchText"
|
||||
type="text"
|
||||
placeholder="Cari ..."
|
||||
class="border rounded-md px-3 py-2 w-64 focus:outline-none focus:ring-2 focus:ring-blue-400"
|
||||
class="border border-C bg-A rounded-md px-3 py-2 w-64 focus:outline-none focus:ring-2 focus:ring-blue-400"
|
||||
@input="$emit('update:search', searchText)"
|
||||
/>
|
||||
</div>
|
||||
|
@ -1,10 +1,19 @@
|
||||
<template>
|
||||
<Header />
|
||||
<div class="mx-2 md:mx-4 lg:mx-6 xl:mx-7 my-6">
|
||||
<div class="min-h-screen flex flex-col">
|
||||
<!-- Navbar -->
|
||||
<NavigationComponent />
|
||||
|
||||
<!-- Konten utama -->
|
||||
<div class="flex-1 mx-2 md:mx-4 lg:mx-6 xl:mx-7 my-6">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<!-- Footer selalu di bawah -->
|
||||
<Footer class="w-full" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Header from '../components/Header.vue'
|
||||
import Footer from '../components/Footer.vue'
|
||||
import NavigationComponent from '../components/NavigationComponent.vue'
|
||||
</script>
|
9
resources/js/middlewares/auth.js
Normal file
9
resources/js/middlewares/auth.js
Normal file
@ -0,0 +1,9 @@
|
||||
export default function auth(to, from, next) {
|
||||
const token = localStorage.getItem("token")
|
||||
|
||||
if (!token) {
|
||||
next({ name: "Login" })
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
}
|
8
resources/js/middlewares/guest.js
Normal file
8
resources/js/middlewares/guest.js
Normal file
@ -0,0 +1,8 @@
|
||||
export default function guest(to, from, next) {
|
||||
const token = localStorage.getItem("token")
|
||||
if (token) {
|
||||
next({ name: "Brankas" })
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
}
|
8
resources/js/middlewares/owner.js
Normal file
8
resources/js/middlewares/owner.js
Normal file
@ -0,0 +1,8 @@
|
||||
export default function owner(to, from, next) {
|
||||
const role = localStorage.getItem("role")
|
||||
if (role !== "owner") {
|
||||
next({ name: "Kasir" })
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
}
|
219
resources/js/pages/Akun.vue
Normal file
219
resources/js/pages/Akun.vue
Normal file
@ -0,0 +1,219 @@
|
||||
<template>
|
||||
<mainLayout>
|
||||
<!-- Modal Create/Edit Akun -->
|
||||
<CreateAkun
|
||||
v-if="creatingAkun"
|
||||
:isOpen="creatingAkun"
|
||||
:akun="detail"
|
||||
@close="closeAkun"
|
||||
/>
|
||||
|
||||
<EditAkun
|
||||
v-if="editingAkun"
|
||||
:isOpen="editingAkun"
|
||||
:akun="detail"
|
||||
@close="closeEditAkun"
|
||||
/>
|
||||
|
||||
<!-- Modal Delete -->
|
||||
<ConfirmDeleteModal
|
||||
:isOpen="confirmDeleteOpen"
|
||||
title="Hapus User"
|
||||
message="Apakah Anda yakin ingin menghapus user ini?"
|
||||
@confirm="confirmDelete"
|
||||
@cancel="closeDeleteModal"
|
||||
/>
|
||||
|
||||
<div class="p-6 min-h-[75vh]">
|
||||
<!-- Header Section -->
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-2xl font-bold text-D">Manajemen Akun</h1>
|
||||
<button
|
||||
@click="tambahAkun"
|
||||
class="px-4 py-2 bg-C text-D rounded-md hover:bg-C/80 transition duration-200 flex items-center gap-2"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
Tambah User
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Table Section -->
|
||||
<div
|
||||
class="bg-white rounded-lg shadow-md border border-C overflow-hidden"
|
||||
>
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr class="bg-C text-white">
|
||||
<th class="px-6 py-4 text-center text-D border-r border-C">No</th>
|
||||
<th class="px-6 py-4 text-center text-D border-r border-C">Nama</th>
|
||||
<th class="px-6 py-4 text-center text-D border-r border-C">Peran</th>
|
||||
<th class="px-6 py-4 text-center text-D">Aksi</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="(item, index) in akun"
|
||||
:key="item.id"
|
||||
class="border-b border-C hover:bg-gray-50 transition duration-150"
|
||||
:class="{ 'bg-gray-50': index % 2 === 1 }"
|
||||
>
|
||||
<td class="px-6 py-4 border-r border-C text-center font-medium text-gray-900">
|
||||
{{ index + 1 }}
|
||||
</td>
|
||||
<td class="px-6 py-4 border-r border-C text-D">
|
||||
{{ item.nama }}
|
||||
</td>
|
||||
<td class="px-6 py-4 border-r border-C text-gray-800">
|
||||
{{ item.role }}
|
||||
</td>
|
||||
<td class="px-6 py-4 text-center">
|
||||
<div class="flex justify-center gap-2">
|
||||
<button
|
||||
@click="ubahAkun(item)"
|
||||
class="px-3 py-1 bg-yellow-500 text-white text-sm rounded hover:bg-yellow-600 transition duration-200"
|
||||
>
|
||||
Ubah
|
||||
</button>
|
||||
<button
|
||||
@click="hapusAkun(item)"
|
||||
class="px-3 py-1 bg-red-500 text-white text-sm rounded hover:bg-red-600 transition duration-200"
|
||||
>
|
||||
Hapus
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Empty State -->
|
||||
<tr v-if="akun.length === 0 && !loading">
|
||||
<td colspan="5" class="px-6 py-8 text-center text-gray-500">
|
||||
<div class="flex flex-col items-center">
|
||||
<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 user</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="flex justify-center items-center py-8">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-[#c6a77d]"></div>
|
||||
<span class="ml-2 text-gray-600">Memuat data...</span>
|
||||
</div>
|
||||
</div>
|
||||
</mainLayout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from "vue";
|
||||
import axios from "axios";
|
||||
import mainLayout from "../layouts/mainLayout.vue";
|
||||
import ConfirmDeleteModal from "../components/ConfirmDeleteModal.vue";
|
||||
import CreateAkun from "../components/CreateAkun.vue";
|
||||
import EditAkun from "../components/EditAkun.vue";
|
||||
|
||||
// State
|
||||
const akun = ref([]);
|
||||
const loading = ref(false);
|
||||
const creatingAkun = ref(false);
|
||||
const detail = ref(null);
|
||||
const editingAkun = ref(false);
|
||||
const confirmDeleteOpen = ref(false);
|
||||
const akunToDelete = ref(null);
|
||||
|
||||
// Fetch data dari API
|
||||
const fetchAkun = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await axios.get("/api/user", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||
},
|
||||
});
|
||||
akun.value = response.data;
|
||||
} catch (error) {
|
||||
console.error("Error fetching akun:", error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Tambah
|
||||
const tambahAkun = () => {
|
||||
detail.value = null;
|
||||
creatingAkun.value = true;
|
||||
};
|
||||
|
||||
// Ubah
|
||||
const ubahAkun = (item) => {
|
||||
detail.value = item;
|
||||
editingAkun.value = true;
|
||||
};
|
||||
|
||||
// Hapus
|
||||
const hapusAkun = (item) => {
|
||||
akunToDelete.value = item;
|
||||
confirmDeleteOpen.value = true;
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
try {
|
||||
await axios.delete(`/api/user/${akunToDelete.value.id}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||
},
|
||||
});
|
||||
fetchAkun();
|
||||
confirmDeleteOpen.value = false;
|
||||
} catch (error) {
|
||||
console.error("Error deleting akun:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const closeDeleteModal = () => {
|
||||
confirmDeleteOpen.value = false;
|
||||
akunToDelete.value = null;
|
||||
};
|
||||
|
||||
// Tutup modal Create/Edit
|
||||
const closeAkun = () => {
|
||||
creatingAkun.value = false;
|
||||
fetchAkun();
|
||||
};
|
||||
|
||||
const closeEditAkun = () => {
|
||||
editingAkun.value = false;
|
||||
fetchAkun();
|
||||
};
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
fetchAkun();
|
||||
});
|
||||
</script>
|
@ -1,8 +1,10 @@
|
||||
<template>
|
||||
<mainLayout>
|
||||
<p style="font-family: 'IM FELL Great Primer', serif; font-style: italic;font-size: 25px;">BRANKAS</p>
|
||||
<div class="p-6">
|
||||
<p class="font-serif italic text-[25px] text-D">BRANKAS</p>
|
||||
<searchbar v-model:search="searchQuery" />
|
||||
<BrankasList :search="searchQuery" />
|
||||
</div>
|
||||
</mainLayout>
|
||||
|
||||
|
||||
|
396
resources/js/pages/EditProduk.vue
Normal file
396
resources/js/pages/EditProduk.vue
Normal file
@ -0,0 +1,396 @@
|
||||
<template>
|
||||
<mainLayout>
|
||||
<!-- Modal Buat Item -->
|
||||
<CreateItemModal
|
||||
:isOpen="openItemModal"
|
||||
:product="editedProduct"
|
||||
@close="closeItemModal"
|
||||
/>
|
||||
|
||||
<div class="p-6">
|
||||
<p class="font-serif italic text-[25px] text-D">Edit Produk</p>
|
||||
|
||||
<div class="flex flex-col md:flex-row mt-5 gap-6">
|
||||
<!-- Form Section -->
|
||||
<div class="flex-1">
|
||||
<div class="mb-3">
|
||||
<label class="block text-D mb-1">Nama Produk</label>
|
||||
<InputField
|
||||
v-model="form.nama"
|
||||
type="text"
|
||||
placeholder="Masukkan nama produk"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="block text-D mb-1">Kategori</label>
|
||||
<InputSelect
|
||||
v-model="form.id_kategori"
|
||||
:options="category"
|
||||
placeholder="Pilih kategori"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mb-3 flex flex-row w-full gap-3">
|
||||
<div class="flex-1">
|
||||
<label class="block text-D mb-1">Berat (g)</label>
|
||||
<InputField
|
||||
v-model="form.berat"
|
||||
type="number"
|
||||
step="0.01"
|
||||
placeholder="Masukkan berat"
|
||||
@input="calculateHargaJual"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<label class="block text-D mb-1">Kadar (K)</label>
|
||||
<InputField
|
||||
v-model="form.kadar"
|
||||
type="number"
|
||||
placeholder="Masukkan kadar"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3 flex flex-row w-full gap-3">
|
||||
<div class="flex-1">
|
||||
<label class="block text-D mb-1"
|
||||
>Harga per Gram</label
|
||||
>
|
||||
<InputField
|
||||
v-model="form.harga_per_gram"
|
||||
type="number"
|
||||
step="0.01"
|
||||
placeholder="Masukkan harga per gram"
|
||||
@input="calculateHargaJual"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<label class="block text-D mb-1">Harga Jual</label>
|
||||
<InputField
|
||||
v-model="form.harga_jual"
|
||||
type="number"
|
||||
step="0.01"
|
||||
placeholder="Masukkan harga jual"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Image Upload Section -->
|
||||
<div class="flex-1">
|
||||
<label class="block text-D mb-1">Foto</label>
|
||||
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<!-- Existing Images -->
|
||||
<div
|
||||
v-for="(image, index) in uploadedImages"
|
||||
:key="`img-${image.id}`"
|
||||
class="relative group aspect-square"
|
||||
>
|
||||
<div
|
||||
class="w-full h-full bg-gray-100 rounded-lg border-2 border-gray-200 overflow-hidden"
|
||||
>
|
||||
<img
|
||||
:src="image.url"
|
||||
:alt="`Foto ${index + 1}`"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
<button
|
||||
@click="removeImage(image.id)"
|
||||
:disabled="uploadLoading"
|
||||
class="absolute -top-2 -right-2 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center text-sm font-bold shadow-lg hover:bg-red-600 transition-colors disabled:bg-gray-400"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upload Button -->
|
||||
<div
|
||||
v-if="uploadedImages.length < 6"
|
||||
@drop="handleDrop"
|
||||
@dragover.prevent
|
||||
@dragenter.prevent="isDragging = true"
|
||||
@dragleave.prevent="isDragging = false"
|
||||
@click="triggerFileInput"
|
||||
class="aspect-square bg-gray-50 border-2 border-dashed border-gray-300 rounded-lg flex flex-col items-center justify-center cursor-pointer hover:border-D hover:bg-blue-50 transition-colors group"
|
||||
:class="{
|
||||
'border-blue-400 bg-blue-50': isDragging,
|
||||
'cursor-not-allowed opacity-50': uploadLoading,
|
||||
}"
|
||||
>
|
||||
<div class="text-center">
|
||||
<div
|
||||
v-if="!uploadLoading"
|
||||
class="w-12 h-12 bg-D rounded-lg flex items-center justify-center mx-auto mb-2 group-hover:bg-D transition-colors"
|
||||
>
|
||||
<svg
|
||||
class="w-6 h-6 text-white"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="w-12 h-12 bg-D rounded-lg flex items-center justify-center mx-auto mb-2"
|
||||
>
|
||||
<svg
|
||||
class="animate-spin w-6 h-6 text-white"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="m4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<p
|
||||
class="text-xs text-gray-600 font-medium"
|
||||
v-html="
|
||||
uploadLoading
|
||||
? 'Uploading...'
|
||||
: 'Unggah<br/>Foto'
|
||||
"
|
||||
></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/jpeg,image/jpg,image/png"
|
||||
@change="handleFileSelect"
|
||||
class="hidden"
|
||||
/>
|
||||
|
||||
<p class="text-xs text-gray-500 mt-2">
|
||||
Format: JPG, JPEG, PNG (Max: 2MB per file, Max: 6 foto)
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-if="uploadError"
|
||||
class="mt-2 p-2 bg-red-50 border border-red-200 rounded text-sm text-red-600"
|
||||
>
|
||||
{{ uploadError }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end flex-row gap-3">
|
||||
<button
|
||||
@click="back"
|
||||
class="px-6 py-2 rounded-md bg-gray-400 hover:bg-gray-500 text-white"
|
||||
>
|
||||
Batal
|
||||
</button>
|
||||
<button
|
||||
@click="submitForm"
|
||||
:disabled="loading || !isFormValid"
|
||||
class="bg-C text-D px-6 py-2 rounded-md hover:bg-B disabled:bg-B disabled:text-white disabled:cursor-not-allowed"
|
||||
>
|
||||
{{ loading ? "Menyimpan..." : "Simpan Perubahan" }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</mainLayout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import axios from "axios";
|
||||
import mainLayout from "../layouts/mainLayout.vue";
|
||||
import InputField from "../components/InputField.vue";
|
||||
import InputSelect from "../components/InputSelect.vue";
|
||||
import CreateItemModal from "../components/CreateItemModal.vue";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const productId = route.params.id;
|
||||
|
||||
const form = ref({
|
||||
nama: "",
|
||||
id_kategori: null,
|
||||
berat: 0,
|
||||
kadar: 0,
|
||||
harga_per_gram: 0,
|
||||
harga_jual: 0,
|
||||
});
|
||||
|
||||
const category = ref([]);
|
||||
const uploadedImages = ref([]);
|
||||
const loading = ref(false);
|
||||
const uploadLoading = ref(false);
|
||||
const uploadError = ref("");
|
||||
const isDragging = ref(false);
|
||||
const fileInput = ref(null);
|
||||
|
||||
const openItemModal = ref(false);
|
||||
const editedProduct = ref(null);
|
||||
const userId = ref(1); // TODO: ambil dari auth
|
||||
|
||||
const isFormValid = computed(() => {
|
||||
return (
|
||||
form.value.nama &&
|
||||
form.value.id_kategori &&
|
||||
form.value.berat > 0 &&
|
||||
form.value.kadar > 0 &&
|
||||
form.value.harga_per_gram > 0 &&
|
||||
form.value.harga_jual > 0
|
||||
);
|
||||
});
|
||||
|
||||
const calculateHargaJual = () => {
|
||||
const berat = parseFloat(form.value.berat) || 0;
|
||||
const hargaPerGram = parseFloat(form.value.harga_per_gram) || 0;
|
||||
if (berat > 0 && hargaPerGram > 0) {
|
||||
form.value.harga_jual = berat * hargaPerGram;
|
||||
}
|
||||
};
|
||||
|
||||
const loadKategori = async () => {
|
||||
const response = await axios.get("/api/kategori", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||
},
|
||||
});
|
||||
category.value = response.data.map((c) => ({ value: c.id, label: c.nama }));
|
||||
};
|
||||
|
||||
const loadProduk = async () => {
|
||||
const response = await axios.get(`/api/produk/${productId}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||
},
|
||||
});
|
||||
const produk = response.data;
|
||||
form.value = {
|
||||
nama: produk.nama,
|
||||
id_kategori: produk.id_kategori,
|
||||
berat: produk.berat,
|
||||
kadar: produk.kadar,
|
||||
harga_per_gram: produk.harga_per_gram,
|
||||
harga_jual: produk.harga_jual,
|
||||
};
|
||||
uploadedImages.value = produk.foto || [];
|
||||
};
|
||||
|
||||
const triggerFileInput = () => {
|
||||
if (!uploadLoading.value && uploadedImages.value.length < 6) {
|
||||
fileInput.value?.click();
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileSelect = (e) => {
|
||||
const files = Array.from(e.target.files);
|
||||
uploadFiles(files);
|
||||
};
|
||||
|
||||
const handleDrop = (e) => {
|
||||
e.preventDefault();
|
||||
isDragging.value = false;
|
||||
if (uploadLoading.value || uploadedImages.value.length >= 6) return;
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
uploadFiles(files);
|
||||
};
|
||||
|
||||
const uploadFiles = async (files) => {
|
||||
uploadError.value = "";
|
||||
const validFiles = files.filter(
|
||||
(file) =>
|
||||
["image/jpeg", "image/jpg", "image/png"].includes(file.type) &&
|
||||
file.size <= 2 * 1024 * 1024
|
||||
);
|
||||
if (!validFiles.length) return;
|
||||
uploadLoading.value = true;
|
||||
try {
|
||||
for (const file of validFiles) {
|
||||
const formData = new FormData();
|
||||
formData.append("foto", file);
|
||||
formData.append("id_user", userId.value);
|
||||
const res = await axios.post("/api/foto/upload", formData, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||
"Content-Type": "multipart/form-data",
|
||||
},
|
||||
});
|
||||
uploadedImages.value.push(res.data);
|
||||
}
|
||||
} finally {
|
||||
uploadLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const removeImage = async (id) => {
|
||||
try {
|
||||
await axios.delete(`/api/foto/hapus/${id}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||
},
|
||||
});
|
||||
uploadedImages.value = uploadedImages.value.filter((i) => i.id !== id);
|
||||
} catch {
|
||||
uploadError.value = "Gagal menghapus foto";
|
||||
}
|
||||
};
|
||||
|
||||
const submitForm = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
await axios.put(
|
||||
`/api/produk/${productId}`,
|
||||
{
|
||||
...form.value,
|
||||
id_user: userId.value,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
alert("Produk berhasil diupdate!");
|
||||
router.push("/produk");
|
||||
} catch (err) {
|
||||
alert("Gagal update produk!");
|
||||
console.error(err);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const closeItemModal = () => {
|
||||
openItemModal.value = false;
|
||||
};
|
||||
|
||||
const back = () => {
|
||||
router.push("/produk");
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadKategori();
|
||||
loadProduk();
|
||||
});
|
||||
</script>
|
@ -1,11 +1,10 @@
|
||||
<template>
|
||||
<mainLayout>
|
||||
<div class="home">
|
||||
<div class="home p-6">
|
||||
<h1 class="text-3xl font-bold text-D mb-4">Contoh penggunaan halaman</h1>
|
||||
|
||||
<div class="message-model">
|
||||
<p>{{ message }}</p>
|
||||
</div>
|
||||
<!-- Komponen Struk -->
|
||||
<StrukOverlay :isOpen="true" />
|
||||
|
||||
<hr class="my-6 border-D" />
|
||||
<h1 class="text-xl font-bold text-D mb-4">Contoh grid</h1>
|
||||
@ -21,25 +20,8 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import mainLayout from '../layouts/mainLayout.vue'
|
||||
import StrukOverlay from '../components/StrukOverlay.vue' // pastikan path sesuai
|
||||
|
||||
const message = ref("Style dan message dari script dan style di dalam halaman")
|
||||
|
||||
const data = ref([1, 2, 3, 4, 5])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.message-model {
|
||||
border: 1px solid yellow;
|
||||
text-align: center;
|
||||
border-radius: 10px;
|
||||
width: 80%;
|
||||
margin: auto;
|
||||
background-color: yellow;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.message-model p {
|
||||
font-weight: bold;
|
||||
font-size: 24px;
|
||||
}
|
||||
</style>
|
408
resources/js/pages/InputProduk.vue
Normal file
408
resources/js/pages/InputProduk.vue
Normal file
@ -0,0 +1,408 @@
|
||||
<template>
|
||||
<mainLayout>
|
||||
<!-- Modal Buat Item - Sekarang menggunakan komponen terpisah -->
|
||||
<CreateItemModal
|
||||
:isOpen="openItemModal"
|
||||
:product="createdProduct"
|
||||
@close="closeItemModal"
|
||||
/>
|
||||
<div class="p-6">
|
||||
<p class="font-serif italic text-[25px] text-D">Produk Baru</p>
|
||||
|
||||
<div class="flex flex-col md:flex-row mt-5 gap-6">
|
||||
<!-- Form Section -->
|
||||
<div class="flex-1">
|
||||
<div class="mb-3">
|
||||
<label class="block text-D mb-1">Nama Produk</label>
|
||||
<InputField v-model="form.nama" type="text" placeholder="Masukkan nama produk" />
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="block text-D mb-1">Kategori</label>
|
||||
<InputSelect v-model="form.id_kategori" :options="category" placeholder="Pilih kategori" />
|
||||
</div>
|
||||
|
||||
<div class="mb-3 flex flex-row w-full gap-3">
|
||||
<div class="flex-1">
|
||||
<label class="block text-D mb-1">Berat (g)</label>
|
||||
<InputField v-model="form.berat" type="number" step="0.01" placeholder="Masukkan berat"
|
||||
@input="calculateHargaJual" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<label class="block text-D mb-1">Kadar (K)</label>
|
||||
<InputField v-model="form.kadar" type="number" placeholder="Masukkan kadar" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3 flex flex-row w-full gap-3">
|
||||
<div class="flex-1">
|
||||
<label class="block text-D mb-1">Harga per Gram</label>
|
||||
<InputField v-model="form.harga_per_gram" type="number" step="0.01" placeholder="Masukkan harga per gram"
|
||||
@input="calculateHargaJual" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<label class="block text-D mb-1">Harga Jual</label>
|
||||
<InputField v-model="form.harga_jual" type="number" step="0.01" placeholder="Masukkan harga jual" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Image Upload Section -->
|
||||
<div class="flex-1">
|
||||
<label class="block text-D mb-1">Foto</label>
|
||||
|
||||
<!-- Image Grid -->
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<!-- Uploaded Images -->
|
||||
<div v-for="(image, index) in uploadedImages" :key="`img-${image.id}`" class="relative group aspect-square">
|
||||
<div class="w-full h-full bg-gray-100 rounded-lg border-2 border-gray-200 overflow-hidden">
|
||||
<img :src="image.url" :alt="`Foto ${index + 1}`" class="w-full h-full object-cover" />
|
||||
<!-- Delete Button -->
|
||||
<button @click="removeImage(image.id)" :disabled="uploadLoading"
|
||||
class="absolute -top-2 -right-2 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center text-sm font-bold shadow-lg hover:bg-red-600 transition-colors disabled:bg-gray-400">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upload Button -->
|
||||
<div v-if="uploadedImages.length < 6" @drop="handleDrop" @dragover.prevent
|
||||
@dragenter.prevent="isDragging = true" @dragleave.prevent="isDragging = false" @click="triggerFileInput"
|
||||
class="aspect-square bg-gray-50 border-2 border-dashed border-gray-300 rounded-lg flex flex-col items-center justify-center cursor-pointer hover:border-D hover:bg-blue-50 transition-colors group"
|
||||
:class="{
|
||||
'border-blue-400 bg-blue-50': isDragging,
|
||||
'cursor-not-allowed opacity-50': uploadLoading
|
||||
}">
|
||||
<div class="text-center">
|
||||
<div v-if="!uploadLoading"
|
||||
class="w-12 h-12 bg-D rounded-lg flex items-center justify-center mx-auto mb-2 group-hover:bg-D transition-colors">
|
||||
<svg class="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div v-else class="w-12 h-12 bg-D rounded-lg flex items-center justify-center mx-auto mb-2">
|
||||
<svg class="animate-spin w-6 h-6 text-white" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor"
|
||||
d="m4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z">
|
||||
</path>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="text-xs text-gray-600 font-medium"
|
||||
v-html="uploadLoading ? 'Uploading...' : 'Unggah<br/>Foto'"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input ref="fileInput" type="file" multiple accept="image/jpeg,image/jpg,image/png" @change="handleFileSelect"
|
||||
class="hidden" />
|
||||
|
||||
<p class="text-xs text-gray-500 mt-2">Format: JPG, JPEG, PNG (Max: 2MB per file, Max: 6 foto)</p>
|
||||
|
||||
<div v-if="uploadError" class="mt-2 p-2 bg-red-50 border border-red-200 rounded text-sm text-red-600">
|
||||
{{ uploadError }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end flex-row gap-3">
|
||||
<button @click="back" class="px-6 py-2 rounded-md bg-gray-400 hover:bg-gray-500 text-white">Batal</button>
|
||||
<button @click="submitForm(true)" :disabled="loading || !isFormValid"
|
||||
class="bg-C text-D px-6 py-2 rounded-md hover:bg-B disabled:bg-B disabled:text-white disabled:cursor-not-allowed">
|
||||
{{ loading ? 'Menyimpan...' : 'Tambah Item' }}
|
||||
</button>
|
||||
<button @click="submitForm(false)" :disabled="loading || !isFormValid"
|
||||
class="bg-C text-D px-6 py-2 rounded-md hover:bg-B disabled:bg-B disabled:text-white disabled:cursor-not-allowed">
|
||||
{{ loading ? 'Menyimpan...' : 'Simpan' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</mainLayout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import axios from "axios";
|
||||
import mainLayout from "../layouts/mainLayout.vue";
|
||||
import InputField from "../components/InputField.vue";
|
||||
import InputSelect from "../components/InputSelect.vue";
|
||||
import CreateItemModal from "../components/CreateItemModal.vue";
|
||||
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const form = ref({
|
||||
nama: '',
|
||||
id_kategori: null,
|
||||
berat: 0,
|
||||
kadar: 0,
|
||||
harga_per_gram: 0,
|
||||
harga_jual: 0,
|
||||
});
|
||||
|
||||
const category = ref([]);
|
||||
|
||||
const loadKategori = async () => {
|
||||
try {
|
||||
const response = await axios.get('/api/kategori', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||
},
|
||||
});
|
||||
if (response.data && Array.isArray(response.data)) {
|
||||
category.value = response.data.map(cat => ({
|
||||
value: cat.id,
|
||||
label: cat.nama
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading categories:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const loading = ref(false);
|
||||
const uploadLoading = ref(false);
|
||||
const uploadedImages = ref([]);
|
||||
const isDragging = ref(false);
|
||||
const uploadError = ref('');
|
||||
const fileInput = ref(null);
|
||||
// TODO: Logika autentikasi user
|
||||
const userId = ref(1);
|
||||
|
||||
const openItemModal = ref(false);
|
||||
const createdProduct = ref(null);
|
||||
|
||||
const isFormValid = computed(() => {
|
||||
return form.value.nama &&
|
||||
form.value.id_kategori &&
|
||||
form.value.berat > 0 &&
|
||||
form.value.kadar > 0 &&
|
||||
form.value.harga_per_gram > 0 &&
|
||||
form.value.harga_jual > 0 &&
|
||||
uploadedImages.value.length > 0;
|
||||
});
|
||||
|
||||
const calculateHargaJual = () => {
|
||||
const berat = parseFloat(form.value.berat) || 0;
|
||||
const hargaPerGram = parseFloat(form.value.harga_per_gram) || 0;
|
||||
if (berat > 0 && hargaPerGram > 0) {
|
||||
form.value.harga_jual = berat * hargaPerGram;
|
||||
}
|
||||
};
|
||||
|
||||
const loadExistingPhotos = async () => {
|
||||
try {
|
||||
const response = await axios.get(`/api/foto/${userId.value}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||
},
|
||||
});
|
||||
if (response.data && Array.isArray(response.data)) {
|
||||
uploadedImages.value = response.data;
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.response?.status !== 404) {
|
||||
console.error('Error loading existing photos:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const openCreateItemModal = (product) => {
|
||||
createdProduct.value = product;
|
||||
openItemModal.value = true;
|
||||
};
|
||||
|
||||
const closeItemModal = () => {
|
||||
openItemModal.value = false;
|
||||
createdProduct.value = null;
|
||||
resetForm();
|
||||
router.replace('/produk');
|
||||
};
|
||||
|
||||
const triggerFileInput = () => {
|
||||
if (!uploadLoading.value && uploadedImages.value.length < 6) {
|
||||
fileInput.value?.click();
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileSelect = (event) => {
|
||||
const files = Array.from(event.target.files);
|
||||
uploadFiles(files);
|
||||
};
|
||||
|
||||
const handleDrop = (event) => {
|
||||
event.preventDefault();
|
||||
isDragging.value = false;
|
||||
|
||||
if (uploadLoading.value || uploadedImages.value.length >= 6) return;
|
||||
|
||||
const files = Array.from(event.dataTransfer.files);
|
||||
uploadFiles(files);
|
||||
};
|
||||
|
||||
const uploadFiles = async (files) => {
|
||||
uploadError.value = '';
|
||||
|
||||
if (uploadedImages.value.length + files.length > 6) {
|
||||
uploadError.value = 'Maksimal 6 foto yang dapat diupload';
|
||||
return;
|
||||
}
|
||||
|
||||
const validFiles = files.filter(file => {
|
||||
const isValidType = ['image/jpeg', 'image/jpg', 'image/png'].includes(file.type);
|
||||
const isValidSize = file.size <= 2 * 1024 * 1024;
|
||||
|
||||
if (!isValidType) {
|
||||
uploadError.value = 'Format file harus JPG, JPEG, atau PNG';
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isValidSize) {
|
||||
uploadError.value = 'Ukuran file maksimal 2MB';
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
if (validFiles.length === 0) return;
|
||||
|
||||
uploadLoading.value = true;
|
||||
|
||||
try {
|
||||
for (const file of validFiles) {
|
||||
const formData = new FormData();
|
||||
formData.append('foto', file);
|
||||
formData.append('id_user', userId.value);
|
||||
|
||||
const response = await axios.post('/api/foto/upload', formData, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||
'Content-Type': 'multipart/form-data',
|
||||
|
||||
},
|
||||
});
|
||||
|
||||
uploadedImages.value.push(response.data);
|
||||
}
|
||||
|
||||
if (fileInput.value) {
|
||||
fileInput.value.value = '';
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error);
|
||||
uploadError.value = error.response?.data?.message || 'Gagal mengupload foto';
|
||||
} finally {
|
||||
uploadLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const removeImage = async (imageId) => {
|
||||
try {
|
||||
await axios.delete(`/api/foto/hapus/${imageId}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||
},
|
||||
})
|
||||
;
|
||||
uploadedImages.value = uploadedImages.value.filter(img => img.id !== imageId);
|
||||
uploadError.value = '';
|
||||
} catch (error) {
|
||||
console.error('Delete error:', error);
|
||||
uploadError.value = 'Gagal menghapus foto';
|
||||
}
|
||||
};
|
||||
|
||||
const submitForm = async (addItem) => {
|
||||
if (!isFormValid.value) {
|
||||
alert('Mohon lengkapi semua field yang diperlukan');
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
const response = await axios.post('/api/produk', {
|
||||
...form.value,
|
||||
id_user: userId.value,
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||
},
|
||||
});
|
||||
|
||||
const createdProductData = response.data.data;
|
||||
|
||||
// Reset form
|
||||
form.value = {
|
||||
nama: '',
|
||||
id_kategori: '',
|
||||
berat: 0,
|
||||
kadar: 0,
|
||||
harga_per_gram: 0,
|
||||
harga_jual: 0,
|
||||
};
|
||||
|
||||
uploadedImages.value = [];
|
||||
uploadError.value = '';
|
||||
|
||||
if (fileInput.value) {
|
||||
fileInput.value.value = '';
|
||||
}
|
||||
|
||||
if (addItem) {
|
||||
openCreateItemModal(createdProductData);
|
||||
} else {
|
||||
window.location.href = '/produk?message=Produk berhasil disimpan';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Submit error:', error);
|
||||
|
||||
if (error.response?.data?.errors) {
|
||||
const errors = Object.values(error.response.data.errors).flat();
|
||||
alert('Error: ' + errors.join(', '));
|
||||
} else {
|
||||
alert('Gagal menyimpan produk: ' + (error.response?.data?.message || error.message));
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = async () => {
|
||||
form.value = {
|
||||
nama: '',
|
||||
id_kategori: '',
|
||||
berat: 0,
|
||||
kadar: 0,
|
||||
harga_per_gram: 0,
|
||||
harga_jual: 0,
|
||||
};
|
||||
try {
|
||||
await axios.delete(`/api/foto/reset/${userId.value}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||
},
|
||||
});
|
||||
uploadedImages.value = [];
|
||||
} catch (error) {
|
||||
console.error('Error resetting photos:', error);
|
||||
}
|
||||
uploadError.value = '';
|
||||
if (fileInput.value) {
|
||||
fileInput.value.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const back = () => {
|
||||
resetForm();
|
||||
window.history.back();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadExistingPhotos();
|
||||
loadKategori();
|
||||
});
|
||||
</script>
|
105
resources/js/pages/Kasir.vue
Normal file
105
resources/js/pages/Kasir.vue
Normal file
@ -0,0 +1,105 @@
|
||||
<template>
|
||||
<mainLayout>
|
||||
<div class="lg:p-2 pt-6">
|
||||
<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 -->
|
||||
<div class="lg:col-span-3">
|
||||
<div
|
||||
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 />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Section - Transaction List -->
|
||||
<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 max-h-[70vh] overflow-y-auto"
|
||||
>
|
||||
<div class="p-3 sm:p-4 md:p-6">
|
||||
<!-- Loading -->
|
||||
<div
|
||||
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>
|
||||
|
||||
<!-- Empty -->
|
||||
<div
|
||||
v-else-if="!transaksi.length"
|
||||
class="text-center py-8"
|
||||
>
|
||||
<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>
|
||||
<p class="text-[var(--color-D)]/60 text-sm">
|
||||
Belum ada transaksi
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Transaction List -->
|
||||
<KasirTransaksiList
|
||||
v-else
|
||||
:transaksi="transaksi"
|
||||
@detail="lihatDetail"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</mainLayout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from "vue";
|
||||
import axios from "axios";
|
||||
|
||||
import mainLayout from "../layouts/mainLayout.vue";
|
||||
import KasirForm from "../components/KasirForm.vue";
|
||||
import KasirTransaksiList from "../components/KasirTransaksiList.vue";
|
||||
|
||||
const transaksi = ref([]);
|
||||
const loading = ref(true);
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
loading.value = true;
|
||||
const res = await axios.get("/api/transaksi?limit=10", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||
},
|
||||
});
|
||||
|
||||
transaksi.value = res.data;
|
||||
} catch (err) {
|
||||
console.error("Gagal fetch transaksi:", err);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
const lihatDetail = (trx) => {
|
||||
alert(`Detail transaksi: ${trx.kode}`);
|
||||
};
|
||||
</script>
|
169
resources/js/pages/Kategori.vue
Normal file
169
resources/js/pages/Kategori.vue
Normal file
@ -0,0 +1,169 @@
|
||||
<template>
|
||||
<mainLayout>
|
||||
<CreateKategori :isOpen="creatingKategori" :product="detail" @close="closeKategori" />
|
||||
|
||||
<ConfirmDeleteModal :isOpen="confirmDeleteOpen" :item="kategoriToDelete" title="Hapus Kategori"
|
||||
message="Apakah Anda yakin ingin menghapus kategori ini?" @confirm="confirmDelete" @cancel="closeDeleteModal" />
|
||||
<div class="p-6 min-h-[75vh]" >
|
||||
<p class="font-serif italic text-[25px] text-D">KATEGORI</p>
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
|
||||
<button @click="tambahKategori"
|
||||
class="px-4 py-2 bg-C text-black rounded-md hover:bg-B transition duration-200 flex items-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Tambah Kategori
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Table Section -->
|
||||
<div class="bg-white rounded-lg shadow-md border border-C overflow-hidden">
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr class="bg-C text-black">
|
||||
<th class="px-6 py-4 text-center font-semibold border-r border-C">
|
||||
No
|
||||
</th>
|
||||
<th class="px-6 py-4 text-center font-semibold border-r border-C">
|
||||
Nama Kategori
|
||||
</th>
|
||||
<th class="px-6 py-4 text-center font-semibold">
|
||||
Aksi
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(item, index) in kategori" :key="item.id"
|
||||
class="border-b border-C hover:bg-A transition duration-150"
|
||||
:class="{ 'bg-gray-50': index % 2 === 1 }">
|
||||
<td class="px-6 py-4 border-r border-C font-medium text-center text-gray-900">
|
||||
{{ index + 1 }}
|
||||
</td>
|
||||
<td class="px-6 py-4 border-r border-C text-center text-gray-800">
|
||||
{{ item.nama }}
|
||||
</td>
|
||||
<td class="px-6 py-4 text-center">
|
||||
<div class="flex justify-center gap-2">
|
||||
<button @click="ubahKategori(item)"
|
||||
class="px-3 py-1 bg-yellow-500 text-white text-sm rounded hover:bg-yellow-600 transition duration-200">
|
||||
Ubah
|
||||
</button>
|
||||
<button @click="hapusKategori(item)"
|
||||
class="px-3 py-1 bg-red-500 text-white text-sm rounded hover:bg-red-600 transition duration-200">
|
||||
Hapus
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Empty State -->
|
||||
<tr v-if="kategori.length === 0 && !loading">
|
||||
<td colspan="3" class="px-6 py-8 text-center text-gray-500">
|
||||
<div class="flex flex-col items-center">
|
||||
<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 kategori</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<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>
|
||||
</div>
|
||||
</mainLayout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from "vue";
|
||||
import axios from "axios";
|
||||
import mainLayout from "../layouts/mainLayout.vue";
|
||||
import CreateKategori from "../components/CreateKategori.vue";
|
||||
import ConfirmDeleteModal from "../components/ConfirmDeleteModal.vue";
|
||||
|
||||
// Reactive data
|
||||
const kategori = ref([]);
|
||||
const loading = ref(false);
|
||||
const creatingKategori = ref(false);
|
||||
const detail = ref(null);
|
||||
const confirmDeleteOpen = ref(false);
|
||||
const kategoriToDelete = ref(null);
|
||||
|
||||
// Fetch data kategori dari API
|
||||
const fetchKategoris = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await axios.get("/api/kategori", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||
},
|
||||
});
|
||||
kategori.value = response.data;
|
||||
console.log("Data kategori:", response.data);
|
||||
} catch (error) {
|
||||
console.error("Error fetching kategori:", error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Tambah kategori - open modal
|
||||
const tambahKategori = () => {
|
||||
detail.value = null; // Reset detail untuk mode create
|
||||
creatingKategori.value = true;
|
||||
};
|
||||
|
||||
// Close modal
|
||||
const closeKategori = () => {
|
||||
creatingKategori.value = false;
|
||||
fetchKategoris(); // Refresh data setelah modal ditutup
|
||||
};
|
||||
|
||||
// Ubah kategori
|
||||
const ubahKategori = (item) => {
|
||||
detail.value = item; // Set detail untuk mode edit
|
||||
creatingKategori.value = true;
|
||||
};
|
||||
|
||||
// Hapus kategori
|
||||
const hapusKategori = (item) => {
|
||||
kategoriToDelete.value = item;
|
||||
confirmDeleteOpen.value = true;
|
||||
};
|
||||
|
||||
// 🔵 Ditambahkan: aksi konfirmasi hapus
|
||||
const confirmDelete = async () => {
|
||||
try {
|
||||
await axios.delete(`/api/kategori/${kategoriToDelete.value.id}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||
},
|
||||
});
|
||||
console.log("Kategori berhasil dihapus");
|
||||
fetchKategoris();
|
||||
} catch (error) {
|
||||
console.error("Error deleting kategori:", error);
|
||||
} finally {
|
||||
closeDeleteModal();
|
||||
}
|
||||
};
|
||||
|
||||
// 🔵 Ditambahkan: tutup modal hapus
|
||||
const closeDeleteModal = () => {
|
||||
confirmDeleteOpen.value = false;
|
||||
kategoriToDelete.value = null;
|
||||
};
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
fetchKategoris();
|
||||
});
|
||||
</script>
|
53
resources/js/pages/Laporan.vue
Normal file
53
resources/js/pages/Laporan.vue
Normal file
@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<mainLayout>
|
||||
<div class="p-6">
|
||||
<p class="font-serif italic text-[25px] text-D mb-4">Laporan</p>
|
||||
|
||||
<div class="mb-4">
|
||||
<ul class="flex flex-wrap text-center" role="tablist">
|
||||
<li v-for="tab in tabs" class="mr-2" role="presentation">
|
||||
<button :class="[
|
||||
'inline-block p-2 border-b-2 rounded-t-lg',
|
||||
activeTab === tab.id
|
||||
? 'border-D text-D'
|
||||
: 'border-transparent text-D hover:text-D/50 hover:border-D',
|
||||
]" @click="activeTab = tab.id" type="button" role="tab" aria-controls="ringkasan-content"
|
||||
:aria-selected="activeTab === tab.id">
|
||||
{{ tab.name }}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div v-if="activeTab === 'ringkasan'" id="ringkasan-content" role="tabpanel">
|
||||
<RingkasanLaporan />
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'detail-nampan'" id="detail-content" role="tabpanel">
|
||||
<DetailPerNampan />
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'detail-produk'" id="detail-content" role="tabpanel">
|
||||
<DetailPerProduk />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</mainLayout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import RingkasanLaporan from '../components/RingkasanLaporan.vue';
|
||||
import mainLayout from '../layouts/mainLayout.vue';
|
||||
import DetailPerNampan from '../components/DetailPerNampan.vue';
|
||||
import DetailPerProduk from '../components/DetailPerProduk.vue';
|
||||
|
||||
const activeTab = ref('ringkasan');
|
||||
|
||||
const tabs = [
|
||||
{ name: 'Ringkasan Laporan', id: 'ringkasan' },
|
||||
{ name: 'Detail per Nampan', id: 'detail-nampan' },
|
||||
{ name: 'Detail per Produk', id: 'detail-produk' },
|
||||
];
|
||||
</script>
|
72
resources/js/pages/Login.vue
Normal file
72
resources/js/pages/Login.vue
Normal file
@ -0,0 +1,72 @@
|
||||
<template>
|
||||
<div class="flex items-center justify-center min-h-screen bg-[#0c4b66]">
|
||||
<div class="bg-white p-8 rounded-2xl shadow-xl w-80 text-center">
|
||||
<!-- Logo + Title -->
|
||||
<div class="mb-6">
|
||||
<img :src="logo" alt="Logo" class="mx-auto w-34 py-5" />
|
||||
</div>
|
||||
|
||||
<!-- Input -->
|
||||
<div>
|
||||
<InputField
|
||||
v-model="username"
|
||||
type="text"
|
||||
placeholder="Username"
|
||||
class="mb-4"
|
||||
/>
|
||||
<PasswordInput v-model="password" placeholder="Password" />
|
||||
</div>
|
||||
|
||||
<!-- Button -->
|
||||
<button
|
||||
@click="handleLogin"
|
||||
:disabled="loading"
|
||||
class="w-full py-2 bg-sky-400 hover:bg-sky-500 rounded font-bold text-gray-800 transition disabled:opacity-50"
|
||||
>
|
||||
{{ loading ? "Loading..." : "Login" }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import logo from '@/../images/logo.png'
|
||||
import InputField from "@/components/InputField.vue";
|
||||
import PasswordInput from "@/components/InputPassword.vue";
|
||||
import axios from "axios";
|
||||
|
||||
const username = ref("");
|
||||
const password = ref("");
|
||||
const loading = ref(false);
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!username.value || !password.value) {
|
||||
alert("Harap isi username dan password!");
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await axios.post("/api/login", {
|
||||
nama: username.value,
|
||||
password: password.value,
|
||||
});
|
||||
|
||||
const data = res.data;
|
||||
|
||||
// Simpan token & role
|
||||
localStorage.setItem("token", data.token);
|
||||
localStorage.setItem("role", data.role);
|
||||
|
||||
// Redirect sesuai role
|
||||
window.location.href = data.redirect;
|
||||
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
alert("Login gagal. Periksa username atau password.");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
@ -1,93 +1,217 @@
|
||||
<template>
|
||||
<mainLayout>
|
||||
<div class="p-6">
|
||||
<!-- Modal Buat Item -->
|
||||
<CreateItemModal
|
||||
:isOpen="creatingItem"
|
||||
:product="detail"
|
||||
@close="closeItemModal"
|
||||
/>
|
||||
|
||||
<!-- Modal Konfirmasi Hapus Produk -->
|
||||
<ConfirmDeleteModal
|
||||
:isOpen="deleting"
|
||||
@cancel="deleting = false"
|
||||
@confirm="deleteProduk"
|
||||
title="Hapus Produk"
|
||||
message="Apakah Anda yakin ingin menghapus produk ini?"
|
||||
/>
|
||||
|
||||
<div class="p-6 min-h-[75vh]">
|
||||
<!-- Judul -->
|
||||
<p class="font-serif italic text-[25px] text-D">PRODUK</p>
|
||||
|
||||
<!-- Wrapper -->
|
||||
<div class="mt-3">
|
||||
<!-- Mobile Layout -->
|
||||
<div class="flex flex-col gap-3 sm:hidden">
|
||||
<!-- Search -->
|
||||
<searchbar v-model:search="searchQuery" />
|
||||
|
||||
<!-- Tombol Tambah Produk -->
|
||||
<div class="mt-3 flex justify-end">
|
||||
<button
|
||||
class="bg-B text-[#0a1a3c] px-4 py-2 rounded-md shadow hover:bg-C transition"
|
||||
>
|
||||
Tambah Produk
|
||||
</button>
|
||||
<div class="w-full">
|
||||
<searchbar
|
||||
v-model:search="searchQuery"
|
||||
class="searchbar-mobile"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Grid Produk -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4 mt-4">
|
||||
<!-- Filter + Tombol -->
|
||||
<div class="flex flex-row justify-between items-center">
|
||||
<!-- Filter Kategori -->
|
||||
<div class="w-40 shrink-0">
|
||||
<InputSelect
|
||||
v-model="selectedCategory"
|
||||
:options="kategori"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Tombol Tambah Produk -->
|
||||
<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>
|
||||
|
||||
<!-- 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
|
||||
v-else
|
||||
class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4 mt-4 relative z-0"
|
||||
>
|
||||
<ProductCard
|
||||
v-for="item in filteredProducts"
|
||||
:key="item.id"
|
||||
:product="item"
|
||||
@showDetail="openOverlay"
|
||||
@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>
|
||||
|
||||
<!-- Overlay Detail Produk -->
|
||||
<div
|
||||
v-if="showOverlay"
|
||||
class="fixed inset-0 bg-black bg-opacity-50 flex justify-center items-center z-50"
|
||||
class="fixed inset-0 bg-black/30 flex justify-center items-center"
|
||||
@click.self="closeOverlay"
|
||||
>
|
||||
<div
|
||||
class="bg-white rounded-lg shadow-lg p-6 w-[400px] border-2 border-[#e6d3b3] relative"
|
||||
class="bg-white rounded-lg shadow-lg p-6 w-[450px] border-2 border-B relative flex flex-col items-center"
|
||||
>
|
||||
<!-- Tombol Close -->
|
||||
<button
|
||||
@click="closeOverlay"
|
||||
class="absolute top-2 right-2 text-gray-500 hover:text-black"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
|
||||
<!-- Foto Produk -->
|
||||
<div class="border border-[#e6d3b3] p-2 mb-4 flex justify-center">
|
||||
<div
|
||||
class="relative w-72 h-72 border border-B flex items-center justify-center mb-3 overflow-hidden rounded"
|
||||
>
|
||||
<img
|
||||
v-if="detail.gambar"
|
||||
:src="`http://127.0.0.1:8000/storage/${detail.gambar}`"
|
||||
v-if="detail.foto && detail.foto.length > 0"
|
||||
:src="detail.foto[currentFotoIndex].url"
|
||||
:alt="detail.nama"
|
||||
class="w-40 h-40 object-contain"
|
||||
class="w-full h-full object-contain"
|
||||
/>
|
||||
<span v-else class="text-gray-400 text-sm">[gambar]</span>
|
||||
|
||||
<!-- Stok (pcs) pojok kiri atas -->
|
||||
<div
|
||||
class="absolute top-1 left-1 bg-black/60 text-white text-xs px-2 py-1 rounded"
|
||||
>
|
||||
{{ detail.items_count }} pcs
|
||||
</div>
|
||||
|
||||
<!-- Stok -->
|
||||
<p class="text-sm mb-1">{{ detail.item_count }} pcs</p>
|
||||
<!-- Tombol Prev -->
|
||||
<button
|
||||
v-if="detail.foto && detail.foto.length > 1"
|
||||
@click.stop="prevFoto"
|
||||
class="absolute left-2 bg-white/80 hover:bg-white text-black px-2 py-1 rounded-full shadow"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<!-- Tombol Next -->
|
||||
<button
|
||||
v-if="detail.foto && detail.foto.length > 1"
|
||||
@click.stop="nextFoto"
|
||||
class="absolute right-2 bg-white/80 hover:bg-white text-black px-2 py-1 rounded-full shadow"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Nama Produk -->
|
||||
<h2 class="text-xl font-semibold text-center mb-3">
|
||||
<p class="text-lg font-semibold text-center mb-4">
|
||||
{{ detail.nama }}
|
||||
</h2>
|
||||
</p>
|
||||
|
||||
<!-- Detail Harga & Info -->
|
||||
<div class="grid grid-cols-2 gap-2 text-sm mb-4">
|
||||
<p>Harga Beli : Rp. {{ formatNumber(detail.harga_beli) }}</p>
|
||||
<p class="text-right">{{ detail.kadar }} K</p>
|
||||
<p>Harga Jual : Rp. {{ formatNumber(detail.harga_jual) }}</p>
|
||||
<p class="text-right">{{ detail.berat }} gram</p>
|
||||
<p class="col-span-2">
|
||||
Harga/gram : Rp. {{ formatNumber(detail.harga_per_gram) }}
|
||||
<div
|
||||
class="grid grid-cols-2 gap-y-2 gap-x-4 text-sm w-full mb-6"
|
||||
>
|
||||
<p class="col-span-1">Harga Jual :</p>
|
||||
<p class="col-span-1 text-right">
|
||||
Rp. {{ formatNumber(detail.harga_jual) }}
|
||||
</p>
|
||||
|
||||
<p class="col-span-1">Kadar :</p>
|
||||
<p class="col-span-1 text-right">{{ detail.kadar }} K</p>
|
||||
|
||||
<p class="col-span-1">Berat :</p>
|
||||
<p class="col-span-1 text-right">{{ detail.berat }} gram</p>
|
||||
|
||||
<p class="col-span-1">Harga/gram :</p>
|
||||
<p class="col-span-1 text-right">
|
||||
Rp. {{ formatNumber(detail.harga_per_gram) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Tombol Aksi -->
|
||||
<div class="flex justify-between">
|
||||
<div class="flex w-full gap-3">
|
||||
<button
|
||||
class="bg-yellow-400 text-black px-4 py-2 rounded font-bold"
|
||||
@click="$router.push(`/produk/${detail.id}/edit`)"
|
||||
class="flex-1 bg-yellow-400 text-black py-2 rounded font-bold"
|
||||
>
|
||||
Ubah
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="openItemModal"
|
||||
class="bg-green-400 text-black px-4 py-2 rounded font-bold"
|
||||
>
|
||||
Tambah
|
||||
</button>
|
||||
<button
|
||||
class="bg-red-500 text-white px-4 py-2 rounded font-bold"
|
||||
@click="deleting = true"
|
||||
class="flex-1 bg-red-500 text-white py-2 rounded font-bold"
|
||||
>
|
||||
Hapus
|
||||
</button>
|
||||
@ -103,51 +227,156 @@ import axios from "axios";
|
||||
import mainLayout from "../layouts/mainLayout.vue";
|
||||
import ProductCard from "../components/ProductCard.vue";
|
||||
import searchbar from "../components/searchbar.vue";
|
||||
import CreateItemModal from "../components/CreateItemModal.vue";
|
||||
import ConfirmDeleteModal from "../components/ConfirmDeleteModal.vue";
|
||||
import InputSelect from "../components/InputSelect.vue";
|
||||
|
||||
const products = ref([]);
|
||||
const searchQuery = ref("");
|
||||
const selectedCategory = ref(0);
|
||||
const creatingItem = ref(false);
|
||||
const deleting = ref(false);
|
||||
|
||||
// overlay state
|
||||
const showOverlay = ref(false);
|
||||
const detail = ref({});
|
||||
const showOverlay = ref(false);
|
||||
const currentFotoIndex = ref(0);
|
||||
|
||||
// Fetch data awal
|
||||
onMounted(async () => {
|
||||
const kategori = ref([]);
|
||||
const loading = ref(false); // 🔥 Loading persis kategori
|
||||
|
||||
// Load kategori
|
||||
const loadKategori = async () => {
|
||||
try {
|
||||
const res = await axios.get("http://127.0.0.1:8000/api/produk");
|
||||
products.value = res.data;
|
||||
} catch (error) {
|
||||
console.error("Gagal ambil data produk:", error);
|
||||
const response = await axios.get("/api/kategori", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||
},
|
||||
});
|
||||
if (response.data && Array.isArray(response.data)) {
|
||||
kategori.value = [
|
||||
{ value: 0, label: "Semua" },
|
||||
...response.data.map((cat) => ({
|
||||
value: cat.id,
|
||||
label: cat.nama,
|
||||
})),
|
||||
];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error loading categories:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// Load produk
|
||||
const loadProduk = async () => {
|
||||
loading.value = true; // 🔵 start loading
|
||||
try {
|
||||
const response = await axios.get(`/api/produk`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.data && Array.isArray(response.data)) {
|
||||
products.value = response.data;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error loading products:", error);
|
||||
} finally {
|
||||
loading.value = false; // 🔵 stop loading
|
||||
}
|
||||
};
|
||||
|
||||
// Modal item
|
||||
const openItemModal = () => {
|
||||
creatingItem.value = true;
|
||||
};
|
||||
const closeItemModal = () => {
|
||||
creatingItem.value = false;
|
||||
};
|
||||
|
||||
// Fetch awal
|
||||
onMounted(async () => {
|
||||
await loadKategori();
|
||||
await loadProduk();
|
||||
});
|
||||
|
||||
// Filter
|
||||
// Filter produk
|
||||
const filteredProducts = computed(() => {
|
||||
if (!searchQuery.value) return products.value;
|
||||
return products.value.filter((p) =>
|
||||
let hasil = products.value;
|
||||
|
||||
if (selectedCategory.value != 0) {
|
||||
hasil = hasil.filter((p) => p.id_kategori == selectedCategory.value);
|
||||
}
|
||||
|
||||
if (searchQuery.value) {
|
||||
hasil = hasil.filter((p) =>
|
||||
p.nama.toLowerCase().includes(searchQuery.value.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
return hasil;
|
||||
});
|
||||
|
||||
// Fungsi buka overlay
|
||||
async function openOverlay(id) {
|
||||
try {
|
||||
const res = await axios.get(`http://127.0.0.1:8000/api/produk/${id}`);
|
||||
detail.value = res.data;
|
||||
// Overlay detail
|
||||
function openOverlay(id) {
|
||||
const produk = products.value.find((p) => p.id === id);
|
||||
if (produk) {
|
||||
detail.value = produk;
|
||||
currentFotoIndex.value = 0;
|
||||
showOverlay.value = true;
|
||||
} catch (error) {
|
||||
console.error("Gagal fetch detail produk:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Fungsi tutup overlay
|
||||
function closeOverlay() {
|
||||
showOverlay.value = false;
|
||||
detail.value = {};
|
||||
currentFotoIndex.value = 0;
|
||||
}
|
||||
|
||||
// Navigasi foto
|
||||
function nextFoto() {
|
||||
if (detail.value.foto && detail.value.foto.length > 0) {
|
||||
currentFotoIndex.value =
|
||||
(currentFotoIndex.value + 1) % detail.value.foto.length;
|
||||
}
|
||||
}
|
||||
function prevFoto() {
|
||||
if (detail.value.foto && detail.value.foto.length > 0) {
|
||||
currentFotoIndex.value =
|
||||
(currentFotoIndex.value - 1 + detail.value.foto.length) %
|
||||
detail.value.foto.length;
|
||||
}
|
||||
}
|
||||
|
||||
// Format angka
|
||||
function formatNumber(num) {
|
||||
return new Intl.NumberFormat().format(num || 0);
|
||||
}
|
||||
|
||||
// Hapus produk
|
||||
async function deleteProduk() {
|
||||
try {
|
||||
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);
|
||||
deleting.value = false;
|
||||
showOverlay.value = false;
|
||||
alert("Produk berhasil dihapus!");
|
||||
} catch (err) {
|
||||
console.error("Gagal hapus produk:", err);
|
||||
alert("Gagal menghapus produk!");
|
||||
}
|
||||
}
|
||||
</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>
|
||||
|
252
resources/js/pages/Sales.vue
Normal file
252
resources/js/pages/Sales.vue
Normal file
@ -0,0 +1,252 @@
|
||||
<template>
|
||||
<mainLayout>
|
||||
<!-- Modal Create/Edit Sales -->
|
||||
<CreateSales
|
||||
v-if="creatingSales"
|
||||
:isOpen="creatingSales"
|
||||
:sales="detail"
|
||||
@close="closeSales"
|
||||
/>
|
||||
|
||||
<EditSales
|
||||
v-if="editingSales"
|
||||
:isOpen="editingSales"
|
||||
:sales="detail"
|
||||
@close="closeEditSales"
|
||||
/>
|
||||
|
||||
<!-- Modal Delete -->
|
||||
<ConfirmDeleteModal
|
||||
:isOpen="confirmDeleteOpen"
|
||||
title="Hapus Sales"
|
||||
message="Apakah Anda yakin ingin menghapus sales ini?"
|
||||
@confirm="confirmDelete"
|
||||
@cancel="closeDeleteModal"
|
||||
/>
|
||||
|
||||
<div class="p-6 min-h-[75vh]">
|
||||
<p class="font-serif italic text-[25px] text-D">SALES</p>
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
|
||||
<button
|
||||
@click="tambahSales"
|
||||
class="px-4 py-2 bg-C text-D rounded-md hover:bg-C/80 transition duration-200 flex items-center gap-2"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
Tambah Sales
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Table Section -->
|
||||
<div
|
||||
class="bg-white rounded-lg shadow-md border border-gray-200\ overflow-hidden"
|
||||
>
|
||||
<table class="w-full">
|
||||
<thead class="">
|
||||
<tr class="bg-C text-white">
|
||||
<th
|
||||
class="px-6 py-4 text-center text-D border-r border-[#b09065]"
|
||||
>
|
||||
No
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-4 text-center text-D border-r border-[#b09065]"
|
||||
>
|
||||
Nama Sales
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-4 text-center text-D border-r border-[#b09065]"
|
||||
>
|
||||
No HP
|
||||
</th>
|
||||
<th
|
||||
class="px-6 py-4 text-center text-D border-r border-[#b09065]"
|
||||
>
|
||||
Alamat
|
||||
</th>
|
||||
<th class="px-6 py-4 text-center text-D">Aksi</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="(item, index) in sales"
|
||||
:key="item.id"
|
||||
class="border-b border-gray-200\ hover:bg-gray-50 transition duration-150"
|
||||
:class="{ 'bg-gray-50': index % 2 === 1 }"
|
||||
>
|
||||
<td
|
||||
class="px-6 py-4 border-r border-gray-200\ text-center font-medium text-gray-900"
|
||||
>
|
||||
{{ index + 1 }}
|
||||
</td>
|
||||
<td
|
||||
class="px-6 py-4 border-r border-gray-200\ text-D"
|
||||
>
|
||||
{{ item.nama }}
|
||||
</td>
|
||||
<td
|
||||
class="px-6 py-4 border-r border-gray-200\ text-gray-800"
|
||||
>
|
||||
{{ item.no_hp }}
|
||||
</td>
|
||||
<td
|
||||
class="px-6 py-4 border-r border-gray-200\ text-gray-800"
|
||||
>
|
||||
{{ item.alamat }}
|
||||
</td>
|
||||
<td class="px-6 py-4 text-center">
|
||||
<div class="flex justify-center gap-2">
|
||||
<button
|
||||
@click="ubahSales(item)"
|
||||
class="px-3 py-1 bg-yellow-500 text-white text-sm rounded hover:bg-yellow-600 transition duration-200"
|
||||
>
|
||||
Ubah
|
||||
</button>
|
||||
<button
|
||||
@click="hapusSales(item)"
|
||||
class="px-3 py-1 bg-red-500 text-white text-sm rounded hover:bg-red-600 transition duration-200"
|
||||
>
|
||||
Hapus
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Empty State -->
|
||||
<tr v-if="sales.length === 0 && !loading">
|
||||
<td
|
||||
colspan="5"
|
||||
class="px-6 py-8 text-center text-gray-500"
|
||||
>
|
||||
<div class="flex flex-col items-center">
|
||||
<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 sales</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="flex justify-center items-center py-8">
|
||||
<div
|
||||
class="animate-spin rounded-full h-8 w-8 border-b-2 border-[#c6a77d]"
|
||||
></div>
|
||||
<span class="ml-2 text-gray-600">Memuat data...</span>
|
||||
</div>
|
||||
</div>
|
||||
</mainLayout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from "vue";
|
||||
import axios from "axios";
|
||||
import mainLayout from "../layouts/mainLayout.vue";
|
||||
import CreateSales from "../components/CreateSales.vue";
|
||||
import ConfirmDeleteModal from "../components/ConfirmDeleteModal.vue";
|
||||
import EditSales from "../components/EditSales.vue";
|
||||
|
||||
// State
|
||||
const sales = ref([]);
|
||||
const loading = ref(false);
|
||||
const creatingSales = ref(false);
|
||||
const detail = ref(null);
|
||||
const editingSales = ref(false);
|
||||
const confirmDeleteOpen = ref(false);
|
||||
const salesToDelete = ref(null);
|
||||
|
||||
// Fetch data dari API
|
||||
const fetchSales = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await axios.get("/api/sales", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||
},
|
||||
});
|
||||
sales.value = response.data;
|
||||
} catch (error) {
|
||||
console.error("Error fetching sales:", error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Tambah
|
||||
const tambahSales = () => {
|
||||
detail.value = null;
|
||||
creatingSales.value = true;
|
||||
};
|
||||
|
||||
// Ubah
|
||||
const ubahSales = (item) => {
|
||||
detail.value = item;
|
||||
editingSales.value = true;
|
||||
};
|
||||
|
||||
// Hapus
|
||||
const hapusSales = (item) => {
|
||||
salesToDelete.value = item;
|
||||
confirmDeleteOpen.value = true;
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
try {
|
||||
await axios.delete(`/api/sales/${salesToDelete.value.id}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||
},
|
||||
});
|
||||
fetchSales();
|
||||
confirmDeleteOpen.value = false;
|
||||
} catch (error) {
|
||||
console.error("Error deleting sales:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const closeDeleteModal = () => {
|
||||
confirmDeleteOpen.value = false;
|
||||
salesToDelete.value = null;
|
||||
};
|
||||
|
||||
// Tutup modal Create/Edit
|
||||
const closeSales = () => {
|
||||
creatingSales.value = false;
|
||||
fetchSales();
|
||||
};
|
||||
|
||||
const closeEditSales = () => {
|
||||
editingSales.value = false;
|
||||
fetchSales();
|
||||
};
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
fetchSales();
|
||||
});
|
||||
</script>
|
@ -1,16 +1,222 @@
|
||||
<template>
|
||||
<mainLayout>
|
||||
<p style="font-family: 'IM FELL Great Primer', serif; font-style: italic;font-size: 25px;">NAMPAN</p>
|
||||
<!-- Header -->
|
||||
<div class="p-6">
|
||||
<!-- Judul -->
|
||||
<p class="font-serif italic text-[25px] text-D">NAMPAN</p>
|
||||
|
||||
<!-- Searchbar -->
|
||||
<div class="flex justify-end mt-2">
|
||||
<div class="w-64">
|
||||
<searchbar v-model:search="searchQuery" />
|
||||
<TrayList :search="searchQuery" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tombol -->
|
||||
<div class="flex gap-2 mt-3 justify-end">
|
||||
<!-- Tambah Nampan -->
|
||||
<button
|
||||
@click="openModal"
|
||||
class="px-4 py-2 hover:bg-B bg-C rounded-md shadow font-semibold"
|
||||
>
|
||||
Tambah Nampan
|
||||
</button>
|
||||
|
||||
<!-- Kosongkan -->
|
||||
<button
|
||||
@click="openConfirmModal"
|
||||
class="px-3 py-2 bg-red-500 hover:bg-red-600 text-white rounded-md"
|
||||
>
|
||||
Kosongkan
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search + List -->
|
||||
|
||||
<TrayList :search="searchQuery" @edit="editTray" @delete="deleteTray" />
|
||||
|
||||
<!-- Modal Tambah/Edit Nampan -->
|
||||
<div
|
||||
v-if="showModal"
|
||||
class="fixed inset-0 bg-black/75 flex justify-center items-center z-50"
|
||||
>
|
||||
<div class="bg-white rounded-lg shadow-lg p-6 w-96">
|
||||
<h2 class="text-lg font-semibold mb-4" style="color: #102c57">
|
||||
Tambah Nampan
|
||||
</h2>
|
||||
|
||||
<label
|
||||
class="block mb-2 text-sm font-medium"
|
||||
style="color: #102c57"
|
||||
>Nama Nampan</label
|
||||
>
|
||||
<input
|
||||
v-model="trayName"
|
||||
type="text"
|
||||
placeholder="Contoh: A4"
|
||||
class="w-full border rounded-md p-2 mb-4"
|
||||
/>
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
@click="closeModal"
|
||||
class="px-4 py-2 bg-gray-400 hover:bg-gray-500 text-white rounded-md"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="saveTray"
|
||||
class="px-4 py-2 bg-[#DAC0A3] hover:bg-[#C9A77E] rounded-md"
|
||||
style="color: #102c57"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal Konfirmasi Kosongkan -->
|
||||
<div
|
||||
v-if="showConfirmModal"
|
||||
class="fixed inset-0 bg-black bg-opacity-40 flex justify-center items-center z-50"
|
||||
>
|
||||
<div class="bg-white rounded-lg shadow-lg p-6 w-96 text-center">
|
||||
<h2 class="text-xl font-bold mb-3" style="color: #102c57">
|
||||
Kosongkan semua nampan?
|
||||
</h2>
|
||||
<p class="text-gray-600 mb-6">
|
||||
Semua item akan dimasukkan ke brankas. <br />
|
||||
Masuk ke menu ‘Brankas’ untuk mengembalikan item ke nampan.
|
||||
</p>
|
||||
<div class="flex justify-center gap-4">
|
||||
<button
|
||||
@click="closeConfirmModal"
|
||||
class="px-5 py-2 bg-gray-300 hover:bg-gray-400 rounded-md font-semibold"
|
||||
>
|
||||
Batal
|
||||
</button>
|
||||
<button
|
||||
@click="confirmEmptyTray"
|
||||
class="px-5 py-2 bg-red-500 hover:bg-red-600 text-white rounded-md font-semibold"
|
||||
>
|
||||
Ya
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</mainLayout>
|
||||
|
||||
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import mainLayout from '../layouts/mainLayout.vue'
|
||||
import searchbar from '../components/searchbar.vue';
|
||||
import TrayList from '../components/TrayList.vue';
|
||||
import { ref } from "vue";
|
||||
import axios from "axios";
|
||||
import mainLayout from "../layouts/mainLayout.vue";
|
||||
import searchbar from "../components/searchbar.vue";
|
||||
import TrayList from "../components/TrayList.vue";
|
||||
|
||||
const searchQuery = ref("");
|
||||
const showModal = ref(false);
|
||||
const showConfirmModal = ref(false);
|
||||
const trayName = ref("");
|
||||
const editingTrayId = ref(null);
|
||||
|
||||
// buka modal tambah/edit
|
||||
const openModal = () => {
|
||||
showModal.value = true;
|
||||
};
|
||||
const closeModal = () => {
|
||||
trayName.value = "";
|
||||
editingTrayId.value = null;
|
||||
showModal.value = false;
|
||||
};
|
||||
|
||||
// simpan nampan
|
||||
const saveTray = async () => {
|
||||
if (!trayName.value.trim()) {
|
||||
alert("Nama Nampan tidak boleh kosong");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (editingTrayId.value) {
|
||||
await axios.put(
|
||||
`/api/nampan/${editingTrayId.value}`,
|
||||
{ nama: trayName.value },
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem(
|
||||
"token"
|
||||
)}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
alert("Nampan berhasil diupdate");
|
||||
} else {
|
||||
await axios.post(
|
||||
"/api/nampan",
|
||||
{ nama: trayName.value },
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem(
|
||||
"token"
|
||||
)}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
alert("Nampan berhasil ditambahkan");
|
||||
}
|
||||
closeModal();
|
||||
location.reload();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
alert("Gagal menyimpan nampan");
|
||||
}
|
||||
};
|
||||
|
||||
// === Konfirmasi kosongkan nampan ===
|
||||
const openConfirmModal = () => {
|
||||
showConfirmModal.value = true;
|
||||
};
|
||||
const closeConfirmModal = () => {
|
||||
showConfirmModal.value = false;
|
||||
};
|
||||
|
||||
const confirmEmptyTray = async () => {
|
||||
try {
|
||||
await axios.delete("/api/kosongkan-nampan", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||
},
|
||||
});
|
||||
alert("Semua item berhasil dipindahkan ke Brankas");
|
||||
closeConfirmModal();
|
||||
location.reload();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
alert("Gagal mengosongkan nampan");
|
||||
}
|
||||
};
|
||||
|
||||
const editTray = (tray) => {
|
||||
trayName.value = tray.nama;
|
||||
editingTrayId.value = tray.id;
|
||||
showModal.value = true;
|
||||
};
|
||||
|
||||
const deleteTray = async (id) => {
|
||||
if (!confirm("Yakin ingin menghapus nampan ini?")) return;
|
||||
try {
|
||||
await axios.delete(`/api/nampan/${id}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||
},
|
||||
});
|
||||
alert("Nampan berhasil dihapus");
|
||||
location.reload();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
alert("Gagal menghapus nampan");
|
||||
}
|
||||
};
|
||||
</script>
|
@ -1,37 +1,136 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import Home from '../pages/Home.vue'
|
||||
import Produk from '../pages/Produk.vue'
|
||||
import Brankas from '../pages/Brankas.vue'
|
||||
import Tray from '../pages/Tray.vue'
|
||||
import { createRouter, createWebHistory } from "vue-router";
|
||||
|
||||
import Produk from "../pages/Produk.vue";
|
||||
import Brankas from "../pages/Brankas.vue";
|
||||
import Tray from "../pages/Tray.vue";
|
||||
import Kasir from "../pages/Kasir.vue";
|
||||
import InputProduk from "../pages/InputProduk.vue";
|
||||
import Kategori from "../pages/Kategori.vue";
|
||||
import Sales from "../pages/Sales.vue";
|
||||
import EditProduk from "../pages/EditProduk.vue";
|
||||
import Laporan from "../pages/Laporan.vue";
|
||||
import Login from "../pages/Login.vue";
|
||||
import Akun from "../pages/Akun.vue";
|
||||
import Home from "../pages/Home.vue";
|
||||
|
||||
import auth from "../middlewares/auth";
|
||||
import guest from "../middlewares/guest";
|
||||
import owner from "../middlewares/owner";
|
||||
import StrukOverlay from "../components/StrukOverlay.vue";
|
||||
|
||||
const middlewareMap = { auth, guest, owner };
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'Home',
|
||||
path: "/",
|
||||
name: "Login",
|
||||
component: Login,
|
||||
meta: { middleware: "guest" },
|
||||
},
|
||||
{
|
||||
path: "/test",
|
||||
name: "Test",
|
||||
component: Home
|
||||
},
|
||||
{
|
||||
path: '/produk',
|
||||
name: 'Produk',
|
||||
component: Produk
|
||||
path: "/produk",
|
||||
name: "Produk",
|
||||
component: Produk,
|
||||
meta: { middleware: "auth" },
|
||||
},
|
||||
{
|
||||
path: '/brankas',
|
||||
name: 'Brankas',
|
||||
component: Brankas
|
||||
path: "/produk/baru",
|
||||
name: "ProdukBaru",
|
||||
component: InputProduk,
|
||||
meta: { middleware: ["auth", "owner"] },
|
||||
},
|
||||
{
|
||||
path: '/nampan',
|
||||
name: 'Nampan',
|
||||
component: Tray
|
||||
path: "/produk/:id/edit",
|
||||
name: "EditProduk",
|
||||
component: EditProduk,
|
||||
props: true,
|
||||
meta: { middleware: ["auth", "owner"] },
|
||||
},
|
||||
]
|
||||
|
||||
{
|
||||
path: "/brankas",
|
||||
name: "Brankas",
|
||||
component: Brankas,
|
||||
meta: { middleware: "auth" },
|
||||
},
|
||||
{
|
||||
path: "/home",
|
||||
name: "Home",
|
||||
meta: { middleware: "auth" },
|
||||
},
|
||||
{
|
||||
path: "/nampan",
|
||||
name: "Nampan",
|
||||
component: Tray,
|
||||
meta: { middleware: "auth" },
|
||||
},
|
||||
{
|
||||
path: "/sales",
|
||||
name: "Sales",
|
||||
component: Sales,
|
||||
meta: { middleware: "auth" },
|
||||
},
|
||||
{
|
||||
path: "/kategori",
|
||||
name: "Kategori",
|
||||
component: Kategori,
|
||||
meta: { middleware: "auth" },
|
||||
},
|
||||
{
|
||||
path: "/kasir",
|
||||
name: "Kasir",
|
||||
component: Kasir,
|
||||
meta: { middleware: "auth" },
|
||||
},
|
||||
{
|
||||
path: "/laporan",
|
||||
name: "Laporan",
|
||||
component: Laporan,
|
||||
meta: { middleware: ["auth", "owner"] },
|
||||
},
|
||||
{
|
||||
path: "/akun",
|
||||
name: "Akun",
|
||||
component: Akun,
|
||||
meta: { middleware: ["auth", "owner"] },
|
||||
},
|
||||
];
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes
|
||||
})
|
||||
routes,
|
||||
});
|
||||
|
||||
export default router
|
||||
router.beforeEach((to, from, next) => {
|
||||
let middlewares = to.meta.middleware;
|
||||
if (!middlewares) return next();
|
||||
|
||||
if (!Array.isArray(middlewares)) {
|
||||
middlewares = [middlewares];
|
||||
}
|
||||
|
||||
let index = 0;
|
||||
const run = () => {
|
||||
const name = middlewares[index];
|
||||
const mw = middlewareMap[name];
|
||||
if (!mw) return next();
|
||||
|
||||
mw(to, from, (redirect) => {
|
||||
if (redirect) return next(redirect);
|
||||
index++;
|
||||
if (index < middlewares.length) {
|
||||
run();
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
run();
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
@ -1,10 +1,34 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Abbauf App</title>
|
||||
</head>
|
||||
<body>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
|
||||
<title>@yield('title', config('app.name', 'Abbauf App'))</title>
|
||||
|
||||
<meta name="description" content="@yield('description', 'Deskripsi default untuk aplikasi Abbauf.')">
|
||||
<meta name="author" content="Nama Anda atau Perusahaan Anda">
|
||||
|
||||
<meta property="og:title" content="@yield('title', config('app.name', 'Abbauf App'))" />
|
||||
<meta property="og:description" content="@yield('description', 'Deskripsi default untuk aplikasi Abbauf.')" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="{{ url()->current() }}" />
|
||||
<meta property="og:image" content="@yield('og_image', asset('images/default-social-image.jpg'))" />
|
||||
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="@yield('title', config('app.name', 'Abbauf App'))">
|
||||
<meta name="twitter:description" content="@yield('description', 'Deskripsi default untuk aplikasi Abbauf.')">
|
||||
<meta name="twitter:image" content="@yield('og_image', asset('images/default-social-image.jpg'))">
|
||||
|
||||
<link rel="icon" href="{{ asset('favicon.ico') }}" type="image/x-icon">
|
||||
<link rel="apple-touch-icon" href="{{ asset('apple-touch-icon.png') }}">
|
||||
|
||||
<meta name="theme-color" content="#FFFFFF">
|
||||
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
@vite(['resources/js/app.js', 'resources/css/app.css'])
|
||||
</body>
|
||||
</html>
|
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>
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user