Compare commits

...

107 Commits

Author SHA1 Message Date
Baghaztra
e80c26ac2f [fix] harga jual 2025-09-10 16:44:24 +07:00
Baghaztra
3313ae13c8 Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-09-10 13:33:18 +07:00
Baghaztra
fc21772679 [feat] export detail per nampan dan per produk
Bug: belum bisa filter
2025-09-10 13:32:58 +07:00
timotiabbauftech
156671a21b Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-09-09 17:27:35 +07:00
timotiabbauftech
cf8f456fb4 [update] TransaksiController, Transaksi.php, Item.php 2025-09-09 17:27:29 +07:00
dhilanradya
a345dd1229 Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-09-09 14:30:54 +07:00
dhilanradya
b578faedd0 [update] ui product 2025-09-09 14:29:50 +07:00
Baghaztra
8e6aa4242b Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-09-09 11:59:03 +07:00
Baghaztra
d32e659076 [Update] Export ringkasan Laporan 2025-09-09 11:59:00 +07:00
timotiabbauftech
420cf47f20 Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-09-09 11:34:26 +07:00
timotiabbauftech
634c0683b5 [update] Kasir, KasirForm, KasirTransaksiList Responsive Mobile Desktop 2025-09-09 11:34:19 +07:00
dhilanradya
c28be3706e Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-09-09 11:24:34 +07:00
dhilanradya
b9c562d0a2 [update] revisi struk 2025-09-09 11:24:13 +07:00
adityaalfarison
7b1fdc30f6 modal, brankas, nampan 2025-09-09 11:16:56 +07:00
Baghaztra
1cd2aa60d4 [Fix] load produk 2025-09-09 09:59:29 +07:00
Baghaztra
4f880d44e4 [feat] export laporan (pdf) 2025-09-08 18:28:49 +07:00
dhilanradya
7f4b41b904 [fix] ui mobile produk 2025-09-08 17:16:05 +07:00
timotiabbauftech
c6cebf145d Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-09-08 14:43:07 +07:00
timotiabbauftech
7083d585f1 Update KasirForm.vue 2025-09-08 14:42:59 +07:00
Baghaztra
10e666a9ce Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-09-08 14:29:19 +07:00
Baghaztra
ebb17c2a43 [update] halaman laporan 2025-09-08 14:29:17 +07:00
dhilanradya
86f3e101c8 [fix] perbaikan padding dan title yang tidak serasih 2025-09-08 14:17:42 +07:00
dhilanradya
7766fd8938 Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-09-08 13:35:38 +07:00
dhilanradya
9b02e00a72 [feat] struk component 2025-09-08 13:34:56 +07:00
timotiabbauftech
ae225ce5c7 Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-09-08 11:12:26 +07:00
timotiabbauftech
6f87bde474 [update token to luis] 2025-09-08 11:12:18 +07:00
Baghaztra
20c844a98b [feat] kasir menu 2025-09-08 10:33:28 +07:00
Baghaztra
9b2d50ac65 [feat] middleware 2025-09-08 10:21:34 +07:00
Baghaztra
ae4b8a3449 [update] Tutor buat monti 2025-09-08 09:55:05 +07:00
Baghaztra
600c87d9ca Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-09-08 09:49:06 +07:00
timotiabbauftech
8e59b1f1f1 [feat RoleMiddleware, update AuthControlller, app.php, BrankasList, TrayList, Login.vue, web.php 2025-09-08 09:48:32 +07:00
Baghaztra
b1babd6c26 [update] laporan 2025-09-08 09:46:04 +07:00
timotiabbauftech
e1a0711082 Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-09-04 14:13:56 +07:00
timotiabbauftech
4afdcada62 (feat AuthControlller, Update User.php, auth.php, web.php] 2025-09-04 14:13:53 +07:00
Baghaztra
b2b34a5f76 Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-09-03 17:04:40 +07:00
Baghaztra
644d6fb222 [Feat] detail laporan 2025-09-03 17:04:37 +07:00
timotiabbauftech
2cce89b6c4 merge 2025-09-03 14:58:36 +07:00
timotiabbauftech
923f5c5c7f [feat AuthController, update UserController, User.php, EditAkun, Web.php 2025-09-03 14:55:50 +07:00
adityaalfarison
ae259cc273 Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-09-03 13:54:22 +07:00
adityaalfarison
1f8f11a7ca commit 2025-09-03 13:54:14 +07:00
Baghaztra
bb487a4c09 Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-09-03 13:53:09 +07:00
Baghaztra
fd328b6e35 [Feat] Ringkasan laporan 2025-09-03 13:53:06 +07:00
adityaalfarison
d58368389e Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-09-03 11:49:20 +07:00
adityaalfarison
bdf3a72c15 Login Page, Input password field 2025-09-03 11:49:06 +07:00
timotiabbauftech
982f99ed7b [update] 2025-09-03 11:06:58 +07:00
adityaalfarison
396baa6444 Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-09-03 09:59:57 +07:00
adityaalfarison
4755dc66fc Halaman Login 2025-09-03 09:59:31 +07:00
timotiabbauftech
26e1ee751e Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-09-02 17:14:42 +07:00
timotiabbauftech
bb7d6e7a32 [feat Akun.vue, CreateAkun, EditAkun, Update UserController, Index.js] 2025-09-02 17:14:37 +07:00
dhilanradya
26644df501 rapih rapi tray 2025-09-02 15:53:28 +07:00
timotiabbauftech
fcd7719826 Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-09-02 14:14:23 +07:00
timotiabbauftech
3174b84c0a Update CreateKategori.vue 2025-09-02 14:14:19 +07:00
timotiabbauftech
11954568ae [update EditSales,Sales,CreateSales, Kategori,EditKategori, CreateKategori] 2025-09-02 14:10:43 +07:00
Baghaztra
a99996940e [Feat] Sistem logic kasir 2025-09-02 11:44:05 +07:00
Baghaztra
3f654c6c7a [fix] bug sales 2025-09-02 10:42:01 +07:00
Baghaztra
ae5507fda4 Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-09-02 10:38:19 +07:00
Baghaztra
91b4010531 [Fix] bg delete sales 2025-09-02 10:38:16 +07:00
adityaalfarison
55213cee64 Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-09-02 09:23:27 +07:00
adityaalfarison
bfbe5d69a9 update nampancontroller, traylist,tray,web.php 2025-09-02 09:23:22 +07:00
Timoti313
d51b73c347 Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-09-01 17:07:51 +07:00
Timoti313
cca9aeaaf0 [feat Sales, CreateSales, EditSales] 2025-09-01 17:05:34 +07:00
dhilanradya
baff04f6a5 Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-09-01 17:03:32 +07:00
dhilanradya
937f24a5ff [feet] edit produk 2025-09-01 17:02:57 +07:00
Baghaztra
8ab48b4e7d [Update ] Edit warna yang tak sevariabel 2025-09-01 16:50:15 +07:00
dhilanradya
21c96c54c5 Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-09-01 14:37:31 +07:00
dhilanradya
b991551687 Update mainLayout.vue 2025-09-01 14:27:36 +07:00
Baghaztra
22e91d72b4 Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-09-01 14:18:35 +07:00
Baghaztra
5b4d7ac6f5 [Fix] Nav dropdown 2025-09-01 14:18:33 +07:00
Timoti313
394b885deb Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-09-01 13:45:17 +07:00
Timoti313
cfeae67dd2 [feat Kategori, CreateKategori, EditKategori, [Update Produk, ConfirmDeleteModal]] 2025-09-01 13:45:13 +07:00
Baghaztra
538c96e6b0 [Update] Navigation menu 2025-09-01 11:51:20 +07:00
Timoti313
e615058a51 Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-08-29 16:10:17 +07:00
Timoti313
7153d79316 [feat,update footer,header ,images, mainLayout]l 2025-08-29 16:10:02 +07:00
Baghaztra
4c4dd5d635 Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-08-29 15:20:33 +07:00
Baghaztra
2eb29d6dc9 [update] Filter kategori 2025-08-29 15:20:30 +07:00
adityaalfarison
b2f93c4537 Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-08-29 11:22:42 +07:00
adityaalfarison
8a0ded4b3e update itemcontroller, traylist DONE 2025-08-29 11:22:18 +07:00
Baghaztra
c7812ea0fb Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-08-29 11:16:31 +07:00
Baghaztra
99fe5322db Create logo.png 2025-08-29 11:16:27 +07:00
Timoti313
fd3565fd64 Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-08-29 11:09:15 +07:00
Timoti313
c8d2e10a87 [update KasirTransaksiList, TransaksiController, KasirForm`]
Error sudah teratasi, tinggal membuat tampilan struk
2025-08-29 11:09:11 +07:00
adityaalfarison
87b064850c Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-08-29 11:07:48 +07:00
adityaalfarison
d231ebe909 brankaslist, traylist 2025-08-29 11:07:46 +07:00
Baghaztra
32bab1f01a Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-08-29 11:05:51 +07:00
Baghaztra
d8348f203b [feat] backend kategori 2025-08-29 11:05:46 +07:00
dhilanradya
e96d973b03 [feet] hapus produk 2025-08-29 10:59:49 +07:00
dhilanradya
87d38dffb8 Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-08-29 09:51:59 +07:00
dhilanradya
5217e2d703 Update Produk.vue 2025-08-29 09:42:36 +07:00
Baghaztra
4e06c25082 [update] membuat item dari halaman produk 2025-08-28 23:56:30 +07:00
Baghaztra
8046360f6e [feat] create item 2025-08-28 22:45:11 +07:00
Timoti313
1a25501579 Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-08-28 16:21:19 +07:00
Timoti313
1d6bee91e1 [update Kasir, KasirForm, KasirTransaksiList]
Error Data transaksi tidak tampil
2025-08-28 16:20:58 +07:00
Baghaztra
2f72c40788 Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-08-28 15:13:21 +07:00
Baghaztra
eebabfd919 [update] Input produk 2025-08-28 15:13:19 +07:00
dhilanradya
f0f570be21 Update Produk.vue 2025-08-28 14:35:04 +07:00
Timoti313
4dd3c5188f Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-08-28 14:09:58 +07:00
Timoti313
12192c536d [feat Kasir, KasirForm, KasirTransaksiList] 2025-08-28 14:07:56 +07:00
dhilanradya
0bb5b23ead Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-08-28 13:46:16 +07:00
dhilanradya
e5f2c9920b Update index.js 2025-08-28 13:46:07 +07:00
adityaalfarison
773cc1516f Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-08-28 13:11:00 +07:00
adityaalfarison
65923ec59c update dropdown navbar, traylist,tray 2025-08-28 13:10:56 +07:00
dhilanradya
ffe0039391 Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-08-28 13:10:03 +07:00
dhilanradya
311605bd5f [update] produk + produkcard 2025-08-28 13:08:33 +07:00
Baghaztra
15917b4c52 Merge branch 'production' of https://git.abbauf.com/Magang-2025/Kasir into production 2025-08-28 13:07:38 +07:00
Baghaztra
71cd60981b [feat] halaman create produk 2025-08-28 13:07:35 +07:00
adityaalfarison
fc5541b8b0 update brankaslist,traylist,tray 2025-08-28 10:28:33 +07:00
Timoti313
7f72921758 Update package-lock.json 2025-08-28 09:29:13 +07:00
103 changed files with 10834 additions and 493 deletions

View File

@ -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
View 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

View 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;
}
}

View 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;
}
}

View 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);
}
}

View 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;
}
}

View 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'
]);
}
}

View File

@ -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);
}

View File

@ -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'

View 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);
}
}

View 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);
}
}
}

View File

@ -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);
}
}

View File

@ -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'],

View File

@ -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);

View File

@ -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);
}

View File

@ -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([
'nama' => $request->nama,
'password' => $request->password,
'role' => $request->role,
]);
$data = [
'nama' => $request->nama,
'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);

View 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);
}
}

View 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),
]);
}
}

View 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"',
];
}
}

View File

@ -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');

View File

@ -10,4 +10,6 @@ class FotoSementara extends Model
'id_user',
'url',
];
protected $hidden = ['created_at', 'updated_at', 'deleted_at'];
}

View File

@ -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');
}
}

View File

@ -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
View 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');
}
}

View File

@ -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');
}
}

View File

@ -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');
}
}

View File

@ -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');

View File

@ -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');
}
}

View File

@ -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';
}
}

View 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();
}
}

View 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']}%");
}
}
}

View File

@ -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 {
//

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -40,6 +40,11 @@ return [
'driver' => 'session',
'provider' => 'users',
],
'api' => [
'driver' => 'sanctum',
'provider' => 'users',
],
],
/*

View 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(),
];
}
}

View File

@ -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,

View File

@ -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(),
'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(),
'id_kasir' => $kasir?->id,
'id_sales' => $sales?->id,
'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' => $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();
});
}
}

View File

@ -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');
}
};

View File

@ -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');

View File

@ -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();

View File

@ -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();
});
}

View File

@ -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');
});
}
};

View File

@ -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');
});
}
};

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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",

View File

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

View File

@ -1,5 +1,5 @@
<template>
<div id="app">
<div>
<router-view />
</div>
</template>

View File

@ -1,33 +1,94 @@
<template>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
<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 cursor-pointer"
@click="openMovePopup(item)"
>
<!-- Gambar & Info Produk -->
<div class="flex items-center gap-3">
<img
v-if="item.produk.foto?.length"
:src="item.produk.foto[0].url"
class="size-12 object-contain"
/>
<div>
<p class="font-semibold">{{ item.produk.nama }}</p>
<p class="text-sm text-gray-500">ID: {{ item.produk.id }}</p>
</div>
</div>
<!-- Berat -->
<span class="font-medium">{{ item.produk.berat }}g</span>
</div>
</div>
<!-- Modal Pindah Nampan -->
<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"
v-if="isPopupVisible"
class="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50"
>
<!-- Gambar -->
<div class="flex items-center gap-3">
<img
:src="item.image"
alt="Product Image"
class="w-12 h-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>
<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>
<!-- Berat -->
<span class="font-medium">{{ item.berat }}g</span>
</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;

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@ -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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@ -1,65 +1,31 @@
<template>
<div>
<!-- Card Produk -->
<div
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="border border-C rounded-md aspect-square flex items-center justify-center hover:shadow-md transition cursor-pointer"
@click="showDetail = true"
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>
{{ product.nama }}
</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>

View 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>

View 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>

View File

@ -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>

View File

@ -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>

View File

@ -1,10 +1,19 @@
<template>
<Header />
<div class="mx-2 md:mx-4 lg:mx-6 xl:mx-7 my-6">
<slot />
<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>

View File

@ -0,0 +1,9 @@
export default function auth(to, from, next) {
const token = localStorage.getItem("token")
if (!token) {
next({ name: "Login" })
} else {
next()
}
}

View File

@ -0,0 +1,8 @@
export default function guest(to, from, next) {
const token = localStorage.getItem("token")
if (token) {
next({ name: "Brankas" })
} else {
next()
}
}

View 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
View 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>

View File

@ -1,8 +1,10 @@
<template>
<mainLayout>
<p style="font-family: 'IM FELL Great Primer', serif; font-style: italic;font-size: 25px;">BRANKAS</p>
<searchbar v-model:search="searchQuery" />
<BrankasList :search="searchQuery" />
<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>

View 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>

View File

@ -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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@ -1,100 +1,224 @@
<template>
<mainLayout>
<div class="p-6">
<!-- Judul -->
<p class="font-serif italic text-[25px] text-D">PRODUK</p>
<!-- 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>
<!-- Grid Produk -->
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4 mt-4">
<ProductCard
v-for="item in filteredProducts"
:key="item.id"
:product="item"
@showDetail="openOverlay"
<mainLayout>
<!-- Modal Buat Item -->
<CreateItemModal
:isOpen="creatingItem"
:product="detail"
@close="closeItemModal"
/>
</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"
@click.self="closeOverlay"
>
<div
class="bg-white rounded-lg shadow-lg p-6 w-[400px] border-2 border-[#e6d3b3] relative"
>
<!-- Tombol Close -->
<button
@click="closeOverlay"
class="absolute top-2 right-2 text-gray-500 hover:text-black"
<!-- 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 -->
<div class="w-full">
<searchbar
v-model:search="searchQuery"
class="searchbar-mobile"
/>
</div>
<!-- Filter + Tombol -->
<div class="flex flex-row justify-between items-center">
<!-- Filter Kategori -->
<div class="w-40 shrink-0">
<InputSelect
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"
@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/30 flex justify-center items-center"
@click.self="closeOverlay"
>
</button>
<div
class="bg-white rounded-lg shadow-lg p-6 w-[450px] border-2 border-B relative flex flex-col items-center"
>
<!-- Foto Produk -->
<div
class="relative w-72 h-72 border border-B flex items-center justify-center mb-3 overflow-hidden rounded"
>
<img
v-if="detail.foto && detail.foto.length > 0"
:src="detail.foto[currentFotoIndex].url"
:alt="detail.nama"
class="w-full h-full object-contain"
/>
<span v-else class="text-gray-400 text-sm">[gambar]</span>
<!-- Foto Produk -->
<div class="border border-[#e6d3b3] p-2 mb-4 flex justify-center">
<img
v-if="detail.gambar"
:src="`http://127.0.0.1:8000/storage/${detail.gambar}`"
:alt="detail.nama"
class="w-40 h-40 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>
<!-- 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 -->
<p class="text-lg font-semibold text-center mb-4">
{{ detail.nama }}
</p>
<!-- Detail Harga & Info -->
<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 w-full gap-3">
<button
@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
@click="deleting = true"
class="flex-1 bg-red-500 text-white py-2 rounded font-bold"
>
Hapus
</button>
</div>
</div>
</div>
<!-- Stok -->
<p class="text-sm mb-1">{{ detail.item_count }} pcs</p>
<!-- Nama Produk -->
<h2 class="text-xl font-semibold text-center mb-3">
{{ detail.nama }}
</h2>
<!-- 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) }}
</p>
</div>
<!-- Tombol Aksi -->
<div class="flex justify-between">
<button
class="bg-yellow-400 text-black px-4 py-2 rounded font-bold"
>
Ubah
</button>
<button
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"
>
Hapus
</button>
</div>
</div>
</div>
</mainLayout>
</mainLayout>
</template>
<script setup>
@ -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
const kategori = ref([]);
const loading = ref(false); // 🔥 Loading persis kategori
// Load kategori
const loadKategori = async () => {
try {
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 () => {
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);
}
await loadKategori();
await loadProduk();
});
// Filter
// Filter produk
const filteredProducts = computed(() => {
if (!searchQuery.value) return products.value;
return products.value.filter((p) =>
p.nama.toLowerCase().includes(searchQuery.value.toLowerCase())
);
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;
showOverlay.value = true;
} catch (error) {
console.error("Gagal fetch detail produk:", error);
}
// 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;
}
}
function closeOverlay() {
showOverlay.value = false;
currentFotoIndex.value = 0;
}
// Fungsi tutup overlay
function closeOverlay() {
showOverlay.value = false;
detail.value = {};
// 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);
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>

View 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>

View File

@ -1,16 +1,222 @@
<template>
<mainLayout>
<p style="font-family: 'IM FELL Great Primer', serif; font-style: italic;font-size: 25px;">NAMPAN</p>
<searchbar v-model:search="searchQuery" />
<TrayList :search="searchQuery" />
<!-- 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" />
</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>

View File

@ -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',
component: Home
},
{
path: '/produk',
name: 'Produk',
component: Produk
},
{
path: '/brankas',
name: 'Brankas',
component: Brankas
},
{
path: '/nampan',
name: 'Nampan',
component: Tray
},
]
{
path: "/",
name: "Login",
component: Login,
meta: { middleware: "guest" },
},
{
path: "/test",
name: "Test",
component: Home
},
{
path: "/produk",
name: "Produk",
component: Produk,
meta: { middleware: "auth" },
},
{
path: "/produk/baru",
name: "ProdukBaru",
component: InputProduk,
meta: { middleware: ["auth", "owner"] },
},
{
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
})
history: createWebHistory(),
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;

View File

@ -1,10 +1,34 @@
<!DOCTYPE html>
<html>
<head>
<title>Abbauf App</title>
</head>
<body>
<div id="app"></div>
</body>
@vite(['resources/js/app.js', 'resources/css/app.css'])
<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>
</html>

View 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>

View 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