Compare commits
205 Commits
36c993cedc
...
a3e68b8cd0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3e68b8cd0 | ||
|
|
6e98ca20e4 | ||
|
|
d4ecc7f6c1 | ||
|
|
fdb3ac15c6 | ||
|
|
9cb8155a35 | ||
|
|
f3f8b7fe04 | ||
|
|
e84a4bdadb | ||
|
|
3052aacb45 | ||
|
|
5999ff6359 | ||
|
|
6f7a4df667 | ||
|
|
9323eb2700 | ||
|
|
9c00f3db7d | ||
|
|
2c87edef82 | ||
|
|
b472c7091c | ||
|
|
dac6f59018 | ||
|
|
be38f618a0 | ||
|
|
13e998eb77 | ||
|
|
fcf4473aa3 | ||
|
|
6d1cba0f2a | ||
|
|
ec9898dd73 | ||
|
|
fc71b974a6 | ||
|
|
89cc69d789 | ||
|
|
604148e28e | ||
|
|
e0431dfeac | ||
|
|
f01b2ba34a | ||
|
|
88d14def41 | ||
|
|
982b14e46a | ||
|
|
e80a68fbb7 | ||
|
|
7ef8f79b6c | ||
|
|
f71fabdc90 | ||
|
|
bd07755a0f | ||
|
|
e4e98e3f98 | ||
|
|
97192bb05a | ||
|
|
edf833e113 | ||
|
|
8ed0ea26cf | ||
|
|
88207ea1fc | ||
|
|
6246800b0c | ||
|
|
d9939d4dab | ||
|
|
43a9c3a8df | ||
|
|
b6c3723fa7 | ||
|
|
e584b8e8f8 | ||
|
|
d8a8622cb5 | ||
|
|
3bdf9001c4 | ||
|
|
f4e0392331 | ||
|
|
14c94f86e7 | ||
|
|
492cc651f9 | ||
|
|
30087c2b7f | ||
|
|
ce1cd280b9 | ||
|
|
d89f433d91 | ||
|
|
0db65d190f | ||
|
|
70c15edc27 | ||
|
|
795edadfe3 | ||
|
|
8674ec9828 | ||
|
|
85138291b5 | ||
|
|
3d25ff5b13 | ||
|
|
428fedb944 | ||
|
|
4ecca7d2c6 | ||
|
|
aa64332418 | ||
|
|
6adc5c0f98 | ||
|
|
1834441d78 | ||
|
|
197cb7e628 | ||
|
|
f252e53fc3 | ||
|
|
bfed0fbd2b | ||
|
|
e60498d74e | ||
|
|
6204acc6aa | ||
|
|
c65f0a857b | ||
|
|
b028cf25b2 | ||
|
|
29a1ebf713 | ||
|
|
6b9ec0515a | ||
|
|
286b270bd6 | ||
|
|
b78a396a51 | ||
|
|
8e59f8d634 | ||
|
|
e67fb10e37 | ||
|
|
75648b52d9 | ||
|
|
4223cad92f | ||
|
|
380ede5ca8 | ||
|
|
37ad328c5c | ||
|
|
f5d0441cd7 | ||
|
|
47a988d078 | ||
|
|
eaa3318506 | ||
|
|
03d01a1b78 | ||
|
|
e1205cf146 | ||
|
|
be90b771ba | ||
|
|
17892603e8 | ||
|
|
e454ef2911 | ||
|
|
02fdad7ff5 | ||
|
|
3e96a158e5 | ||
|
|
679ffc504c | ||
|
|
e33805b18e | ||
|
|
dbd4d46048 | ||
|
|
a1a665bebf | ||
|
|
e04c19c5eb | ||
|
|
c1197259e6 | ||
|
|
251a661032 | ||
|
|
4525444505 | ||
|
|
758f404b0f | ||
|
|
876c5301b3 | ||
|
|
73134f3fd6 | ||
|
|
e80c26ac2f | ||
|
|
3313ae13c8 | ||
|
|
fc21772679 | ||
|
|
156671a21b | ||
|
|
cf8f456fb4 | ||
|
|
a345dd1229 | ||
|
|
b578faedd0 | ||
|
|
8e6aa4242b | ||
|
|
d32e659076 | ||
|
|
420cf47f20 | ||
|
|
634c0683b5 | ||
|
|
c28be3706e | ||
|
|
b9c562d0a2 | ||
|
|
7b1fdc30f6 | ||
|
|
1cd2aa60d4 | ||
|
|
4f880d44e4 | ||
|
|
7f4b41b904 | ||
|
|
c6cebf145d | ||
|
|
7083d585f1 | ||
|
|
10e666a9ce | ||
|
|
ebb17c2a43 | ||
|
|
86f3e101c8 | ||
|
|
7766fd8938 | ||
|
|
9b02e00a72 | ||
|
|
ae225ce5c7 | ||
|
|
6f87bde474 | ||
|
|
20c844a98b | ||
|
|
9b2d50ac65 | ||
|
|
ae4b8a3449 | ||
|
|
600c87d9ca | ||
|
|
8e59b1f1f1 | ||
|
|
b1babd6c26 | ||
|
|
e1a0711082 | ||
|
|
4afdcada62 | ||
|
|
b2b34a5f76 | ||
|
|
644d6fb222 | ||
|
|
2cce89b6c4 | ||
|
|
923f5c5c7f | ||
|
|
ae259cc273 | ||
|
|
1f8f11a7ca | ||
|
|
bb487a4c09 | ||
|
|
fd328b6e35 | ||
|
|
d58368389e | ||
|
|
bdf3a72c15 | ||
|
|
982f99ed7b | ||
|
|
396baa6444 | ||
|
|
4755dc66fc | ||
|
|
26e1ee751e | ||
|
|
bb7d6e7a32 | ||
|
|
26644df501 | ||
|
|
fcd7719826 | ||
|
|
3174b84c0a | ||
|
|
11954568ae | ||
|
|
a99996940e | ||
|
|
3f654c6c7a | ||
|
|
ae5507fda4 | ||
|
|
91b4010531 | ||
|
|
55213cee64 | ||
|
|
bfbe5d69a9 | ||
|
|
d51b73c347 | ||
|
|
cca9aeaaf0 | ||
|
|
baff04f6a5 | ||
|
|
937f24a5ff | ||
|
|
8ab48b4e7d | ||
|
|
21c96c54c5 | ||
|
|
b991551687 | ||
|
|
22e91d72b4 | ||
|
|
5b4d7ac6f5 | ||
|
|
394b885deb | ||
|
|
cfeae67dd2 | ||
|
|
538c96e6b0 | ||
|
|
e615058a51 | ||
|
|
7153d79316 | ||
|
|
4c4dd5d635 | ||
|
|
2eb29d6dc9 | ||
|
|
b2f93c4537 | ||
|
|
8a0ded4b3e | ||
|
|
c7812ea0fb | ||
|
|
99fe5322db | ||
|
|
fd3565fd64 | ||
|
|
c8d2e10a87 | ||
|
|
87b064850c | ||
|
|
d231ebe909 | ||
|
|
32bab1f01a | ||
|
|
d8348f203b | ||
|
|
e96d973b03 | ||
|
|
87d38dffb8 | ||
|
|
5217e2d703 | ||
|
|
4e06c25082 | ||
|
|
8046360f6e | ||
|
|
1a25501579 | ||
|
|
1d6bee91e1 | ||
|
|
2f72c40788 | ||
|
|
eebabfd919 | ||
|
|
f0f570be21 | ||
|
|
4dd3c5188f | ||
|
|
12192c536d | ||
|
|
0bb5b23ead | ||
|
|
e5f2c9920b | ||
|
|
773cc1516f | ||
|
|
65923ec59c | ||
|
|
ffe0039391 | ||
|
|
311605bd5f | ||
|
|
15917b4c52 | ||
|
|
71cd60981b | ||
|
|
fc5541b8b0 | ||
|
|
7f72921758 |
10
.dockerignore
Normal file
@ -0,0 +1,10 @@
|
||||
node_modules
|
||||
vendor
|
||||
.env
|
||||
Dockerfile
|
||||
docker-compose.yml
|
||||
.git
|
||||
.gitignore
|
||||
tests
|
||||
*.log
|
||||
storage/logs/*
|
||||
@ -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
|
||||
|
||||
34
Dockerfile
Normal file
@ -0,0 +1,34 @@
|
||||
# Stage 1: Build Vue (tetap sama)
|
||||
FROM node:20 as node_builder
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Laravel
|
||||
FROM php:8.3-fpm
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
git unzip libzip-dev libpng-dev libonig-dev libxml2-dev curl \
|
||||
&& docker-php-ext-install pdo_mysql zip gd mbstring exif pcntl bcmath \
|
||||
&& apt-get clean && rm -rf /var/lib/apt/lists/* # Cleanup untuk ukuran kecil
|
||||
|
||||
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
||||
|
||||
WORKDIR /var/www/html
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
# Copy hasil build Vue
|
||||
COPY --from=node_builder /app/public/build /var/www/html/public/build
|
||||
|
||||
RUN composer install --no-dev --optimize-autoloader
|
||||
RUN php artisan config:cache && php artisan route:cache && php artisan view:cache # Optimasi cache untuk performa laporan/transaksi
|
||||
|
||||
# Set permission dan user non-root
|
||||
RUN chown -R www-data:www-data /var/www/html
|
||||
USER www-data
|
||||
|
||||
EXPOSE 9000
|
||||
CMD ["php-fpm"]
|
||||
172
Documentation/Laporan.md
Normal file
@ -0,0 +1,172 @@
|
||||
# Dokumentasi Refactoring LaporanController
|
||||
|
||||
## 📋 Ringkasan Refactoring
|
||||
|
||||
File `LaporanController` yang awalnya berukuran **~600 baris** telah dipecah menjadi **6 file** yang lebih terorganisir dan mudah dipelihara:
|
||||
|
||||
1. **LaporanController** - Controller utama yang ramping
|
||||
2. **LaporanService** - Business logic layer
|
||||
3. **TransaksiRepository** - Data access layer
|
||||
4. **LaporanHelper** - Utility functions
|
||||
5. **DetailLaporanRequest** - Validation untuk detail laporan
|
||||
6. **ExportLaporanRequest** - Validation untuk export
|
||||
|
||||
## 🏗️ Struktur Baru
|
||||
|
||||
### 1. LaporanController (~80 baris)
|
||||
|
||||
- **Tanggung jawab**: Menangani HTTP requests dan responses
|
||||
- **Fitur**: Error handling, logging, delegasi ke service layer
|
||||
- **Prinsip**: Single Responsibility - hanya menangani concerns HTTP
|
||||
|
||||
### 2. LaporanService (~180 baris)
|
||||
|
||||
- **Tanggung jawab**: Business logic dan orchestration
|
||||
- **Fitur**:
|
||||
- Caching logic
|
||||
- Data processing coordination
|
||||
- Export functionality
|
||||
- Input validation bisnis
|
||||
- **Prinsip**: Service layer yang mengkoordinasi antara repository dan helper
|
||||
|
||||
### 3. TransaksiRepository (~120 baris)
|
||||
|
||||
- **Tanggung jawab**: Data access dan query operations
|
||||
- **Fitur**:
|
||||
- Complex database queries
|
||||
- Data aggregation
|
||||
- Pagination logic untuk laporan
|
||||
- **Prinsip**: Repository pattern untuk data abstraction
|
||||
|
||||
### 4. LaporanHelper (~180 baris)
|
||||
|
||||
- **Tanggung jawab**: Utility functions dan data formatting
|
||||
- **Fitur**:
|
||||
- Data formatting (currency, weight)
|
||||
- Data mapping dan transformation
|
||||
- Pagination info building
|
||||
- Filter info building
|
||||
- **Prinsip**: Helper class untuk fungsi-fungsi utility yang reusable
|
||||
|
||||
### 5. DetailLaporanRequest (~60 baris)
|
||||
|
||||
- **Tanggung jawab**: Validation rules untuk detail laporan
|
||||
- **Fitur**:
|
||||
- Input validation
|
||||
- Custom error messages
|
||||
- Data preparation
|
||||
- **Prinsip**: Form Request untuk clean validation
|
||||
|
||||
### 6. ExportLaporanRequest (~40 baris)
|
||||
|
||||
- **Tanggung jawab**: Validation rules untuk export
|
||||
- **Fitur**:
|
||||
- Export format validation
|
||||
- Filter validation
|
||||
- **Prinsip**: Separated concerns untuk different validation needs
|
||||
|
||||
## 🎯 Keuntungan Refactoring
|
||||
|
||||
### ✅ Maintainability
|
||||
|
||||
- **Sebelum**: 1 file besar (~600 baris) sulit untuk debug dan modify
|
||||
- **Sesudah**: 6 file kecil dengan tanggung jawab yang jelas
|
||||
|
||||
### ✅ Testability
|
||||
|
||||
- **Sebelum**: Sulit untuk unit test karena semua logic tercampur
|
||||
- **Sesudah**: Setiap layer dapat di-test secara terpisah
|
||||
- Service layer dapat di-mock
|
||||
- Repository dapat di-test dengan database
|
||||
- Helper functions dapat di-unit test
|
||||
|
||||
### ✅ Reusability
|
||||
|
||||
- **LaporanHelper** dapat digunakan di controller/service lain
|
||||
- **TransaksiRepository** dapat digunakan untuk keperluan transaksi lain
|
||||
- **Form Requests** dapat digunakan di route lain
|
||||
|
||||
### ✅ SOLID Principles
|
||||
|
||||
- **S** - Single Responsibility: Setiap class punya satu tanggung jawab
|
||||
- **O** - Open/Closed: Mudah untuk extend tanpa modify existing code
|
||||
- **L** - Liskov Substitution: Repository dapat di-substitute dengan implementasi lain
|
||||
- **I** - Interface Segregation: Dependencies yang spesifik
|
||||
- **D** - Dependency Inversion: Controller depend pada abstraction (Service), bukan concrete class
|
||||
|
||||
### ✅ Performance
|
||||
|
||||
- Caching logic tetap terjaga di Service layer
|
||||
- Query optimization tetap di Repository layer
|
||||
- No performance degradation dari refactoring
|
||||
|
||||
## 🔧 Cara Implementasi
|
||||
|
||||
### 1. Buat file-file baru:
|
||||
|
||||
```
|
||||
app/
|
||||
├── Http/
|
||||
│ ├── Controllers/
|
||||
│ │ └── LaporanController.php
|
||||
│ └── Requests/
|
||||
│ ├── DetailLaporanRequest.php
|
||||
│ └── ExportLaporanRequest.php
|
||||
├── Services/
|
||||
│ └── LaporanService.php
|
||||
├── Repositories/
|
||||
│ └── TransaksiRepository.php
|
||||
└── Helpers/
|
||||
└── LaporanHelper.php
|
||||
```
|
||||
|
||||
### 2. Register dependencies di Service Provider:
|
||||
|
||||
```php
|
||||
// AppServiceProvider.php
|
||||
public function register()
|
||||
{
|
||||
$this->app->bind(TransaksiRepository::class, TransaksiRepository::class);
|
||||
$this->app->bind(LaporanHelper::class, LaporanHelper::class);
|
||||
$this->app->bind(LaporanService::class, LaporanService::class);
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Update routes (tidak ada perubahan):
|
||||
|
||||
```php
|
||||
// Routes tetap sama, hanya implementasi internal yang berubah
|
||||
Route::get('/laporan/ringkasan', [LaporanController::class, 'ringkasan']);
|
||||
Route::get('/laporan/detail-per-produk', [LaporanController::class, 'detailPerProduk']);
|
||||
Route::get('/laporan/detail-per-nampan', [LaporanController::class, 'detailPerNampan']);
|
||||
Route::post('/laporan/export', [LaporanController::class, 'exportRingkasan']);
|
||||
```
|
||||
|
||||
## 📊 Perbandingan Ukuran File
|
||||
|
||||
| File Original | Baris | File Baru | Baris | Pengurangan |
|
||||
| --------------------- | ------- | ------------------------ | ------- | ----------- |
|
||||
| LaporanController.php | ~600 | LaporanController.php | ~80 | 87% |
|
||||
| | | LaporanService.php | ~180 | |
|
||||
| | | TransaksiRepository.php | ~120 | |
|
||||
| | | LaporanHelper.php | ~180 | |
|
||||
| | | DetailLaporanRequest.php | ~60 | |
|
||||
| | | ExportLaporanRequest.php | ~40 | |
|
||||
| **Total** | **600** | **Total** | **660** | **+60** |
|
||||
|
||||
_Note: Sedikit penambahan baris karena struktur class yang lebih terorganisir dan dokumentasi yang lebih baik_
|
||||
|
||||
## 🚀 Langkah Selanjutnya (Optional)
|
||||
|
||||
1. **Interface Implementation**: Buat interface untuk Service dan Repository
|
||||
2. **Unit Tests**: Tambahkan comprehensive unit tests untuk setiap layer
|
||||
3. **API Documentation**: Update API documentation
|
||||
4. **Caching Strategy**: Implement more sophisticated caching dengan Redis
|
||||
5. **Query Optimization**: Review dan optimize database queries di Repository
|
||||
|
||||
## ⚠️ Catatan Penting
|
||||
|
||||
- **Backward Compatibility**: API endpoints dan response format tetap sama
|
||||
- **Dependencies**: Pastikan semua dependencies di-register di Service Provider
|
||||
- **Testing**: Lakukan thorough testing sebelum deploy ke production
|
||||
- **Migration**: Bisa dilakukan secara bertahap jika diperlukan
|
||||
@ -276,6 +276,13 @@ php artisan backup:run
|
||||
php artisan make:model ProductCategory -m
|
||||
```
|
||||
|
||||
### Production
|
||||
|
||||
> Pastikan `.env.production` sudah ada.
|
||||
|
||||
```bash
|
||||
docker compose --env-file .env.production up --build -d
|
||||
```
|
||||
---
|
||||
|
||||
## 📄 License
|
||||
|
||||
88
app/Exports/DetailNampanExport.php
Normal file
@ -0,0 +1,88 @@
|
||||
<?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]],
|
||||
];
|
||||
|
||||
if (isset($this->data['rekap_harian'])) {
|
||||
$lastRow = 1;
|
||||
if (isset($this->data['nampan'])) {
|
||||
$lastRow += count($this->data['nampan']);
|
||||
}
|
||||
$lastRow++;
|
||||
|
||||
$styles[$lastRow] = [
|
||||
'font' => ['bold' => true],
|
||||
'fill' => [
|
||||
'fillType' => \PhpOffice\PhpSpreadsheet\Style\Fill::FILL_SOLID,
|
||||
'startColor' => ['argb' => 'FFE2E3E5'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
return $styles;
|
||||
}
|
||||
}
|
||||
94
app/Exports/DetailProdukExport.php
Normal file
@ -0,0 +1,94 @@
|
||||
<?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]],
|
||||
];
|
||||
|
||||
if (isset($this->data['rekap_harian'])) {
|
||||
$lastRow = 1;
|
||||
if (isset($this->data['nampan'])) {
|
||||
$lastRow += count($this->data['nampan']);
|
||||
}
|
||||
$lastRow++;
|
||||
|
||||
$styles[$lastRow] = [
|
||||
'font' => ['bold' => true],
|
||||
'fill' => [
|
||||
'fillType' => \PhpOffice\PhpSpreadsheet\Style\Fill::FILL_SOLID,
|
||||
'startColor' => ['argb' => 'FFE2E3E5'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
return $styles;
|
||||
}
|
||||
}
|
||||
73
app/Exports/RingkasanExport.php
Normal file
@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exports;
|
||||
|
||||
use Illuminate\Support\Collection;
|
||||
use Maatwebsite\Excel\Concerns\FromCollection;
|
||||
use Maatwebsite\Excel\Concerns\WithHeadings;
|
||||
use Maatwebsite\Excel\Concerns\WithStyles;
|
||||
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
||||
|
||||
class RingkasanExport implements FromCollection, WithHeadings, WithStyles
|
||||
{
|
||||
private $data;
|
||||
|
||||
public function __construct(iterable $data)
|
||||
{
|
||||
$this->data = $data;
|
||||
}
|
||||
|
||||
public function collection(): Collection
|
||||
{
|
||||
$rows = collect();
|
||||
|
||||
foreach ($this->data as $item) {
|
||||
$tanggal = $item['tanggal'] ?? '-';
|
||||
$totalItem = $item['total_item'] ?? 0;
|
||||
$totalBerat = $item['total_berat'] ?? '0 g';
|
||||
$totalPendapatan = $item['total_pendapatan'] ?? 'Rp 0';
|
||||
|
||||
// Tambahkan detail sales per baris
|
||||
foreach ($item['sales'] ?? [] as $sale) {
|
||||
$rows->push([
|
||||
'Tanggal' => $tanggal,
|
||||
'Nama Sales' => $sale['nama'] ?? 'Sales Tidak Dikenal',
|
||||
'Item Terjual' => $sale['item_terjual'] ?? 0,
|
||||
'Berat' => $sale['berat'] ?? '-',
|
||||
'Pendapatan' => $sale['pendapatan'] ?? '-',
|
||||
]);
|
||||
}
|
||||
|
||||
// Tambahkan baris total
|
||||
$rows->push([
|
||||
'Tanggal' => $tanggal,
|
||||
'Nama Sales' => 'TOTAL',
|
||||
'Item Terjual' => $totalItem,
|
||||
'Berat' => $totalBerat,
|
||||
'Pendapatan' => $totalPendapatan,
|
||||
]);
|
||||
|
||||
$rows->push(['Tanggal' => '', 'Nama Sales' => '', 'Item Terjual' => '', 'Berat' => '', 'Pendapatan' => '']);
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
public function headings(): array
|
||||
{
|
||||
return [
|
||||
'Tanggal',
|
||||
'Nama Sales',
|
||||
'Item Terjual',
|
||||
'Berat',
|
||||
'Pendapatan',
|
||||
];
|
||||
}
|
||||
|
||||
public function styles(Worksheet $sheet)
|
||||
{
|
||||
return [
|
||||
1 => ['font' => ['bold' => true]],
|
||||
];
|
||||
}
|
||||
}
|
||||
212
app/Helpers/LaporanHelper.php
Normal file
@ -0,0 +1,212 @@
|
||||
<?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;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class LaporanHelper
|
||||
{
|
||||
public const CURRENCY_SYMBOL = 'Rp ';
|
||||
public const WEIGHT_UNIT = ' g';
|
||||
public const DEFAULT_DISPLAY = '-';
|
||||
|
||||
public function calculateTotals(Collection $data): array
|
||||
{
|
||||
// Asumsi $data punya raw numeric (int/float)
|
||||
$totalPendapatan = $data->sum('pendapatan'); // Raw float
|
||||
$totalItemTerjual = $data->sum('jumlah_item_terjual'); // Int
|
||||
$totalBeratTerjual = $data->sum('berat_terjual'); // Float
|
||||
|
||||
return [
|
||||
'total_item_terjual' => $totalItemTerjual,
|
||||
'total_berat_terjual' => $this->formatWeight($totalBeratTerjual),
|
||||
'total_pendapatan' => $this->formatCurrency($totalPendapatan),
|
||||
];
|
||||
}
|
||||
|
||||
public function getAllNampanWithPagination(int $page, int $perPage): LengthAwarePaginator
|
||||
{
|
||||
$semuaPosisi = DB::table('item_transaksis')
|
||||
->select('posisi_asal')
|
||||
->distinct()
|
||||
->pluck('posisi_asal')
|
||||
->sort()
|
||||
->values();
|
||||
|
||||
$offset = ($page - 1) * $perPage;
|
||||
$itemsForCurrentPage = $semuaPosisi->slice($offset, $perPage);
|
||||
|
||||
return new LengthAwarePaginator(
|
||||
$itemsForCurrentPage,
|
||||
$semuaPosisi->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, // Selalu int
|
||||
'berat_terjual' => $this->formatWeight($dataTerjual->berat_terjual),
|
||||
'pendapatan' => $this->formatCurrency($dataTerjual->pendapatan),
|
||||
];
|
||||
}
|
||||
|
||||
return [ // Untuk kosong, return dengan 0 (akan difilter nanti)
|
||||
'nama_produk' => $item->nama,
|
||||
'jumlah_item_terjual' => 0,
|
||||
'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)) {
|
||||
$dataTerjual = $salesData->get($item);
|
||||
return [
|
||||
'nama_nampan' => $item, // sekarang langsung string posisi
|
||||
'jumlah_item_terjual' => (int) $dataTerjual->jumlah_item_terjual,
|
||||
'berat_terjual' => $this->formatWeight($dataTerjual->berat_terjual),
|
||||
'pendapatan' => $this->formatCurrency($dataTerjual->pendapatan),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'nama_nampan' => $item,
|
||||
'jumlah_item_terjual' => self::DEFAULT_DISPLAY,
|
||||
'berat_terjual' => self::DEFAULT_DISPLAY,
|
||||
'pendapatan' => self::DEFAULT_DISPLAY,
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
public function buildProdukFilterInfo(Carbon $startDate, Carbon $endDate, array $params): array
|
||||
{
|
||||
$filterInfo = [
|
||||
'periode' => "{$startDate->isoFormat('D MMMM Y')} - {$endDate->isoFormat('D MMMM Y')}",
|
||||
'nama_sales' => null,
|
||||
'nampan' => null, // Default 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'] === -1) {
|
||||
$filterInfo['nampan'] = 'Brankas';
|
||||
} elseif ($params['nampan_id'] > 0) {
|
||||
$nampan = Nampan::find($params['nampan_id']);
|
||||
$filterInfo['nampan'] = $nampan?->nama;
|
||||
} else { // 0: Semua
|
||||
$filterInfo['nampan'] = 'Semua Nampan';
|
||||
}
|
||||
}
|
||||
|
||||
return $filterInfo;
|
||||
}
|
||||
|
||||
public function buildNampanFilterInfo(Carbon $startDate, Carbon $endDate, array $params): array
|
||||
{
|
||||
$filterInfo = [
|
||||
'periode' => "{$startDate->isoFormat('D MMMM Y')} - {$endDate->isoFormat('D MMMM Y')}", // FIXED: Range
|
||||
'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());
|
||||
|
||||
// UBAH BAGIAN INI: Hapus ->item dari path relasi
|
||||
$beratTerjual = $transaksisPerSales->sum(
|
||||
fn($t) => $t->itemTransaksi->sum(fn($it) => $it->produk?->berat ?? 0)
|
||||
);
|
||||
|
||||
$pendapatan = $transaksisPerSales->sum('total_harga');
|
||||
|
||||
return [
|
||||
'nama' => $transaksisPerSales->first()->nama_sales,
|
||||
'item_terjual' => $itemTerjual,
|
||||
'berat_terjual_raw' => $beratTerjual,
|
||||
'pendapatan_raw' => $pendapatan,
|
||||
];
|
||||
}
|
||||
|
||||
public function defaultSalesData(string $namaSales): array
|
||||
{
|
||||
return [
|
||||
'nama' => $namaSales,
|
||||
'item_terjual' => 0,
|
||||
'berat_terjual_raw' => 0,
|
||||
'pendapatan_raw' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
public function formatSalesDataValues(Collection $salesData): Collection
|
||||
{
|
||||
return $salesData->map(function ($sale) {
|
||||
$sale['item_terjual'] = $sale['item_terjual'] > 0 ? $sale['item_terjual'] : self::DEFAULT_DISPLAY;
|
||||
$sale['berat_terjual'] = $sale['berat_terjual_raw'] > 0 ?
|
||||
$this->formatWeight($sale['berat_terjual_raw']) : self::DEFAULT_DISPLAY;
|
||||
$sale['pendapatan'] = $sale['pendapatan_raw'] > 0 ?
|
||||
$this->formatCurrency($sale['pendapatan_raw']) : self::DEFAULT_DISPLAY;
|
||||
|
||||
unset($sale['berat_terjual_raw'], $sale['pendapatan_raw']);
|
||||
return $sale;
|
||||
});
|
||||
}
|
||||
|
||||
public function formatCurrency(float $amount): string
|
||||
{
|
||||
return self::CURRENCY_SYMBOL . number_format($amount, 2, ',', '.');
|
||||
}
|
||||
|
||||
public function formatWeight(float $weight): string
|
||||
{
|
||||
return number_format($weight, 2, ',', '.') . self::WEIGHT_UNIT;
|
||||
}
|
||||
}
|
||||
51
app/Http/Controllers/AuthController.php
Normal file
@ -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'
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -10,8 +10,12 @@ class FotoSementaraController extends Controller
|
||||
{
|
||||
public function upload(Request $request)
|
||||
{
|
||||
$user = $request->user();
|
||||
if (!$user) {
|
||||
return response()->json(['message' => 'Unauthorized'], 401);
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'id_produk' => 'required|exists:produk,id',
|
||||
'foto' => 'required|image|mimes:jpg,jpeg,png|max:2048',
|
||||
]);
|
||||
|
||||
@ -19,15 +23,20 @@ class FotoSementaraController extends Controller
|
||||
$url = asset('storage/' . $path);
|
||||
|
||||
$foto = FotoSementara::create([
|
||||
'id_produk' => $request->id_produk,
|
||||
'id_user' => $user->id,
|
||||
'url' => $url,
|
||||
]);
|
||||
|
||||
return response()->json(['message' => 'Foto berhasil disimpan'], 201);
|
||||
return response()->json($foto, 201);
|
||||
}
|
||||
|
||||
public function hapus($id)
|
||||
public function hapus(Request $request, int $id)
|
||||
{
|
||||
$user = $request->user();
|
||||
if (!$user) {
|
||||
return response()->json(['message' => 'Unauthorized'], 401);
|
||||
}
|
||||
|
||||
$foto = FotoSementara::findOrFail($id);
|
||||
|
||||
// Extract the relative path from the URL
|
||||
@ -42,15 +51,25 @@ class FotoSementaraController extends Controller
|
||||
return response()->json(['message' => 'Foto berhasil dihapus']);
|
||||
}
|
||||
|
||||
public function getAll($user_id)
|
||||
public function getAll(Request $request)
|
||||
{
|
||||
$data = FotoSementara::where('id_user', $user_id);
|
||||
$user = $request->user();
|
||||
if (!$user) {
|
||||
return response()->json(['message' => 'Unauthorized'], 401);
|
||||
}
|
||||
|
||||
$data = FotoSementara::where('id_user', $user->id)->get();
|
||||
return response()->json($data);
|
||||
}
|
||||
|
||||
public function reset($user_id)
|
||||
public function reset(Request $request)
|
||||
{
|
||||
FotoSementara::where('id_user', $user_id)->delete();
|
||||
$user = $request->user();
|
||||
if (!$user) {
|
||||
return response()->json(['message' => 'Unauthorized'], 401);
|
||||
}
|
||||
|
||||
FotoSementara::where('id_user', $user->id)->delete();
|
||||
return response()->json(['message' => 'Foto sementara berhasil direset']);
|
||||
}
|
||||
}
|
||||
|
||||
@ -23,15 +23,16 @@ 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);
|
||||
|
||||
$item->load('nampan');
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Item berhasil dibuat',
|
||||
'data' => $item
|
||||
@ -53,8 +54,8 @@ class ItemController extends Controller
|
||||
public function update(Request $request, int $id)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'id_produk' => 'required|in:produks.id',
|
||||
'id_nampan' => 'nullable|in:nampans.id'
|
||||
'id_produk' => 'required|exists:produks,id',
|
||||
'id_nampan' => 'nullable|exists:nampans,id'
|
||||
],[
|
||||
'id_produk' => 'Id produk tidak valid.',
|
||||
'id_nampan' => 'Id nampan tidak valid'
|
||||
@ -82,7 +83,7 @@ class ItemController extends Controller
|
||||
|
||||
// custom methods
|
||||
public function brankasItem(){
|
||||
$items = Item::with('produk.foto','nampan')->whereNull('id_nampan')->belumTerjual()->get();
|
||||
$items = Item::with('produk.foto','nampan')->whereNull('id_nampan')->get();
|
||||
|
||||
return response()->json($items);
|
||||
}
|
||||
|
||||
82
app/Http/Controllers/KategoriController.php
Normal file
@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Kategori;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class KategoriController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display a listing of the resource.
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
return response()->json(
|
||||
Kategori::withCount('produk')->get()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a newly created resource in storage.
|
||||
*/
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'nama' => 'required|string|max:50',
|
||||
],
|
||||
[
|
||||
'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:50',
|
||||
],
|
||||
[
|
||||
'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'
|
||||
], 200);
|
||||
}
|
||||
}
|
||||
125
app/Http/Controllers/LaporanController.php
Normal file
@ -0,0 +1,125 @@
|
||||
<?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' . $e->getMessage()], 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 {
|
||||
$validated = $request->validate([
|
||||
'start_date' => 'required|date_format:Y-m-d',
|
||||
'end_date' => 'required|date_format:Y-m-d|after_or_equal:start_date',
|
||||
'format' => 'required|string|in:pdf,xlsx,csv',
|
||||
'page' => 'required|integer|min:1',
|
||||
'sales_id' => 'nullable|integer|exists:sales,id',
|
||||
'produk_id' => 'nullable|integer|exists:produks,id',
|
||||
'nama_pembeli' => 'nullable|string|max:255',
|
||||
]);
|
||||
return $this->laporanService->exportPerNampan($validated);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error in export per nampan: ' . $e->getMessage());
|
||||
return response()->json(['error' => 'Terjadi kesalahan saat export data'], 500);
|
||||
}
|
||||
}
|
||||
|
||||
public function exportDetailProduk(Request $request)
|
||||
{
|
||||
try {
|
||||
$validated = $request->validate([
|
||||
'start_date' => 'required|date_format:Y-m-d',
|
||||
'end_date' => 'required|date_format:Y-m-d|after_or_equal:start_date',
|
||||
'format' => 'required|string|in:pdf,xlsx,csv',
|
||||
'page' => 'required|integer|min:1',
|
||||
'sales_id' => 'nullable|integer|exists:sales,id',
|
||||
'nampan_id' => 'nullable|integer',
|
||||
'nama_pembeli' => 'nullable|string|max:255',
|
||||
]);
|
||||
return $this->laporanService->exportPerProduk($validated);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Error in export per produk: ' . $e->getMessage());
|
||||
return response()->json(['error' => 'Terjadi kesalahan saat export data'], 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -3,6 +3,7 @@
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Nampan;
|
||||
use App\Models\Item;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class NampanController extends Controller
|
||||
@ -13,7 +14,7 @@ class NampanController extends Controller
|
||||
public function index()
|
||||
{
|
||||
return response()->json(
|
||||
Nampan::withCount('items')->get()
|
||||
Nampan::with('items.produk.foto', 'items.produk.kategori')->withCount('items')->get()
|
||||
);
|
||||
}
|
||||
|
||||
@ -23,10 +24,12 @@ class NampanController extends Controller
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'nama' => 'required|string|max:100',
|
||||
'nama' => 'required|string|max:10|unique:nampans,nama',
|
||||
],
|
||||
[
|
||||
'nama' => 'Nama nampan harus diisi.'
|
||||
'nama.required' => 'Nama nampan harus diisi.',
|
||||
'nama.unique' => 'Nampan dengan nama yang sama sudah ada.',
|
||||
'nama.max' => 'Nama nampan maksimal 10 karakter.'
|
||||
]);
|
||||
|
||||
Nampan::create($validated);
|
||||
@ -43,7 +46,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)
|
||||
);
|
||||
}
|
||||
|
||||
@ -53,7 +56,7 @@ class NampanController extends Controller
|
||||
public function update(Request $request, int $id)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'nama' => 'required|string|max:100',
|
||||
'nama' => 'required|string|max:10|unique:nampans,nama,'.$id,
|
||||
],
|
||||
[
|
||||
'nama' => 'Nama nampan harus diisi.'
|
||||
@ -85,4 +88,14 @@ class NampanController extends Controller
|
||||
'message' => 'Nampan berhasil dihapus'
|
||||
], 204);
|
||||
}
|
||||
|
||||
public function kosongkan()
|
||||
{
|
||||
Item::query()->update(['id_nampan' => null]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Semua nampan berhasil dikosongkan'
|
||||
], 200);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -17,7 +17,7 @@ class ProdukController extends Controller
|
||||
public function index()
|
||||
{
|
||||
return response()->json(
|
||||
Produk::withCount('items')->with('foto')->get()
|
||||
Produk::withCount('items')->with('foto', 'kategori')->get()
|
||||
);
|
||||
}
|
||||
|
||||
@ -26,39 +26,36 @@ class ProdukController extends Controller
|
||||
*/
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'nama' => 'required|string|max:100',
|
||||
'kategori' => 'required|in:cincin,gelang,kalung,anting',
|
||||
$user = $request->user();
|
||||
if (!$user) {
|
||||
return response()->json(['message' => 'Unauthorized'], 401);
|
||||
}
|
||||
|
||||
$validated = $request->validate(
|
||||
[
|
||||
'nama' => 'required|string|max:100|unique:produks,nama',
|
||||
'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
|
||||
],
|
||||
[
|
||||
'nama.required' => 'Nama produk harus diisi.',
|
||||
'kategori.in' => 'Kategori harus salah satu dari cincin, gelang, kalung, atau anting.',
|
||||
'nama.unique' => 'Nama produk sudah digunakan.',
|
||||
'id_kategori' => 'Kategori tidak valid.',
|
||||
'berat.required' => 'Berat harus diisi.',
|
||||
'kadar.required' => 'Kadar harus diisi',
|
||||
'harga_per_gram.required' => 'Harga per gram harus diisi',
|
||||
'harga_jual.required' => 'Harga jual harus diisi'
|
||||
]);
|
||||
'kadar.required' => 'Kadar harus diisi.',
|
||||
'harga_per_gram.required' => 'Harga per gram harus diisi.',
|
||||
'harga_jual.required' => 'Harga jual harus diisi.'
|
||||
]
|
||||
);
|
||||
|
||||
DB::beginTransaction();
|
||||
try {
|
||||
// Create produk
|
||||
$produk = Produk::create([
|
||||
'nama' => $validated['nama'],
|
||||
'kategori' => $validated['kategori'],
|
||||
'berat' => $validated['berat'],
|
||||
'kadar' => $validated['kadar'],
|
||||
'harga_per_gram' => $validated['harga_per_gram'],
|
||||
'harga_jual' => $validated['harga_jual'],
|
||||
]);
|
||||
$produk = Produk::create($validated);
|
||||
|
||||
// Pindahkan foto sementara ke foto permanen jika ada
|
||||
if (isset($validated['id_user'])) {
|
||||
$fotoSementara = FotoSementara::where('id_user', $validated['id_user'])->get();
|
||||
$fotoSementara = FotoSementara::where('id_user', $user->id)->get();
|
||||
|
||||
foreach ($fotoSementara as $fs) {
|
||||
Foto::create([
|
||||
@ -66,10 +63,8 @@ class ProdukController extends Controller
|
||||
'url' => $fs->url
|
||||
]);
|
||||
|
||||
// Hapus foto sementara setelah dipindah
|
||||
$fs->delete();
|
||||
}
|
||||
}
|
||||
|
||||
DB::commit();
|
||||
|
||||
@ -77,7 +72,6 @@ class ProdukController extends Controller
|
||||
'message' => 'Produk berhasil dibuat',
|
||||
'data' => $produk->load('foto')
|
||||
], 201);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
DB::rollback();
|
||||
return response()->json([
|
||||
@ -92,7 +86,28 @@ 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the specified resource to edit.
|
||||
*/
|
||||
public function edit(Request $request, int $id)
|
||||
{
|
||||
$user = $request->user();
|
||||
if (!$user) {
|
||||
return response()->json(['message' => 'Unauthorized'], 401);
|
||||
}
|
||||
|
||||
$produk = Produk::with('foto', 'kategori')->findOrFail($id);
|
||||
$foto_sementara = [];
|
||||
foreach ($produk->foto as $foto) {
|
||||
$foto_sementara[] = FotoSementara::create([
|
||||
'id_user' => $user->id,
|
||||
'url' => $foto->url
|
||||
]);
|
||||
}
|
||||
return response()->json($produk);
|
||||
}
|
||||
|
||||
@ -101,24 +116,30 @@ class ProdukController extends Controller
|
||||
*/
|
||||
public function update(Request $request, int $id)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'nama' => 'required|string|max:100',
|
||||
'kategori' => 'required|in:cincin,gelang,kalung,anting',
|
||||
$user = $request->user();
|
||||
if (!$user) {
|
||||
return response()->json(['message' => 'Unauthorized'], 401);
|
||||
}
|
||||
|
||||
$validated = $request->validate(
|
||||
[
|
||||
'nama' => 'required|string|max:100|unique:produks,nama,' . $id,
|
||||
'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 baru
|
||||
'hapus_foto_lama' => 'nullable|boolean', // flag untuk menghapus foto lama
|
||||
],
|
||||
[
|
||||
'nama.required' => 'Nama produk harus diisi.',
|
||||
'kategori.in' => 'Kategori harus salah satu dari cincin, gelang, kalung, atau anting.',
|
||||
'nama.unique' => 'Nama produk sudah digunakan.',
|
||||
'id_kategori' => 'Kategori tidak valid.',
|
||||
'berat.required' => 'Berat harus diisi.',
|
||||
'kadar.required' => 'Kadar harus diisi',
|
||||
'harga_per_gram.required' => 'Harga per gram harus diisi',
|
||||
'harga_jual.required' => 'Harga jual harus diisi'
|
||||
]);
|
||||
'harga_jual.required' => 'Harga jual harus diisi',
|
||||
]
|
||||
);
|
||||
|
||||
DB::beginTransaction();
|
||||
try {
|
||||
@ -127,28 +148,28 @@ 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'],
|
||||
'harga_jual' => $validated['harga_jual'],
|
||||
]);
|
||||
|
||||
// Hapus foto lama jika diminta
|
||||
if (isset($validated['hapus_foto_lama']) && $validated['hapus_foto_lama']) {
|
||||
// Hapus foto lama
|
||||
foreach ($produk->foto as $foto) {
|
||||
// Hapus file fisik
|
||||
// Hapus file fisik jika memungkinkan
|
||||
try {
|
||||
$relativePath = str_replace(asset('storage') . '/', '', $foto->url);
|
||||
if (Storage::disk('public')->exists($relativePath)) {
|
||||
Storage::disk('public')->delete($relativePath);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// Maklum Pak, soalnya kadang url aja, ga ada file fisiknya #Bagas
|
||||
}
|
||||
$foto->delete();
|
||||
}
|
||||
}
|
||||
|
||||
// Tambahkan foto baru dari foto sementara jika ada
|
||||
if (isset($validated['id_user'])) {
|
||||
$fotoSementara = FotoSementara::where('id_user', $validated['id_user'])->get();
|
||||
$fotoSementara = FotoSementara::where('id_user', $user->id)->get();
|
||||
|
||||
foreach ($fotoSementara as $fs) {
|
||||
Foto::create([
|
||||
@ -156,10 +177,8 @@ class ProdukController extends Controller
|
||||
'url' => $fs->url
|
||||
]);
|
||||
|
||||
// Hapus foto sementara setelah dipindah
|
||||
$fs->delete();
|
||||
}
|
||||
}
|
||||
|
||||
DB::commit();
|
||||
|
||||
@ -167,7 +186,6 @@ class ProdukController extends Controller
|
||||
'message' => 'Produk berhasil diubah',
|
||||
'data' => $produk->load('foto')
|
||||
], 200);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
DB::rollback();
|
||||
return response()->json([
|
||||
@ -195,7 +213,7 @@ class ProdukController extends Controller
|
||||
$foto->delete();
|
||||
}
|
||||
|
||||
// Hapus produk (soft delete)
|
||||
$produk->items()->delete();
|
||||
$produk->delete();
|
||||
|
||||
DB::commit();
|
||||
@ -203,7 +221,6 @@ class ProdukController extends Controller
|
||||
return response()->json([
|
||||
'message' => 'Produk berhasil dihapus.'
|
||||
], 200);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
DB::rollback();
|
||||
return response()->json([
|
||||
|
||||
@ -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);
|
||||
|
||||
51
app/Http/Controllers/StrukController.php
Normal file
@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Transaksi;
|
||||
use Barryvdh\DomPDF\Facade\Pdf;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class StrukController extends Controller
|
||||
{
|
||||
public function cetak(int $id)
|
||||
{
|
||||
try {
|
||||
$data = Transaksi::with(['itemTransaksi.produk.foto', 'sales'])
|
||||
->find($id);
|
||||
|
||||
if (!$data) {
|
||||
return response()->json(['error'=>'Transaksi tidak ditemukan'], 404);
|
||||
}
|
||||
|
||||
// Debug: Let's see what data structure we have
|
||||
// dd([
|
||||
// 'transaksi' => $data->toArray(),
|
||||
// 'item_count' => $data->itemTransaksi->count(),
|
||||
// 'has_sales' => $data->sales ? true : false,
|
||||
// ]);
|
||||
|
||||
// After debugging, uncomment this:
|
||||
|
||||
$pdf = Pdf::loadView('exports.struk', $data->toArray())
|
||||
->setPaper([0, 0, 1224 * 0.75, 528 * 0.75], 'landscape')
|
||||
->setOptions([
|
||||
'isHtml5ParserEnabled' => true,
|
||||
'isRemoteEnabled' => true,
|
||||
'defaultFont' => 'DejaVu Sans'
|
||||
]);
|
||||
|
||||
$filename = 'Struk_' . $data->kode_transaksi . '.pdf';
|
||||
return $pdf->download($filename);
|
||||
|
||||
|
||||
} catch (\Exception $e) {
|
||||
return response()->json([
|
||||
'error' => 'Debug Error',
|
||||
'message' => $e->getMessage(),
|
||||
'line' => $e->getLine(),
|
||||
'file' => $e->getFile()
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -5,79 +5,170 @@ namespace App\Http\Controllers;
|
||||
use App\Models\Transaksi;
|
||||
use App\Models\ItemTransaksi;
|
||||
use App\Models\Item;
|
||||
use App\Models\Sales;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class TransaksiController extends Controller
|
||||
{
|
||||
// List semua transaksi
|
||||
public function index()
|
||||
public function index(Request $request)
|
||||
{
|
||||
$transaksi = Transaksi::with(['kasir', 'sales', 'items.item.produk'])->get();
|
||||
return response()->json($transaksi);
|
||||
$limit = $request->query('limit', 10);
|
||||
$page = $request->query('page', 1);
|
||||
$startDate = $request->query('start_date');
|
||||
$endDate = $request->query('end_date');
|
||||
$search = $request->query('search');
|
||||
|
||||
$query = Transaksi::with(['kasir', 'sales', 'itemTransaksi.produk']);
|
||||
|
||||
// Filter berdasarkan interval tanggal
|
||||
if ($startDate && $endDate) {
|
||||
$query->whereBetween('created_at', [
|
||||
Carbon::parse($startDate)->startOfDay(),
|
||||
Carbon::parse($endDate)->endOfDay()
|
||||
]);
|
||||
}
|
||||
// Default: hanya transaksi hari ini
|
||||
elseif (!$startDate && !$endDate) {
|
||||
$today = Carbon::today();
|
||||
$query->whereDate('created_at', $today);
|
||||
}
|
||||
|
||||
// Detail transaksi by ID
|
||||
// Search berdasarkan kode transaksi atau nama pelanggan
|
||||
if ($search) {
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('kode_transaksi', 'like', '%' . $search . '%')
|
||||
->orWhere('nama_pembeli', 'like', '%' . $search . '%');
|
||||
});
|
||||
}
|
||||
|
||||
$query->latest();
|
||||
$transaksi = $query->paginate($limit, ['*'], 'page', $page);
|
||||
|
||||
// Transform data
|
||||
$transaksi->getCollection()->transform(function ($transaksi) {
|
||||
$transaksi->total_items = $transaksi->itemTransaksi->count();
|
||||
$transaksi->tanggal = $transaksi->created_at->format('d/m/Y H:i');
|
||||
$transaksi->pendapatan = $transaksi->total_harga ?? 0;
|
||||
|
||||
return $transaksi;
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'data' => $transaksi->items(),
|
||||
'pagination' => [
|
||||
'current_page' => $transaksi->currentPage(),
|
||||
'last_page' => $transaksi->lastPage(),
|
||||
'per_page' => $transaksi->perPage(),
|
||||
'total' => $transaksi->total(),
|
||||
'from' => $transaksi->firstItem(),
|
||||
'to' => $transaksi->lastItem(),
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
// Detail transaksi
|
||||
public function show($id)
|
||||
{
|
||||
$transaksi = Transaksi::with(['kasir', 'sales', 'items.item.produk'])->findOrFail($id);
|
||||
$transaksi = Transaksi::with([
|
||||
'kasir',
|
||||
'sales',
|
||||
'itemTransaksi.produk',
|
||||
'itemTransaksi' => function ($query) {
|
||||
$query->orderBy('created_at', 'asc');
|
||||
}
|
||||
])->findOrFail($id);
|
||||
|
||||
$transaksi->total_items = $transaksi->itemTransaksi->count();
|
||||
$transaksi->tanggal = $transaksi->created_at->format('d/m/Y H:i');
|
||||
$transaksi->pendapatan = $transaksi->total_harga ?? 0;
|
||||
|
||||
return response()->json($transaksi);
|
||||
}
|
||||
|
||||
// Membuat transaksi baru
|
||||
public function store(Request $request)
|
||||
{
|
||||
$kasir = $request->user();
|
||||
if (!$kasir) {
|
||||
return response()->json(['error' => 'Unauthorized'], 401);
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'id_kasir' => 'required|exists:akun,id',
|
||||
'id_sales' => 'nullable|exists:sales,id',
|
||||
'nama_sales' => 'nullable|string',
|
||||
'no_hp' => 'nullable|string',
|
||||
'alamat' => 'nullable|string',
|
||||
'ongkos_bikin' => 'nullable|numeric',
|
||||
'id_sales' => 'required|exists:sales,id',
|
||||
'nama_pembeli' => 'required|string',
|
||||
'no_hp' => 'required|string',
|
||||
'alamat' => 'required|string',
|
||||
'ongkos_bikin' => 'nullable|numeric|min:0',
|
||||
'total_harga' => 'required|numeric',
|
||||
'items' => 'required|array',
|
||||
'items.*.id_item' => 'required|exists:item,id',
|
||||
'items.*.kode_item' => 'required|exists:items,id|numeric',
|
||||
'items.*.harga_deal' => 'required|numeric',
|
||||
]);
|
||||
|
||||
DB::beginTransaction();
|
||||
try {
|
||||
|
||||
$sales = Sales::find($request->id_sales);
|
||||
|
||||
$transaksi = Transaksi::create([
|
||||
'id_kasir' => $request->id_kasir,
|
||||
'kode_transaksi' => 'belum pak',
|
||||
'id_kasir' => $kasir->id,
|
||||
'id_sales' => $request->id_sales,
|
||||
'nama_sales' => $request->nama_sales,
|
||||
'nama_sales' => $sales->nama ?? 'N/A',
|
||||
'nama_pembeli' => $request->nama_pembeli,
|
||||
'no_hp' => $request->no_hp,
|
||||
'alamat' => $request->alamat,
|
||||
'ongkos_bikin' => $request->ongkos_bikin,
|
||||
'ongkos_bikin' => $request->ongkos_bikin ?? 0,
|
||||
'total_harga' => $request->total_harga,
|
||||
]);
|
||||
|
||||
foreach ($request->items as $it) {
|
||||
// TODO: ubah saat transaksi pake kode_item
|
||||
// $item = Item::where('kode_item', $it['kode_item'])->first();
|
||||
// if (!$item) {
|
||||
// throw new \Exception("Item dengan kode_item {$it['kode_item']} tidak ditemukan.");
|
||||
// }
|
||||
$item = Item::where('id',$it['kode_item'])->with('produk')->first();
|
||||
|
||||
ItemTransaksi::create([
|
||||
'id_transaksi' => $transaksi->id,
|
||||
'id_item' => $it['id_item'],
|
||||
'id_produk' => $item->produk->id,
|
||||
'harga_deal' => $it['harga_deal'],
|
||||
'posisi_asal' => $it['posisi'],
|
||||
]);
|
||||
|
||||
Item::where('id', $it['id_item'])->update(['is_sold' => true]);
|
||||
$item->forceDelete();
|
||||
}
|
||||
|
||||
DB::commit();
|
||||
return response()->json($transaksi->load('items'), 201);
|
||||
|
||||
return response()->json(
|
||||
$transaksi->load(['itemTransaksi.produk.foto', 'kasir', 'sales']),
|
||||
201
|
||||
);
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
return response()->json(['error' => $e->getMessage()], 500);
|
||||
return response()->json([
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTrace()
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Update transaksi
|
||||
public function update(Request $request, $id)
|
||||
{
|
||||
$transaksi = Transaksi::findOrFail($id);
|
||||
|
||||
$transaksi->update($request->only([
|
||||
'id_sales', 'nama_sales', 'no_hp', 'alamat', 'ongkos_bikin', 'total_harga'
|
||||
'id_sales',
|
||||
'nama_sales',
|
||||
'no_hp',
|
||||
'alamat',
|
||||
'ongkos_bikin',
|
||||
'total_harga'
|
||||
]));
|
||||
|
||||
return response()->json($transaksi);
|
||||
|
||||
@ -19,14 +19,21 @@ 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',
|
||||
], [
|
||||
'nama.require' => 'Nama wajib diisi',
|
||||
'nama.unique' => 'Nama sudah digunakan',
|
||||
'password.require' => 'Password wajib diisi',
|
||||
'password.min' => 'Password minimal 6 karakter',
|
||||
'role.require' => 'Role wajib diisi',
|
||||
'role.in' => 'Role harus owner atau kasir',
|
||||
]);
|
||||
|
||||
User::create([
|
||||
'nama' => $request->nama,
|
||||
'password' => bcrypt($request->password),
|
||||
'password' => $request->password,
|
||||
'role' => $request->role,
|
||||
]);
|
||||
|
||||
@ -41,22 +48,32 @@ class UserController extends Controller
|
||||
$user = User::findOrFail($id);
|
||||
|
||||
$request->validate([
|
||||
'nama' => 'required|nama|unique:users,nama,' . $id,
|
||||
'password' => 'required|min:6',
|
||||
'nama' => 'required|string|unique:users,nama,' . $id,
|
||||
'password' => 'nullable|min:6',
|
||||
'role' => 'required|in:owner,kasir',
|
||||
], [
|
||||
'nama.require' => 'Nama wajib diisi',
|
||||
'nama.unique' => 'Nama sudah digunakan',
|
||||
'password.min' => 'Password minimal 6 karakter',
|
||||
'role.require' => 'Role wajib diisi',
|
||||
'role.in' => 'Role harus owner atau kasir',
|
||||
]);
|
||||
|
||||
$user->update([
|
||||
$data = [
|
||||
'nama' => $request->nama,
|
||||
'password' => $request->password,
|
||||
'role' => $request->role,
|
||||
]);
|
||||
];
|
||||
|
||||
return response()->json([
|
||||
'message' => 'User berhasil diupdate'
|
||||
],200);
|
||||
if ($request->filled('password')) {
|
||||
$data['password'] = $request->password;
|
||||
}
|
||||
|
||||
$user->update($data);
|
||||
|
||||
return response()->json(['message' => 'User berhasil diupdate', 'user' => $user], 200);
|
||||
}
|
||||
|
||||
|
||||
public function destroy($id)
|
||||
{
|
||||
$user = User::findOrFail($id);
|
||||
|
||||
30
app/Http/Middleware/RoleMiddleware.php
Normal file
@ -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);
|
||||
}
|
||||
}
|
||||
60
app/Http/Requests/DetailLaporanRequest.php
Normal file
@ -0,0 +1,60 @@
|
||||
<?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 [
|
||||
'start_date' => 'required|date_format:Y-m-d',
|
||||
'end_date' => 'required|date_format:Y-m-d|after_or_equal:start_date',
|
||||
'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 [
|
||||
'end_date.after_or_equal' => 'Tanggal akhir harus sama atau setelah tanggal mulai.',
|
||||
'sales_id.exists' => 'Sales tidak ditemukan',
|
||||
'produk_id.exists' => 'Produk tidak ditemukan',
|
||||
'nama_pembeli.max' => 'Nama pembeli maksimal 255 karakter',
|
||||
'page.min' => 'Page minimal 1',
|
||||
'per_page.min' => 'Per page minimal 1',
|
||||
'per_page.max' => 'Per page maksimal 100',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare the data for validation.
|
||||
*/
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
$this->merge([
|
||||
'page' => $this->query('page', 1),
|
||||
'per_page' => $this->query('per_page', 15),
|
||||
]);
|
||||
}
|
||||
}
|
||||
41
app/Http/Requests/ExportLaporanRequest.php
Normal file
@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class ExportLaporanRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'filter' => 'required|in:hari,bulan',
|
||||
'format' => 'required|in:pdf,xlsx,csv',
|
||||
'page' => 'nullable',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get custom messages for validator errors.
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'filter.required' => 'Filter harus diisi',
|
||||
'filter.in' => 'Filter harus berupa "hari" atau "bulan"',
|
||||
'format.required' => 'Format export harus diisi',
|
||||
'format.in' => 'Format export harus berupa "pdf", "xlsx", atau "csv"',
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -15,6 +15,8 @@ class Foto extends Model
|
||||
'url',
|
||||
];
|
||||
|
||||
protected $hidden = ['created_at', 'updated_at', 'deleted_at'];
|
||||
|
||||
public function produk()
|
||||
{
|
||||
return $this->belongsTo(Produk::class, 'id_produk');
|
||||
|
||||
@ -10,4 +10,6 @@ class FotoSementara extends Model
|
||||
'id_user',
|
||||
'url',
|
||||
];
|
||||
|
||||
protected $hidden = ['created_at', 'updated_at', 'deleted_at'];
|
||||
}
|
||||
|
||||
@ -1,38 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\itemTransaksi;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
|
||||
class Item extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
protected $table = 'items';
|
||||
|
||||
protected $fillable = [
|
||||
'id_produk',
|
||||
'id_nampan',
|
||||
'is_sold',
|
||||
'kode_item',
|
||||
];
|
||||
|
||||
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 = 'TMJC';
|
||||
$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');
|
||||
}
|
||||
|
||||
public function scopeBelumTerjual($query)
|
||||
{
|
||||
return $query->where('is_sold', false);
|
||||
}
|
||||
|
||||
public function nampan()
|
||||
{
|
||||
return $this->belongsTo(Nampan::class, 'id_nampan');
|
||||
}
|
||||
|
||||
public function itemTransaksi()
|
||||
{
|
||||
return $this->hasMany(ItemTransaksi::class, 'id_item');
|
||||
}
|
||||
}
|
||||
|
||||
@ -12,17 +12,20 @@ class ItemTransaksi extends Model
|
||||
|
||||
protected $fillable = [
|
||||
'id_transaksi',
|
||||
'id_item',
|
||||
'harga_deal'
|
||||
'id_produk',
|
||||
'harga_deal',
|
||||
'posisi_asal'
|
||||
];
|
||||
|
||||
protected $hidden = ['created_at', 'updated_at', 'deleted_at'];
|
||||
|
||||
public function transaksi()
|
||||
{
|
||||
return $this->belongsTo(Transaksi::class, 'id_transaksi');
|
||||
}
|
||||
|
||||
public function item()
|
||||
public function produk()
|
||||
{
|
||||
return $this->belongsTo(Item::class, 'id_item');
|
||||
return $this->belongsTo(Produk::class, 'id_produk');
|
||||
}
|
||||
}
|
||||
|
||||
22
app/Models/Kategori.php
Normal file
@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class Kategori extends Model
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\KategoriFactory> */
|
||||
use HasFactory, SoftDeletes;
|
||||
|
||||
protected $fillable = ['nama'];
|
||||
|
||||
protected $hidden = ['created_at', 'updated_at', 'deleted_at'];
|
||||
|
||||
public function produk()
|
||||
{
|
||||
return $this->hasMany(Produk::class, 'id_kategori');
|
||||
}
|
||||
}
|
||||
@ -13,9 +13,19 @@ class Nampan extends Model
|
||||
protected $fillable = [
|
||||
'nama'
|
||||
];
|
||||
protected $appends = ['berat_total'];
|
||||
|
||||
protected $hidden = ['created_at', 'updated_at', 'deleted_at'];
|
||||
|
||||
public function items()
|
||||
{
|
||||
return $this->hasMany(Item::class, 'id_nampan');
|
||||
}
|
||||
|
||||
public function getBeratTotalAttribute()
|
||||
{
|
||||
return $this->items()
|
||||
->join('produks', 'items.id_produk', '=', 'produks.id')
|
||||
->sum('produks.berat');
|
||||
}
|
||||
}
|
||||
|
||||
@ -12,13 +12,15 @@ class Produk extends Model
|
||||
|
||||
protected $fillable = [
|
||||
'nama',
|
||||
'kategori',
|
||||
'id_kategori',
|
||||
'berat',
|
||||
'kadar',
|
||||
'harga_per_gram',
|
||||
'harga_jual',
|
||||
];
|
||||
|
||||
protected $hidden = ['created_at', 'updated_at', 'deleted_at'];
|
||||
|
||||
public function items()
|
||||
{
|
||||
return $this->hasMany(Item::class, 'id_produk');
|
||||
@ -28,4 +30,9 @@ class Produk extends Model
|
||||
{
|
||||
return $this->hasMany(Foto::class, 'id_produk');
|
||||
}
|
||||
|
||||
public function kategori()
|
||||
{
|
||||
return $this->belongsTo(Kategori::class, 'id_kategori');
|
||||
}
|
||||
}
|
||||
|
||||
@ -16,6 +16,8 @@ class Sales extends Model
|
||||
'alamat'
|
||||
];
|
||||
|
||||
protected $hidden = ['created_at', 'updated_at', 'deleted_at'];
|
||||
|
||||
public function transaksi()
|
||||
{
|
||||
return $this->hasMany(Transaksi::class, 'id_sales');
|
||||
|
||||
@ -9,10 +9,13 @@ class Transaksi extends Model
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\TransaksiFactory> */
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'kode_transaksi',
|
||||
'id_kasir',
|
||||
'id_sales',
|
||||
'nama_sales',
|
||||
'nama_pembeli',
|
||||
'no_hp',
|
||||
'alamat',
|
||||
'ongkos_bikin',
|
||||
@ -20,6 +23,24 @@ class Transaksi extends Model
|
||||
'created_at',
|
||||
];
|
||||
|
||||
protected $hidden = ['updated_at', 'deleted_at'];
|
||||
|
||||
protected static function boot()
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
static::created(function ($transaksi) {
|
||||
if (!$transaksi->kode_transaksi || $transaksi->kode_transaksi === 'belum pak') {
|
||||
$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 +55,4 @@ class Transaksi extends Model
|
||||
{
|
||||
return $this->hasMany(ItemTransaksi::class, 'id_transaksi');
|
||||
}
|
||||
|
||||
public function items()
|
||||
{
|
||||
return $this->hasMany(ItemTransaksi::class, 'id_transaksi');
|
||||
}
|
||||
|
||||
public function foto ()
|
||||
{
|
||||
return $this->hasMany(Foto::class, 'id_produk');
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,11 +6,13 @@ namespace App\Models;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Laravel\Sanctum\HasApiTokens;
|
||||
|
||||
|
||||
class User extends Authenticatable
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\UserFactory> */
|
||||
use HasFactory, Notifiable;
|
||||
use HasApiTokens, HasFactory, Notifiable;
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
@ -45,4 +47,9 @@ class User extends Authenticatable
|
||||
'password' => 'hashed',
|
||||
];
|
||||
}
|
||||
|
||||
public function getAuthIdentifierName()
|
||||
{
|
||||
return 'id';
|
||||
}
|
||||
}
|
||||
|
||||
143
app/Repositories/TransaksiRepository.php
Normal file
@ -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.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.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();
|
||||
}
|
||||
}
|
||||
479
app/Services/LaporanService.php
Normal file
@ -0,0 +1,479 @@
|
||||
<?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 sales detail aggregated by product (NO PAGINATION - all data).
|
||||
*
|
||||
* @param array $params Filter parameters (tanggal, sales_id, nampan_id, nama_pembeli)
|
||||
* @return array Report data structure
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function getDetailPerProduk(array $params)
|
||||
{
|
||||
$startDate = Carbon::parse($params['start_date'])->startOfDay();
|
||||
$endDate = Carbon::parse($params['end_date'])->endOfDay();
|
||||
|
||||
// TAMBAH: Validasi range max 30 hari
|
||||
if ($startDate->diffInDays($endDate) > 30) {
|
||||
throw new \InvalidArgumentException('Interval tanggal maksimal 30 hari.');
|
||||
}
|
||||
|
||||
// FIXED: Skip pagination params untuk data utama
|
||||
$page = $params['page'] ?? 1;
|
||||
$perPage = $params['per_page'] ?? self::DEFAULT_PER_PAGE;
|
||||
|
||||
// --- Step 1: Totals ---
|
||||
$totalsQuery = $this->buildBaseItemQueryForRange($startDate, $endDate);
|
||||
$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();
|
||||
|
||||
$rekapInterval = [
|
||||
'total_item_terjual' => (int) $totalsResult->total_item_terjual,
|
||||
'total_berat_terjual' => $this->helper->formatWeight($totalsResult->total_berat_terjual),
|
||||
'total_pendapatan' => $this->helper->formatCurrency($totalsResult->total_pendapatan),
|
||||
];
|
||||
|
||||
// --- Step 2: Subquery for all products ---
|
||||
$salesSubQuery = $this->buildBaseItemQueryForRange($startDate, $endDate)
|
||||
->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');
|
||||
|
||||
$this->applyFilters($salesSubQuery, $params);
|
||||
|
||||
// --- Step 3: All products (NO PAGINATION) ---
|
||||
$semuaProduk = 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')
|
||||
->get(); // FIXED: get() instead of paginate()
|
||||
|
||||
// --- Step 4: Map & filter ---
|
||||
$detailItem = $semuaProduk->map(function ($item) {
|
||||
return [
|
||||
'nama_produk' => $item->nama_produk,
|
||||
'jumlah_item_terjual' => $item->jumlah_item_terjual ? (int) $item->jumlah_item_terjual : 0,
|
||||
'berat_terjual' => $item->berat_terjual ? $this->helper->formatWeight($item->berat_terjual) : '-',
|
||||
'pendapatan' => $item->pendapatan ? $this->helper->formatCurrency($item->pendapatan) : '-',
|
||||
];
|
||||
})->filter(function ($item) {
|
||||
return $item['jumlah_item_terjual'] > 0;
|
||||
});
|
||||
|
||||
// FIXED: Simple collection without pagination
|
||||
$filteredCollection = $detailItem->values();
|
||||
|
||||
// --- Step 5: Response ---
|
||||
$filterInfo = $this->helper->buildProdukFilterInfo($startDate, $endDate, $params);
|
||||
|
||||
return [
|
||||
'filter' => $filterInfo,
|
||||
'rekap_interval' => $rekapInterval,
|
||||
'produk' => $filteredCollection,
|
||||
'pagination' => [
|
||||
'current_page' => 1,
|
||||
'from' => 1,
|
||||
'last_page' => 1,
|
||||
'per_page' => $filteredCollection->count(),
|
||||
'to' => $filteredCollection->count(),
|
||||
'total' => $filteredCollection->count(),
|
||||
'has_more_pages' => false,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function buildBaseItemQueryForRange(Carbon $startDate, Carbon $endDate)
|
||||
{
|
||||
return ItemTransaksi::query()
|
||||
->join('produks', 'item_transaksis.id_produk', '=', 'produks.id')
|
||||
->join('transaksis', 'item_transaksis.id_transaksi', '=', 'transaksis.id')
|
||||
->whereBetween('transaksis.created_at', [$startDate, $endDate]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get sales detail aggregated by nampan (NO PAGINATION - all data).
|
||||
*
|
||||
* @param array $params Filter parameters (tanggal, sales_id, produk_id, nama_pembeli)
|
||||
* @return array Report data structure
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function getDetailPerNampan(array $params)
|
||||
{
|
||||
$startDate = Carbon::parse($params['start_date'])->startOfDay();
|
||||
$endDate = Carbon::parse($params['end_date'])->endOfDay();
|
||||
|
||||
if ($startDate->diffInDays($endDate) > 30) {
|
||||
throw new \InvalidArgumentException('Interval tanggal maksimal 30 hari.');
|
||||
}
|
||||
|
||||
$page = $params['page'] ?? 1;
|
||||
$perPage = $params['per_page'] ?? self::DEFAULT_PER_PAGE;
|
||||
|
||||
$nampanTerjualQuery = $this->buildBaseItemQueryForRange($startDate, $endDate);
|
||||
$this->applyNampanFilters($nampanTerjualQuery, $params);
|
||||
|
||||
$nampanTerjual = $nampanTerjualQuery
|
||||
->select(
|
||||
DB::raw('COALESCE(item_transaksis.posisi_asal, "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('nama_nampan')
|
||||
->get()
|
||||
->keyBy('nama_nampan');
|
||||
|
||||
// FIXED: calculateTotals sum raw (bukan formatted string)
|
||||
$nampanTerjualRaw = $nampanTerjual->map(function ($item) {
|
||||
return [
|
||||
'jumlah_item_terjual' => (int) $item->jumlah_item_terjual,
|
||||
'berat_terjual' => $item->berat_terjual,
|
||||
'pendapatan' => $item->pendapatan,
|
||||
];
|
||||
});
|
||||
$totals = $this->helper->calculateTotals($nampanTerjualRaw);
|
||||
|
||||
// FIXED: Get all nampan without pagination
|
||||
$semuaNampan = $this->helper->getAllNampanWithPagination(1, PHP_INT_MAX); // Skip pagination
|
||||
$detailItem = $this->helper->mapNampanWithSalesData($semuaNampan, $nampanTerjual)
|
||||
->filter(function ($item) {
|
||||
return $item['jumlah_item_terjual'] > 0;
|
||||
});
|
||||
|
||||
// FIXED: Simple collection without pagination
|
||||
$filteredCollection = $detailItem->values();
|
||||
|
||||
$filterInfo = $this->helper->buildNampanFilterInfo($startDate, $endDate, $params);
|
||||
|
||||
return [
|
||||
'filter' => $filterInfo,
|
||||
'rekap_interval' => $totals,
|
||||
'nampan' => $filteredCollection,
|
||||
'pagination' => [
|
||||
'current_page' => 1,
|
||||
'from' => 1,
|
||||
'last_page' => 1,
|
||||
'per_page' => $filteredCollection->count(),
|
||||
'to' => $filteredCollection->count(),
|
||||
'total' => $filteredCollection->count(),
|
||||
'has_more_pages' => false,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function exportRingkasan(array $params)
|
||||
{
|
||||
$filter = $params['filter'];
|
||||
$format = $params['format'];
|
||||
$page = $params['page'] ?? 1;
|
||||
|
||||
$allSalesNames = $this->getAllSalesNames();
|
||||
|
||||
if ($filter === 'hari') {
|
||||
$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);
|
||||
}
|
||||
|
||||
public function exportPerProduk(array $params)
|
||||
{
|
||||
$format = $params['format'];
|
||||
$allParams = $params;
|
||||
unset($allParams['page'], $allParams['per_page']);
|
||||
|
||||
$data = $this->getDetailPerProdukForExport($allParams);
|
||||
|
||||
$startDate = Carbon::parse($params['start_date'])->format('Ymd');
|
||||
$endDate = Carbon::parse($params['end_date'])->format('Ymd');
|
||||
$fileName = "laporan_per_produk_{$startDate}_to_{$endDate}.{$format}";
|
||||
|
||||
if ($format === 'pdf') {
|
||||
$pdf = PDF::loadView('exports.perproduk_pdf', [
|
||||
'data' => $data,
|
||||
'title' => 'Laporan Detail Per Produk'
|
||||
]);
|
||||
$pdf->setPaper('a4', 'portrait');
|
||||
return $pdf->download($fileName);
|
||||
}
|
||||
|
||||
return Excel::download(new DetailProdukExport($data), $fileName);
|
||||
}
|
||||
|
||||
public function exportPerNampan(array $params)
|
||||
{
|
||||
$format = $params['format'];
|
||||
$allParams = $params;
|
||||
unset($allParams['page'], $allParams['per_page']);
|
||||
|
||||
$data = $this->getDetailPerNampanForExport($allParams);
|
||||
|
||||
$startDate = Carbon::parse($params['start_date'])->format('Ymd');
|
||||
$endDate = Carbon::parse($params['end_date'])->format('Ymd');
|
||||
$fileName = "laporan_per_nampan_{$startDate}_to_{$endDate}.{$format}";
|
||||
|
||||
if ($format === 'pdf') {
|
||||
$pdf = PDF::loadView('exports.pernampan_pdf', [
|
||||
'data' => $data,
|
||||
'title' => 'Laporan Detail Per Nampan'
|
||||
]);
|
||||
$pdf->setPaper('a4', 'portrait');
|
||||
return $pdf->download($fileName);
|
||||
}
|
||||
|
||||
return Excel::download(new DetailNampanExport($data), $fileName);
|
||||
}
|
||||
|
||||
private function getDetailPerProdukForExport(array $params)
|
||||
{
|
||||
$startDate = Carbon::parse($params['start_date'])->startOfDay();
|
||||
$endDate = Carbon::parse($params['end_date'])->endOfDay();
|
||||
|
||||
$produkTerjualQuery = $this->buildBaseItemQueryForRange($startDate, $endDate);
|
||||
$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');
|
||||
|
||||
// FIXED: calculateTotals sum raw
|
||||
$produkTerjualRaw = $produkTerjual->map(function ($item) {
|
||||
return [
|
||||
'jumlah_item_terjual' => (int) $item->jumlah_item_terjual,
|
||||
'berat_terjual' => $item->berat_terjual,
|
||||
'pendapatan' => $item->pendapatan,
|
||||
];
|
||||
});
|
||||
$totals = $this->helper->calculateTotals($produkTerjualRaw);
|
||||
|
||||
$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 null;
|
||||
})->filter();
|
||||
|
||||
$filterInfo = $this->helper->buildProdukFilterInfo($startDate, $endDate, $params);
|
||||
|
||||
return [
|
||||
'filter' => $filterInfo,
|
||||
'rekap_interval' => $totals,
|
||||
'produk' => $detailItem->values(),
|
||||
];
|
||||
}
|
||||
|
||||
private function getDetailPerNampanForExport(array $params)
|
||||
{
|
||||
$startDate = Carbon::parse($params['start_date'])->startOfDay();
|
||||
$endDate = Carbon::parse($params['end_date'])->endOfDay();
|
||||
|
||||
$nampanTerjualQuery = $this->buildBaseItemQueryForRange($startDate, $endDate);
|
||||
$this->applyNampanFilters($nampanTerjualQuery, $params);
|
||||
|
||||
$nampanTerjual = $nampanTerjualQuery
|
||||
->select(
|
||||
'item_transaksis.posisi_asal as posisi_asal',
|
||||
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('item_transaksis.posisi_asal')
|
||||
->get()
|
||||
->keyBy('posisi_asal');
|
||||
|
||||
// FIXED: Sum raw
|
||||
$nampanTerjualRaw = $nampanTerjual->map(function ($item) {
|
||||
return [
|
||||
'jumlah_item_terjual' => (int) $item->jumlah_item_terjual,
|
||||
'berat_terjual' => $item->berat_terjual,
|
||||
'pendapatan' => $item->pendapatan,
|
||||
];
|
||||
});
|
||||
$totals = $this->helper->calculateTotals($nampanTerjualRaw);
|
||||
|
||||
$semuaPosisi = DB::table('item_transaksis')
|
||||
->whereBetween('created_at', [$startDate, $endDate])
|
||||
->select('posisi_asal')
|
||||
->distinct()
|
||||
->pluck('posisi_asal')
|
||||
->sort()
|
||||
->values();
|
||||
|
||||
$detailItem = $semuaPosisi->map(function ($posisi) use ($nampanTerjual) {
|
||||
if ($nampanTerjual->has($posisi)) {
|
||||
$dataTerjual = $nampanTerjual->get($posisi);
|
||||
return [
|
||||
'nama_nampan' => $posisi,
|
||||
'jumlah_item_terjual' => (int) $dataTerjual->jumlah_item_terjual,
|
||||
'berat_terjual' => $this->helper->formatWeight($dataTerjual->berat_terjual),
|
||||
'pendapatan' => $this->helper->formatCurrency($dataTerjual->pendapatan),
|
||||
];
|
||||
}
|
||||
return null;
|
||||
})->filter();
|
||||
|
||||
$filterInfo = $this->helper->buildNampanFilterInfo($startDate, $endDate, $params);
|
||||
|
||||
return [
|
||||
'filter' => $filterInfo,
|
||||
'rekap_interval' => $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 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'])) {
|
||||
$nampanId = (int) $params['nampan_id'];
|
||||
if ($nampanId === -1) {
|
||||
$query->where('item_transaksis.posisi_asal', 'Brankas');
|
||||
} elseif ($nampanId > 0) {
|
||||
$query->join('nampans', function ($join) use ($nampanId) {
|
||||
$join->on('item_transaksis.posisi_asal', '=', 'nampans.nama')
|
||||
->where('nampans.id', $nampanId);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($params['nama_pembeli'])) {
|
||||
$query->where('transaksis.nama_pembeli', 'like', "%{$params['nama_pembeli']}%");
|
||||
}
|
||||
}
|
||||
|
||||
private function applyNampanFilters($query, array $params): void
|
||||
{
|
||||
if (!empty($params['sales_id'])) {
|
||||
$query->join('sales', 'transaksis.id_sales', '=', 'sales.id')
|
||||
->where('sales.id', $params['sales_id']);
|
||||
}
|
||||
|
||||
if (!empty($params['produk_id'])) {
|
||||
$query->where('produks.id', $params['produk_id']);
|
||||
}
|
||||
|
||||
if (!empty($params['nama_pembeli'])) {
|
||||
$query->where('transaksis.nama_pembeli', 'like', "%{$params['nama_pembeli']}%");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -15,6 +15,10 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
$middleware->validateCsrfTokens(except: [
|
||||
'api/*'
|
||||
]);
|
||||
|
||||
$middleware->alias([
|
||||
'role' => \App\Http\Middleware\RoleMiddleware::class,
|
||||
]);
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
//
|
||||
|
||||
@ -7,9 +7,11 @@
|
||||
"license": "MIT",
|
||||
"require": {
|
||||
"php": "^8.2",
|
||||
"barryvdh/laravel-dompdf": "^3.1",
|
||||
"laravel/framework": "^12.0",
|
||||
"laravel/sanctum": "^4.2",
|
||||
"laravel/tinker": "^2.10.1"
|
||||
"laravel/tinker": "^2.10.1",
|
||||
"maatwebsite/excel": "^3.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"fakerphp/faker": "^1.23",
|
||||
|
||||
960
composer.lock
generated
@ -40,6 +40,11 @@ return [
|
||||
'driver' => 'session',
|
||||
'provider' => 'users',
|
||||
],
|
||||
|
||||
'api' => [
|
||||
'driver' => 'sanctum',
|
||||
'provider' => 'users',
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Produk;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
@ -17,9 +18,8 @@ class ItemFactory extends Factory
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'id_produk' => \App\Models\Produk::factory(),
|
||||
'id_produk' => Produk::inRandomOrder()->first()->id,
|
||||
'id_nampan' => null,
|
||||
'is_sold' => false,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Item;
|
||||
use App\Models\Produk;
|
||||
use App\Models\Transaksi;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
@ -20,7 +21,7 @@ class ItemTransaksiFactory extends Factory
|
||||
{
|
||||
return [
|
||||
'id_transaksi' => Transaksi::factory(),
|
||||
'id_item' => Item::factory(),
|
||||
'id_produk' => Produk::factory(),
|
||||
'harga_deal' => $this->faker->randomFloat(2, 100000, 5000000),
|
||||
'created_at' => now(),
|
||||
];
|
||||
|
||||
23
database/factories/KategoriFactory.php
Normal file
@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Kategori>
|
||||
*/
|
||||
class KategoriFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'nama' => $this->faker->word(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Kategori;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
@ -16,11 +17,16 @@ class ProdukFactory extends Factory
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
$kategori = Kategori::inRandomOrder()->first();
|
||||
|
||||
$harga_per_gram = $this->faker->numberBetween(80, 120) * 10000;
|
||||
$berat = $this->faker->randomFloat(2, 1, 10);
|
||||
|
||||
return [
|
||||
'nama' => $this->faker->words(3, true),
|
||||
'kategori' => $this->faker->randomElement(['cincin', 'gelang', 'kalung', 'anting']),
|
||||
'nama' => $kategori->nama . ' ' . $this->faker->words(mt_rand(1, 2), true),
|
||||
|
||||
'id_kategori' => $kategori->id,
|
||||
|
||||
'berat' => $berat,
|
||||
'kadar' => $this->faker->numberBetween(10, 24),
|
||||
'harga_per_gram' => $harga_per_gram,
|
||||
|
||||
@ -4,33 +4,46 @@ namespace Database\Factories;
|
||||
|
||||
use App\Models\Sales;
|
||||
use App\Models\User;
|
||||
use App\Models\Transaksi;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Transaksi>
|
||||
*/
|
||||
class TransaksiFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected $model = Transaksi::class;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
|
||||
$sales = Sales::inRandomOrder()->first();
|
||||
$kasir = User::inRandomOrder()->first();
|
||||
|
||||
$date = $this->faker->dateTimeBetween('-3 months');
|
||||
$ongkos_bikin = $this->faker->numberBetween(8, 12) * 10000;
|
||||
|
||||
return [
|
||||
'id_kasir' => $kasir?->id,
|
||||
'id_sales' => $sales?->id,
|
||||
'nama_sales' => $sales?->nama ?? $this->faker->name(),
|
||||
'nama_sales' => $sales?->nama,
|
||||
'kode_transaksi' => 'bwabwa' . $this->faker->unique()->numberBetween(1, 9999), // temporary, will be updated in configure()
|
||||
'nama_pembeli' => $this->faker->name(),
|
||||
'no_hp' => $this->faker->phoneNumber(),
|
||||
'alamat' => $this->faker->address(),
|
||||
'ongkos_bikin' => $this->faker->randomFloat(2, 0, 1000000),
|
||||
'total_harga' => $this->faker->randomFloat(2, 100000, 5000000),
|
||||
'created_at' => now(),
|
||||
'ongkos_bikin' => $ongkos_bikin,
|
||||
'total_harga' => $ongkos_bikin,
|
||||
'created_at' => $date,
|
||||
'updated_at' => $date,
|
||||
];
|
||||
}
|
||||
|
||||
public function configure()
|
||||
{
|
||||
return $this->afterCreating(function (Transaksi $transaksi) {
|
||||
// generate kode transaksi TRS202509090001
|
||||
$prefix = "TRS";
|
||||
$date = $transaksi->created_at->format('Ymd');
|
||||
$number = str_pad($transaksi->id, 4, '0', STR_PAD_LEFT);
|
||||
|
||||
$transaksi->kode_transaksi = $prefix . $date . $number;
|
||||
$transaksi->save();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('kategoris', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('nama', 100);
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('kategoris');
|
||||
}
|
||||
};
|
||||
@ -14,7 +14,7 @@ return new class extends Migration
|
||||
Schema::create('produks', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('nama', 100);
|
||||
$table->enum('kategori', ['cincin', 'gelang', 'kalung', 'anting']);
|
||||
$table->foreignId('id_kategori')->constrained('kategoris');
|
||||
$table->float('berat');
|
||||
$table->integer('kadar');
|
||||
$table->double('harga_per_gram');
|
||||
|
||||
@ -15,7 +15,6 @@ return new class extends Migration
|
||||
$table->id();
|
||||
$table->foreignId('id_produk')->constrained('produks')->cascadeOnDelete();
|
||||
$table->foreignId('id_nampan')->nullable()->constrained('nampans');
|
||||
$table->boolean('is_sold')->default(false);
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -14,8 +14,9 @@ return new class extends Migration
|
||||
Schema::create('item_transaksis', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('id_transaksi')->constrained('transaksis')->onDelete('cascade');
|
||||
$table->foreignId('id_item')->constrained('items');
|
||||
$table->foreignId('id_produk')->constrained('produks');
|
||||
$table->double('harga_deal');
|
||||
$table->string('posisi_asal', 100);
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
Schema::table('items', function (Blueprint $table) {
|
||||
$table->string('kode_item')->unique()->after('id');
|
||||
});
|
||||
}
|
||||
|
||||
public function down()
|
||||
{
|
||||
Schema::table('items', function (Blueprint $table) {
|
||||
$table->dropColumn('kode_item');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
Schema::table('transaksis', function (Blueprint $table) {
|
||||
$table->string('kode_transaksi')->unique()->after('id');
|
||||
});
|
||||
}
|
||||
|
||||
public function down()
|
||||
{
|
||||
Schema::table('transaksis', function (Blueprint $table) {
|
||||
$table->dropColumn('kode_transaksi');
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
};
|
||||
@ -3,6 +3,7 @@
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\Item;
|
||||
use App\Models\Kategori;
|
||||
use App\Models\Nampan;
|
||||
use App\Models\Produk;
|
||||
use App\Models\Sales;
|
||||
@ -19,48 +20,87 @@ 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();
|
||||
Sales::factory(5)->create();
|
||||
|
||||
$kodeNampan = ['A', 'B'];
|
||||
foreach ($kodeNampan as $kode) {
|
||||
for ($i=0; $i < 4; $i++) {
|
||||
for ($i=0; $i < 30; $i++) {
|
||||
if ($i != 12) {
|
||||
Nampan::factory()->create([
|
||||
'nama' => $kode . ($i + 1) // A1, A2, ... B4
|
||||
'nama' => 'A' . ($i + 1)
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
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'
|
||||
];
|
||||
}
|
||||
$produk->foto()->createMany($fotoData);
|
||||
|
||||
$jumlah_item = rand(1, 20);
|
||||
Item::factory($jumlah_item)->create([
|
||||
'id_produk' => $produk->id,
|
||||
'is_sold' => false,
|
||||
$kategoriList = ['Cincin', 'Gelang Rantai', 'Gelang Bulat', 'Kalung', 'Liontin', 'Anting', 'Giwang'];
|
||||
foreach ($kategoriList as $kategori) {
|
||||
Kategori::factory()->create([
|
||||
'nama' => $kategori
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
// 30% peluang item masuk nampan, sisanya di brankas
|
||||
// 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://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);
|
||||
|
||||
// $jumlah_item = rand(1, 20);
|
||||
// Item::factory($jumlah_item)->create([
|
||||
// 'id_produk' => $produk->id,
|
||||
// ]);
|
||||
// });
|
||||
|
||||
$produk1 = Produk::factory()->create([
|
||||
'nama'=>'Gelang serut daun shimmer mp (mas putih)',
|
||||
'id_kategori'=>Kategori::find(2),
|
||||
'berat'=>1.4,
|
||||
'kadar'=>8,
|
||||
'harga_per_gram'=>900000,
|
||||
'harga_jual'=>1260000,
|
||||
]);
|
||||
$produk1->foto()->create([
|
||||
'id_produk'=>$produk1->id,
|
||||
'url'=>'https://i.imgur.com/eGYHzvw.jpeg'
|
||||
]);
|
||||
|
||||
$produk2 = Produk::factory()->create([
|
||||
'nama'=>'Gelang rantai 5 buah clover merah',
|
||||
'id_kategori'=>Kategori::find(2),
|
||||
'berat'=>3.6,
|
||||
'kadar'=>8,
|
||||
'harga_per_gram'=>850000,
|
||||
'harga_jual'=>3060000,
|
||||
]);
|
||||
$produk2->foto()->create([
|
||||
'id_produk'=>$produk2->id,
|
||||
'url'=>'https://i.imgur.com/UjQzYoE.jpeg'
|
||||
]);
|
||||
|
||||
Item::factory(500)->create();
|
||||
|
||||
// 75% peluang item masuk nampan, sisanya di brankas
|
||||
$nampans = Nampan::all()->pluck('id')->toArray();
|
||||
$jumlahNampan = count($nampans);
|
||||
$counter = 0;
|
||||
|
||||
foreach (Item::all() as $item) {
|
||||
if (rand(1, 100) <= 30) {
|
||||
if (rand(1, 100) <= 75) {
|
||||
$item->update([
|
||||
'id_nampan' => $nampans[$counter % $jumlahNampan],
|
||||
]);
|
||||
@ -68,17 +108,21 @@ class DatabaseSeeder extends Seeder
|
||||
}
|
||||
}
|
||||
|
||||
Transaksi::factory(20)->create()->each(function ($transaksi) {
|
||||
$jumlah_item = rand(1, 5);
|
||||
$items = Item::where('is_sold', false)->inRandomOrder()->limit($jumlah_item)->get();
|
||||
Transaksi::factory(250)->create()->each(function ($transaksi) {
|
||||
$jumlah_item = rand(1, 2);
|
||||
$items = Item::with('produk')->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,
|
||||
'id_produk' => $item->produk->id,
|
||||
'harga_deal' => $item->produk->harga_jual,
|
||||
'posisi_asal' => $item->id_nampan ? $item->nampan->nama : 'Brankas',
|
||||
]);
|
||||
$item->update(['is_sold' => true]);
|
||||
$item->delete();
|
||||
$total_harga += $item->produk->harga_jual;
|
||||
}
|
||||
$transaksi->update(['total_harga' => $total_harga]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
56
docker-compose.yml
Normal file
@ -0,0 +1,56 @@
|
||||
services:
|
||||
laravel:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: laravel_app_prod
|
||||
volumes:
|
||||
- ./storage:/var/www/html/storage
|
||||
ports:
|
||||
- "9000"
|
||||
depends_on:
|
||||
- mysql
|
||||
environment:
|
||||
APP_ENV: production
|
||||
APP_DEBUG: false
|
||||
APP_KEY: ${APP_KEY}
|
||||
DB_CONNECTION: mysql
|
||||
DB_HOST: mysql
|
||||
DB_PORT: 3306
|
||||
DB_DATABASE: ${DB_DATABASE}
|
||||
DB_USERNAME: ${DB_USERNAME}
|
||||
DB_PASSWORD: ${DB_PASSWORD}
|
||||
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: nginx_prod
|
||||
ports:
|
||||
- "80:80"
|
||||
volumes:
|
||||
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
- ./storage:/var/www/html/storage:ro
|
||||
depends_on:
|
||||
- laravel
|
||||
|
||||
mysql:
|
||||
image: mysql:8
|
||||
container_name: mysql_db_prod
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
|
||||
MYSQL_DATABASE: ${DB_DATABASE}
|
||||
MYSQL_USER: ${DB_USERNAME}
|
||||
MYSQL_PASSWORD: ${DB_PASSWORD}
|
||||
ports:
|
||||
- "3306:3306"
|
||||
volumes:
|
||||
- mysql_data:/var/lib/mysql
|
||||
|
||||
redis:
|
||||
image: redis:alpine
|
||||
container_name: redis_prod
|
||||
ports:
|
||||
- "6379:6379"
|
||||
|
||||
volumes:
|
||||
mysql_data:
|
||||
21
nginx.conf
Normal file
@ -0,0 +1,21 @@
|
||||
server {
|
||||
listen 80;
|
||||
index index.php index.html;
|
||||
error_log /var/log/nginx/error.log;
|
||||
access_log /var/log/nginx/access.log;
|
||||
root /var/www/html/public;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.php?$query_string;
|
||||
}
|
||||
|
||||
location ~ \.php$ {
|
||||
try_files $uri =404;
|
||||
fastcgi_split_path_info ^(.+\.php)(/.+)$;
|
||||
fastcgi_pass laravel:9000;
|
||||
fastcgi_index index.php;
|
||||
include fastcgi_params;
|
||||
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
||||
fastcgi_param PATH_INFO $fastcgi_path_info;
|
||||
}
|
||||
}
|
||||
1540
package-lock.json
generated
@ -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",
|
||||
|
||||
BIN
public/logo.ico
Normal file
|
After Width: | Height: | Size: 215 KiB |
@ -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
|
After Width: | Height: | Size: 138 KiB |
BIN
resources/images/logo_bca.png
Normal file
|
After Width: | Height: | Size: 120 KiB |
BIN
resources/images/logo_bni.png
Normal file
|
After Width: | Height: | Size: 109 KiB |
BIN
resources/images/logo_bri.png
Normal file
|
After Width: | Height: | Size: 108 KiB |
BIN
resources/images/logo_mandiri.png
Normal file
|
After Width: | Height: | Size: 107 KiB |
BIN
resources/images/logo_mastercard.png
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
resources/images/logo_visa.png
Normal file
|
After Width: | Height: | Size: 112 KiB |
@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div id="app">
|
||||
<div>
|
||||
<router-view />
|
||||
</div>
|
||||
</template>
|
||||
@ -1,35 +1,175 @@
|
||||
<template>
|
||||
<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"
|
||||
>
|
||||
<!-- Gambar -->
|
||||
<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 v-else>
|
||||
<!-- Alert Section -->
|
||||
<div class="mb-4" v-if="alert">
|
||||
<div v-if="alert.error" class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative mb-4" role="alert">
|
||||
<strong class="font-bold">Error!</strong>
|
||||
<span class="block sm:inline">{{ alert.error }}</span>
|
||||
</div>
|
||||
<div v-if="alert.success" class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded relative mb-4" role="alert">
|
||||
<strong class="font-bold">Success!</strong>
|
||||
<span class="block sm:inline">{{ alert.success }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Statistik Brankas -->
|
||||
<div class="bg-A border border-C rounded-xl p-4 mb-6">
|
||||
<div class="flex flex-row sm:items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<img
|
||||
:src="item.image"
|
||||
alt="Product Image"
|
||||
class="w-12 h-12 object-contain"
|
||||
/>
|
||||
<!-- Info produk -->
|
||||
<div class="p-2 bg-A rounded-lg">
|
||||
<i class="fas fa-archive text-D"></i>
|
||||
</div>
|
||||
<div class="flex flex-col sm:flex-row sm:gap-6 text-sm text-gray-600">
|
||||
<span>Total Item di brankas: {{ filteredItems.length }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-2xl font-bold text-D">{{ totalWeight }}g</div>
|
||||
<div class="text-sm text-gray-500">Total Berat</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Daftar Item -->
|
||||
<div v-if="filteredItems.length === 0" class="text-center text-gray-500 py-[120px]">
|
||||
{{ props.search ? 'Item tidak ditemukan.' : 'Brankas kosong.' }}
|
||||
</div>
|
||||
|
||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
<div v-for="item in filteredItems" :key="item.id"
|
||||
class="flex justify-between items-center border border-C 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-cover rounded"
|
||||
@error="handleImageError" />
|
||||
<div class="size-12 bg-gray-200 rounded flex items-center justify-center" v-else>
|
||||
<i class="fas fa-image text-gray-400"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-semibold">{{ item.produk.nama }}</p>
|
||||
<p class="text-sm text-gray-500">{{ item.produk.id }}</p>
|
||||
<p class="font-semibold text-D">{{ item.produk?.nama }}</p>
|
||||
<p class="text-sm text-gray-500 font-semibold">{{ item.kode_item }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Berat -->
|
||||
<span class="font-medium">{{ item.berat }}g</span>
|
||||
<span class="font-medium text-D">{{ item.produk?.berat }}g</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal Pindah Nampan -->
|
||||
<div v-if="isPopupVisible" class="fixed inset-0 bg-black/75 flex items-center justify-center p-4 z-50 backdrop-blur-sm">
|
||||
<div class="bg-white rounded-xl shadow-lg max-w-sm w-full p-6 relative transform transition-all duration-300 scale-95 opacity-0 animate-fadeIn">
|
||||
<!-- QR Code -->
|
||||
<div class="flex justify-center mb-4">
|
||||
<div class="p-2 border border-C rounded-lg">
|
||||
<img :src="qrCodeUrl" alt="QR Code" class="size-36" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info Produk -->
|
||||
<div class="text-center text-D font-bold text-lg mb-1">
|
||||
{{ selectedItem?.kode_item }}
|
||||
</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>
|
||||
|
||||
<!-- Tombol Cetak -->
|
||||
<div class="flex justify-center mb-4">
|
||||
<button @click="printQR" class="bg-D text-A px-4 py-2 rounded hover:bg-D/80 transition">
|
||||
<i class="fas fa-print mr-2"></i>Cetak
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Dropdown pilih nampan -->
|
||||
<div class="mb-4">
|
||||
<label for="tray-select" class="block text-sm font-medium text-D mb-2">
|
||||
Pindah ke Nampan
|
||||
</label>
|
||||
<select id="tray-select" v-model="selectedTrayId"
|
||||
class="w-full px-3 py-2 border border-C rounded-md shadow-sm focus:border-D focus:ring focus:ring-D/20 focus:ring-opacity-50">
|
||||
<option value="" disabled>Pilih Nampan</option>
|
||||
<option v-for="tray in trays" :key="tray.id" :value="tray.id">
|
||||
{{ tray.nama }}
|
||||
</option>
|
||||
</select>
|
||||
<p v-if="errorMove" class="text-red-500 text-sm mt-1">{{ errorMove }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Tombol -->
|
||||
<!-- Tombol -->
|
||||
<div class="flex justify-end gap-2">
|
||||
<button @click="closePopup" class="px-4 py-2 rounded border border-gray-300 text-gray-700 hover:bg-gray-50 transition">
|
||||
Batal
|
||||
</button>
|
||||
|
||||
<button @click="showDeleteConfirm = true"
|
||||
class="px-4 py-2 rounded bg-red-500 text-white hover:bg-red-600 transition flex items-center">
|
||||
<i class="fas fa-trash mr-2"></i>Hapus
|
||||
</button>
|
||||
|
||||
<button @click="saveMove" :disabled="!selectedTrayId || isMoving"
|
||||
class="px-4 py-2 rounded text-D transition flex items-center"
|
||||
:class="(selectedTrayId && !isMoving) ? 'bg-C hover:bg-C/80' : 'bg-gray-400 cursor-not-allowed'">
|
||||
<div v-if="isMoving" class="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||
{{ isMoving ? 'Memindahkan...' : 'Pindahkan' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<!-- Modal Konfirmasi Hapus -->
|
||||
<ConfirmDeleteModal
|
||||
:isOpen="showDeleteConfirm"
|
||||
title="Konfirmasi Hapus Item"
|
||||
message="Apakah kamu yakin ingin menghapus item ini?"
|
||||
confirmText="Ya, Hapus"
|
||||
cancelText="Batal"
|
||||
@confirm="confirmDelete"
|
||||
@cancel="cancelDelete"
|
||||
/>
|
||||
|
||||
<!-- Confirm Modal untuk aksi berbahaya (jika diperlukan di masa depan) -->
|
||||
<div v-if="isConfirmModalVisible" class="fixed inset-0 bg-black/75 flex items-center justify-center p-4 z-50 backdrop-blur-sm">
|
||||
<div class="bg-white rounded-xl shadow-lg max-w-md w-full p-6 transform transition-all duration-300 scale-95 opacity-0 animate-fadeIn">
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="flex-shrink-0 w-10 h-10 mx-auto bg-red-100 rounded-full flex items-center justify-center">
|
||||
<i class="fas fa-exclamation-triangle text-red-600"></i>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-lg font-medium text-D">{{ confirmModalTitle }}</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<p class="text-sm text-gray-500" v-html="confirmModalMessage"></p>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button @click="closeConfirmModal" class="px-4 py-2 rounded border border-gray-300 text-gray-700 hover:bg-gray-50 transition">
|
||||
{{ cancelText }}
|
||||
</button>
|
||||
<button @click="handleConfirmAction" class="px-4 py-2 rounded bg-red-500 hover:bg-red-600 text-white transition">
|
||||
{{ confirmText }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
||||
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import axios from "axios";
|
||||
import ConfirmDeleteModal from './ConfirmDeleteModal.vue';
|
||||
|
||||
const props = defineProps({
|
||||
search: {
|
||||
@ -39,26 +179,249 @@ const props = defineProps({
|
||||
});
|
||||
|
||||
const items = ref([]);
|
||||
const trays = ref([]);
|
||||
const loading = ref(true);
|
||||
const error = ref(null);
|
||||
const alert = ref(null);
|
||||
const timer = 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 pindah
|
||||
const isPopupVisible = ref(false);
|
||||
const selectedItem = ref(null);
|
||||
const selectedTrayId = ref("");
|
||||
const errorMove = ref("");
|
||||
const isMoving = ref(false);
|
||||
|
||||
} catch (err) {
|
||||
error.value = err.message || "Gagal mengambil data";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
const showDeleteConfirm = ref(false);
|
||||
|
||||
// State modal konfirmasi
|
||||
const isConfirmModalVisible = ref(false);
|
||||
const confirmModalTitle = ref("");
|
||||
const confirmModalMessage = ref("");
|
||||
const confirmText = ref("Ya, Konfirmasi");
|
||||
const cancelText = ref("Batal");
|
||||
|
||||
// QR Code generator
|
||||
const qrCodeUrl = computed(() => {
|
||||
if (selectedItem.value) {
|
||||
const data = selectedItem.value.kode_item;
|
||||
return `https://api.qrserver.com/v1/create-qr-code/?size=150x150&data=${encodeURIComponent(data)}`;
|
||||
}
|
||||
return "";
|
||||
});
|
||||
|
||||
// Computed untuk statistik
|
||||
const totalWeight = computed(() => {
|
||||
const total = filteredItems.value.reduce((sum, item) => {
|
||||
return sum + (item?.produk?.berat || 0);
|
||||
}, 0);
|
||||
return total.toFixed(2);
|
||||
});
|
||||
|
||||
const filteredItems = computed(() => {
|
||||
if (!props.search) return items.value;
|
||||
return items.value.filter((item) =>
|
||||
item.produk?.nama?.toLowerCase().includes(props.search.toLowerCase())
|
||||
item.produk?.nama?.toLowerCase().includes(props.search.toLowerCase()) ||
|
||||
item.kode_item?.toLowerCase().includes(props.search.toLowerCase())
|
||||
);
|
||||
});
|
||||
|
||||
// Fungsi modal pindah
|
||||
const openMovePopup = (item) => {
|
||||
selectedItem.value = item;
|
||||
selectedTrayId.value = "";
|
||||
errorMove.value = "";
|
||||
isPopupVisible.value = true;
|
||||
};
|
||||
|
||||
const closePopup = () => {
|
||||
isPopupVisible.value = false;
|
||||
selectedItem.value = null;
|
||||
selectedTrayId.value = "";
|
||||
errorMove.value = "";
|
||||
isMoving.value = false;
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!selectedItem.value) return;
|
||||
|
||||
try {
|
||||
// Panggil API hapus item
|
||||
await axios.delete(`/api/item/${selectedItem.value.id}`, {
|
||||
headers: { Authorization: `Bearer ${localStorage.getItem("token")}` },
|
||||
});
|
||||
|
||||
// Tampilkan alert sukses
|
||||
alert.value = { success: `Item ${selectedItem.value.kode_item} berhasil dihapus.` };
|
||||
|
||||
// Refresh data
|
||||
await refreshData();
|
||||
|
||||
// Tutup modal & popup
|
||||
showDeleteConfirm.value = false;
|
||||
closePopup();
|
||||
|
||||
// Auto hide alert
|
||||
clearTimeout(timer.value);
|
||||
timer.value = setTimeout(() => { alert.value = null; }, 3000);
|
||||
|
||||
} catch (err) {
|
||||
console.error("Gagal menghapus item:", err.response?.data || err);
|
||||
alert.value = { error: err.response?.data?.message || "Gagal menghapus item. Silakan coba lagi." };
|
||||
|
||||
// Auto hide alert error
|
||||
clearTimeout(timer.value);
|
||||
timer.value = setTimeout(() => { alert.value = null; }, 5000);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const cancelDelete = () => {
|
||||
showDeleteConfirm.value = false;
|
||||
};
|
||||
|
||||
const saveMove = async () => {
|
||||
if (!selectedTrayId.value || !selectedItem.value || isMoving.value) return;
|
||||
|
||||
errorMove.value = "";
|
||||
isMoving.value = true;
|
||||
|
||||
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")}` },
|
||||
}
|
||||
);
|
||||
|
||||
// Tampilkan alert sukses
|
||||
const trayName = trays.value.find(t => t.id === selectedTrayId.value)?.nama;
|
||||
alert.value = { success: `Item berhasil dipindahkan ke nampan "${trayName}"` };
|
||||
|
||||
await refreshData();
|
||||
closePopup();
|
||||
|
||||
// Auto hide alert
|
||||
clearTimeout(timer.value);
|
||||
timer.value = setTimeout(() => { alert.value = null; }, 3000);
|
||||
|
||||
} catch (err) {
|
||||
console.error("Gagal memindahkan item:", err.response?.data || err);
|
||||
errorMove.value = err.response?.data?.message || "Gagal memindahkan item. Silakan coba lagi.";
|
||||
} finally {
|
||||
isMoving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Fungsi modal konfirmasi
|
||||
const closeConfirmModal = () => {
|
||||
isConfirmModalVisible.value = false;
|
||||
confirmModalTitle.value = "";
|
||||
confirmModalMessage.value = "";
|
||||
confirmText.value = "Ya, Konfirmasi";
|
||||
cancelText.value = "Batal";
|
||||
};
|
||||
|
||||
const handleConfirmAction = async () => {
|
||||
// Implementasi aksi konfirmasi jika diperlukan
|
||||
closeConfirmModal();
|
||||
};
|
||||
|
||||
// Fungsi utilitas
|
||||
const printQR = () => {
|
||||
if (qrCodeUrl.value) {
|
||||
const printWindow = window.open('', '_blank');
|
||||
printWindow.document.write(`
|
||||
<html>
|
||||
<head>
|
||||
<title>Print QR Code - ${selectedItem.value.kode_item}</title>
|
||||
<style>
|
||||
@page {
|
||||
size: 60mm 50mm;
|
||||
margin: 1mm;
|
||||
}
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
}
|
||||
.qr-container {
|
||||
text-align: center;
|
||||
}
|
||||
.qr-img {
|
||||
width: 40mm;
|
||||
height: 40mm;
|
||||
margin-bottom: 2mm;
|
||||
}
|
||||
.item-info {
|
||||
font-size: 14pt;
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="qr-container">
|
||||
<img class="qr-img" src="${qrCodeUrl.value}" alt="QR Code"
|
||||
onload="window.print()" />
|
||||
<div class="item-info">
|
||||
${selectedItem.value.kode_item}
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
printWindow.document.close();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const handleImageError = (event) => {
|
||||
event.target.style.display = 'none';
|
||||
};
|
||||
|
||||
// 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")}` },
|
||||
}),
|
||||
]);
|
||||
|
||||
// Filter hanya item yang ada di brankas (id_nampan = null atau tidak ada)
|
||||
items.value = itemRes.data.filter(item => !item.id_nampan);
|
||||
trays.value = trayRes.data;
|
||||
} catch (err) {
|
||||
console.error("Error fetching data:", err);
|
||||
alert.value = { error: err.response?.data?.message || "Gagal mengambil data" };
|
||||
clearTimeout(timer.value);
|
||||
timer.value = setTimeout(() => { alert.value = null; }, 5000);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(refreshData);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: scale(0.95); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
.animate-fadeIn {
|
||||
animation: fadeIn 0.25s ease-out forwards;
|
||||
}
|
||||
</style>
|
||||
|
||||
60
resources/js/components/ConfirmDeleteModal.vue
Normal file
@ -0,0 +1,60 @@
|
||||
<template>
|
||||
<div v-if="isOpen" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<div class="bg-white rounded-2xl shadow-xl w-full max-w-md p-6 transform transition-all">
|
||||
<h2 class="text-xl font-bold text-gray-800 mb-3 text-center">
|
||||
{{ title }}
|
||||
</h2>
|
||||
|
||||
<p class="text-gray-600 text-sm mb-6 text-center leading-relaxed" v-html="message"></p>
|
||||
|
||||
<div class="flex justify-center gap-3">
|
||||
<button @click="$emit('cancel')"
|
||||
class="px-5 py-2 rounded-lg font-semibold border border-gray-300 text-gray-700 hover:bg-gray-100 transition">
|
||||
{{ cancelText }}
|
||||
</button>
|
||||
<button @click="$emit('confirm')"
|
||||
class="px-5 py-2 rounded-lg font-semibold bg-red-500 text-white hover:bg-red-600 transition">
|
||||
{{ confirmText }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
isOpen: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
message: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
confirmText: {
|
||||
type: String,
|
||||
default: 'Ya, Konfirmasi',
|
||||
},
|
||||
cancelText: {
|
||||
type: String,
|
||||
default: 'Batal',
|
||||
},
|
||||
});
|
||||
|
||||
// Mendefinisikan events yang dapat di-emit oleh komponen
|
||||
defineEmits(['confirm', 'cancel']);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: scale(0.95); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
.animate-fadeIn {
|
||||
animation: fadeIn 0.25s ease-out forwards;
|
||||
}
|
||||
</style>
|
||||
152
resources/js/components/CreateAkun.vue
Normal file
@ -0,0 +1,152 @@
|
||||
<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 -->
|
||||
<div>
|
||||
<label for="nama" class="block text-sm font-medium">Nama</label>
|
||||
<InputField
|
||||
v-model="form.nama"
|
||||
id="nama"
|
||||
type="text"
|
||||
:required="true"
|
||||
@input="clearError('nama')"
|
||||
/>
|
||||
<p v-if="errors.nama" class="text-red-500 text-sm">{{ errors.nama }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Password -->
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium">Password</label>
|
||||
<InputPassword
|
||||
v-model="form.password"
|
||||
id="password"
|
||||
type="password"
|
||||
:required="true"
|
||||
@input="clearError('password')"
|
||||
/>
|
||||
<p v-if="errors.password" class="text-red-500 text-sm">{{ errors.password }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Role -->
|
||||
<div>
|
||||
<label for="peran" class="block text-sm font-medium">Peran</label>
|
||||
<InputSelect
|
||||
v-model="form.role"
|
||||
:options="[
|
||||
{ value: 'owner', label: 'Owner' },
|
||||
{ value: 'kasir', label: 'Kasir' },
|
||||
]"
|
||||
placeholder="-- Pilih Peran --"
|
||||
@change="clearError('role')"
|
||||
/>
|
||||
<p v-if="errors.role" class="text-red-500 text-sm">{{ errors.role }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 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-C hover:bg-C/80 text-white px-4 py-2 rounded"
|
||||
>
|
||||
Simpan
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Error global -->
|
||||
<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";
|
||||
import InputPassword from "./InputPassword.vue";
|
||||
|
||||
export default {
|
||||
name: "CreateAkun",
|
||||
components: { InputField, InputSelect, InputPassword },
|
||||
data() {
|
||||
return {
|
||||
form: { nama: "", password: "", role: "" },
|
||||
errors: { nama: "", password: "", role: "" },
|
||||
errorMessage: "",
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
clearError(field) {
|
||||
this.errors[field] = "";
|
||||
this.errorMessage = "";
|
||||
},
|
||||
validateForm() {
|
||||
let valid = true;
|
||||
this.errors = { nama: "", password: "", role: "" };
|
||||
|
||||
if (!this.form.nama) {
|
||||
this.errors.nama = "Nama wajib diisi";
|
||||
valid = false;
|
||||
}
|
||||
if (!this.form.password) {
|
||||
this.errors.password = "Password wajib diisi";
|
||||
valid = false;
|
||||
} else if (this.form.password.length < 6) {
|
||||
this.errors.password = "Password minimal 6 karakter";
|
||||
valid = false;
|
||||
}
|
||||
if (!this.form.role) {
|
||||
this.errors.role = "Role wajib dipilih";
|
||||
valid = false;
|
||||
} else if (!["owner", "kasir"].includes(this.form.role)) {
|
||||
this.errors.role = "Role harus owner atau kasir";
|
||||
valid = false;
|
||||
}
|
||||
|
||||
return valid;
|
||||
},
|
||||
async createAkun() {
|
||||
if (!this.validateForm()) return;
|
||||
|
||||
try {
|
||||
await axios.post("/api/user", this.form, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||
},
|
||||
});
|
||||
|
||||
// reset form
|
||||
this.form = { nama: "", password: "", role: "" };
|
||||
this.$emit("refresh");
|
||||
this.$emit("close");
|
||||
} catch (err) {
|
||||
if (err.response?.status === 422 && err.response.data.errors) {
|
||||
// tampilkan error validasi backend
|
||||
const backendErrors = err.response.data.errors;
|
||||
Object.keys(backendErrors).forEach((key) => {
|
||||
this.errors[key] = backendErrors[key][0];
|
||||
});
|
||||
} else {
|
||||
this.errorMessage =
|
||||
err.response?.data?.message || "Gagal menambah akun.";
|
||||
}
|
||||
console.error("Gagal tambah akun:", err);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
243
resources/js/components/CreateItemModal.vue
Normal file
@ -0,0 +1,243 @@
|
||||
<template>
|
||||
<Modal :active="isOpen" size="md" @close="handleClose" clickOutside="false">
|
||||
<div class="p-6">
|
||||
|
||||
<div v-if="!success">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">Item {{ product?.nama }}</h3>
|
||||
<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">
|
||||
<h4 class="text-lg font-semibold text-gray-900 mb-2">Item Berhasil Dibuat!</h4>
|
||||
|
||||
<!-- QR Code -->
|
||||
<div class="flex justify-center mb-4">
|
||||
<div class="p-2 border border-gray-300 rounded-lg">
|
||||
<img :src="qrCodeUrl" alt="QR Code" class="w-36 h-36" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Item Info -->
|
||||
<div class="text-center text-gray-700 font-medium mb-1">
|
||||
{{ createdItem?.kode_item }}
|
||||
</div>
|
||||
<div class="text-center text-gray-500 text-sm mb-6">
|
||||
{{ product?.nama }} - {{ createdItem?.nampan?.nama || 'Brankas' }}
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<i class="fas fa-print mr-1"></i>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','itemAdded']);
|
||||
|
||||
// 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);
|
||||
|
||||
// QR Code generator - berdasarkan logika dari brankas list
|
||||
const qrCodeUrl = computed(() => {
|
||||
if (createdItem.value && props.product) {
|
||||
const itemId = createdItem.value.id || createdItem.value.kode_item;
|
||||
const productName = props.product.nama.replace(/\s/g, "");
|
||||
const data = `ITM-${itemId}-${productName}`;
|
||||
return `https://api.qrserver.com/v1/create-qr-code/?size=150x150&data=${encodeURIComponent(data)}`;
|
||||
}
|
||||
return "";
|
||||
});
|
||||
|
||||
// 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);
|
||||
|
||||
emit('itemAdded'); // 🔔 penting
|
||||
|
||||
loadNampanList();
|
||||
} 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 = '';
|
||||
createdItem.value = null;
|
||||
};
|
||||
|
||||
// Fungsi print berdasarkan logika dari brankas list
|
||||
const printItem = () => {
|
||||
if (qrCodeUrl.value && createdItem.value && props.product) {
|
||||
const printWindow = window.open('', '_blank');
|
||||
const itemCode = createdItem.value.kode_item || createdItem.value.id;
|
||||
|
||||
printWindow.document.write(`
|
||||
<html>
|
||||
<head>
|
||||
<title>Print QR Code - ${itemCode}</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
}
|
||||
.qr-container {
|
||||
border: 2px solid #ccc;
|
||||
padding: 20px;
|
||||
display: inline-block;
|
||||
margin: 20px;
|
||||
}
|
||||
.item-info {
|
||||
margin-top: 10px;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="qr-container">
|
||||
<img src="${qrCodeUrl.value}" alt="QR Code" style="width: 200px; height: 200px;" />
|
||||
<div class="item-info">
|
||||
<div style="font-weight: bold; margin-bottom: 5px;">${itemCode}</div>
|
||||
<div>${props.product.nama}</div>
|
||||
<div style="color: #666; margin-top: 5px;">${props.product.berat}g</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
printWindow.document.close();
|
||||
printWindow.print();
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
// Reset state
|
||||
selectedNampan.value = '';
|
||||
success.value = false;
|
||||
loading.value = false;
|
||||
createdItem.value = null;
|
||||
|
||||
emit('close');
|
||||
};
|
||||
|
||||
// Watchers
|
||||
watch(() => props.isOpen, (newValue) => {
|
||||
if (newValue) {
|
||||
selectedNampan.value = '';
|
||||
success.value = false;
|
||||
loading.value = false;
|
||||
createdItem.value = null;
|
||||
|
||||
loadNampanList();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
82
resources/js/components/CreateKategori.vue
Normal file
@ -0,0 +1,82 @@
|
||||
<template>
|
||||
|
||||
<div v-if="isOpen" class="fixed inset-0 flex items-center justify-center bg-black/65 z-50">
|
||||
<div class="bg-white rounded-lg shadow-lg w-96 p-6 relative">
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-lg font-bold text-gray-800">
|
||||
{{ product ? 'Edit Kategori' : 'Tambah Kategori Baru' }}
|
||||
</h2>
|
||||
<button @click="emit('close')" class="text-gray-400 hover:text-gray-600">✕</button>
|
||||
</div>
|
||||
|
||||
<!-- Form -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Nama Kategori</label>
|
||||
<InputField
|
||||
v-model="form.nama"
|
||||
type="text"
|
||||
placeholder="Masukkan nama kategori"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Buttons -->
|
||||
<div class="flex justify-end gap-2 mt-4">
|
||||
<button
|
||||
@click="emit('close')"
|
||||
class="px-4 py-2 bg-gray-200 text-gray-700 rounded hover:bg-gray-300"
|
||||
>
|
||||
Batal
|
||||
</button>
|
||||
<button
|
||||
@click="saveKategori"
|
||||
:disabled="!form.nama"
|
||||
class="px-4 py-2 bg-C text-black rounded hover:bg-B"
|
||||
>
|
||||
Simpan
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import axios from 'axios'
|
||||
import InputField from './InputField.vue'
|
||||
|
||||
const props = defineProps({
|
||||
isOpen: Boolean,
|
||||
product: Object
|
||||
})
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
const form = ref({ nama: '' })
|
||||
|
||||
// Sync kalau ubah kategori
|
||||
watch(() => props.product, (val) => {
|
||||
form.value.nama = val ? val.nama : ''
|
||||
}, { immediate: true })
|
||||
|
||||
const saveKategori = async () => {
|
||||
try {
|
||||
if (props.product) {
|
||||
await axios.put(`/api/kategori/${props.product.id}`, form.value, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await axios.post('/api/kategori', form.value, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
emit('close') // tutup modal
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
alert('Gagal menyimpan kategori')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
86
resources/js/components/CreateSales.vue
Normal file
@ -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>
|
||||
206
resources/js/components/DatePicker.vue
Normal file
@ -0,0 +1,206 @@
|
||||
<template>
|
||||
<div class="relative" ref="datePickerRef">
|
||||
<!-- Input Display -->
|
||||
<div class="flex gap-2 items-center">
|
||||
<div class="flex-1">
|
||||
<label v-if="label" class="text-D/80 block text-sm font-medium mb-1">{{ label }}</label>
|
||||
<div
|
||||
@click="toggleCalendar"
|
||||
class="w-full px-3 py-2 bg-A text-D border border-B rounded-md cursor-pointer hover:border-C focus-within:border-C focus-within:ring focus-within:ring-D focus-within:ring-opacity-50 transition-colors"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<span v-if="displayText" class="text-sm">{{ displayText }}</span>
|
||||
<span v-else class="text-sm text-D/60">{{ placeholder }}</span>
|
||||
<i class="fas fa-calendar-alt text-D/60"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="errorMessage" class="text-red-500 text-xs mt-1">{{ errorMessage }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Calendar Popup (inline, no teleport) -->
|
||||
<div
|
||||
v-if="showCalendar"
|
||||
ref="popupRef"
|
||||
class="absolute z-[9999] bg-A border border-C rounded-lg shadow-xl p-4 min-w-[300px] mt-2"
|
||||
:class="popupPositionClass"
|
||||
>
|
||||
<!-- Manual Date Inputs -->
|
||||
<div class="mb-4">
|
||||
<div class="text-sm font-medium text-D mb-2">Pilih Manual</div>
|
||||
<div class="flex gap-3 items-center">
|
||||
<div class="flex-1">
|
||||
<label class="text-xs text-D/80 block mb-1">Dari</label>
|
||||
<input
|
||||
type="date"
|
||||
v-model="tempStartDate"
|
||||
@input="validateDates"
|
||||
:max="maxDate"
|
||||
class="w-full text-xs px-2 py-2 bg-A text-D border border-B rounded focus:border-C focus:outline-none transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<span class="text-D/50 text-sm">s/d</span>
|
||||
<div class="flex-1">
|
||||
<label class="text-xs text-D/80 block mb-1">Sampai</label>
|
||||
<input
|
||||
type="date"
|
||||
v-model="tempEndDate"
|
||||
@input="validateDates"
|
||||
:min="tempStartDate"
|
||||
:max="maxDate"
|
||||
class="w-full text-xs px-2 py-2 bg-A text-D border border-B rounded focus:border-C focus:outline-none transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Range Info -->
|
||||
<div v-if="tempStartDate && tempEndDate" class="text-xs text-D/60 mt-2">
|
||||
{{ rangeDaysText }} ({{ formatDisplayDate(tempStartDate) }} - {{ formatDisplayDate(tempEndDate) }})
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex justify-between items-center pt-3 border-t border-C">
|
||||
<button
|
||||
@click="clearDates"
|
||||
class="px-3 py-1 text-xs text-D/60 hover:text-D transition-colors"
|
||||
>
|
||||
Bersihkan
|
||||
</button>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
@click="cancel"
|
||||
class="px-4 py-1 text-xs bg-gray-200 hover:bg-gray-300 text-gray-700 rounded transition-colors"
|
||||
>
|
||||
Batal
|
||||
</button>
|
||||
<button
|
||||
@click="confirm"
|
||||
:disabled="!isValidRange"
|
||||
class="px-4 py-1 text-xs bg-C hover:bg-C/80 text-D rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Terapkan
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Object, default: () => ({ start: '', end: '' }) },
|
||||
label: { type: String, default: 'Pilih Periode' },
|
||||
placeholder: { type: String, default: 'Pilih rentang tanggal' },
|
||||
maxDays: { type: Number, default: 31 },
|
||||
position: { type: String, default: 'left', validator: (v) => ['left', 'right'].includes(v) }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'change'])
|
||||
|
||||
const datePickerRef = ref(null)
|
||||
const showCalendar = ref(false)
|
||||
const tempStartDate = ref('')
|
||||
const tempEndDate = ref('')
|
||||
const errorMessage = ref('')
|
||||
|
||||
const maxDate = computed(() => new Date().toISOString().split('T')[0])
|
||||
|
||||
const displayText = computed(() => {
|
||||
if (props.modelValue.start && props.modelValue.end) {
|
||||
const startFormatted = formatDisplayDate(props.modelValue.start)
|
||||
const endFormatted = formatDisplayDate(props.modelValue.end)
|
||||
return props.modelValue.start === props.modelValue.end
|
||||
? startFormatted
|
||||
: `${startFormatted} - ${endFormatted}`
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
const isValidRange = computed(() => {
|
||||
if (!tempStartDate.value || !tempEndDate.value) return false
|
||||
const start = new Date(tempStartDate.value)
|
||||
const end = new Date(tempEndDate.value)
|
||||
if (start > end) return false
|
||||
const diffDays = Math.ceil((end - start) / (1000 * 60 * 60 * 24)) + 1
|
||||
return diffDays <= props.maxDays
|
||||
})
|
||||
|
||||
const rangeDaysText = computed(() => {
|
||||
if (!tempStartDate.value || !tempEndDate.value) return ''
|
||||
const start = new Date(tempStartDate.value)
|
||||
const end = new Date(tempEndDate.value)
|
||||
const diffDays = Math.ceil((end - start) / (1000 * 60 * 60 * 24)) + 1
|
||||
return diffDays > props.maxDays
|
||||
? `⚠️ Maksimal ${props.maxDays} hari`
|
||||
: `${diffDays} hari`
|
||||
})
|
||||
|
||||
const popupPositionClass = computed(() => props.position === 'right' ? 'right-0' : 'left-0')
|
||||
|
||||
const formatDisplayDate = (dateString) => {
|
||||
const date = new Date(dateString + 'T00:00:00')
|
||||
return date.toLocaleDateString('id-ID', { day: '2-digit', month: 'short', year: 'numeric' })
|
||||
}
|
||||
|
||||
const toggleCalendar = () => {
|
||||
showCalendar.value = !showCalendar.value
|
||||
if (showCalendar.value) {
|
||||
tempStartDate.value = props.modelValue.start
|
||||
tempEndDate.value = props.modelValue.end
|
||||
errorMessage.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const validateDates = () => {
|
||||
errorMessage.value = ''
|
||||
if (!tempStartDate.value || !tempEndDate.value) return
|
||||
const start = new Date(tempStartDate.value)
|
||||
const end = new Date(tempEndDate.value)
|
||||
if (start > end) {
|
||||
errorMessage.value = 'Tanggal akhir harus setelah tanggal mulai'
|
||||
return
|
||||
}
|
||||
const diffDays = Math.ceil((end - start) / (1000 * 60 * 60 * 24)) + 1
|
||||
if (diffDays > props.maxDays) {
|
||||
errorMessage.value = `Maksimal ${props.maxDays} hari`
|
||||
}
|
||||
}
|
||||
|
||||
const clearDates = () => {
|
||||
tempStartDate.value = ''
|
||||
tempEndDate.value = ''
|
||||
errorMessage.value = ''
|
||||
}
|
||||
|
||||
const cancel = () => {
|
||||
showCalendar.value = false
|
||||
errorMessage.value = ''
|
||||
}
|
||||
|
||||
const confirm = () => {
|
||||
if (isValidRange.value) {
|
||||
const newValue = { start: tempStartDate.value, end: tempEndDate.value }
|
||||
emit('update:modelValue', newValue)
|
||||
emit('change', newValue)
|
||||
showCalendar.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleClickOutside = (e) => {
|
||||
if (datePickerRef.value && !datePickerRef.value.contains(e.target)) {
|
||||
showCalendar.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener('click', handleClickOutside))
|
||||
onUnmounted(() => document.removeEventListener('click', handleClickOutside))
|
||||
|
||||
watch(() => props.modelValue, (newValue) => {
|
||||
if (newValue.start !== tempStartDate.value || newValue.end !== tempEndDate.value) {
|
||||
tempStartDate.value = newValue.start
|
||||
tempEndDate.value = newValue.end
|
||||
}
|
||||
}, { deep: true })
|
||||
</script>
|
||||
491
resources/js/components/DetailPerNampan.vue
Normal file
@ -0,0 +1,491 @@
|
||||
<template>
|
||||
<div class="my-6">
|
||||
<hr class="border-B mb-5" />
|
||||
|
||||
<!-- Filter Section -->
|
||||
<div class="flex flex-row my-3 gap-1 md:gap-5 lg:gap-8">
|
||||
<div class="mb-3 w-full">
|
||||
<DatePicker
|
||||
v-model="dateRange"
|
||||
label="Filter Tanggal"
|
||||
placeholder="Pilih rentang tanggal"
|
||||
:max-days="31"
|
||||
@change="handleDateChange"
|
||||
/>
|
||||
</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 v-if="loading">
|
||||
<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="flex gap-4" v-else-if="data?.rekap_interval">
|
||||
<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_interval.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_interval.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_interval.total_pendapatan }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else></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 == 0">
|
||||
<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 DatePicker from './DatePicker.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 dateRange = ref({ start: '', end: '' });
|
||||
const data = ref(null);
|
||||
const loading = ref(false);
|
||||
const loadingExport = ref(false);
|
||||
|
||||
const sortBy = ref(null);
|
||||
const sortOrder = ref('asc');
|
||||
|
||||
const pagination = ref({
|
||||
current_page: 1,
|
||||
last_page: 1,
|
||||
total: 0,
|
||||
});
|
||||
|
||||
const pendapatanWidth = ref(0);
|
||||
const pendapatanElements = ref([]);
|
||||
|
||||
const salesDipilih = ref(0);
|
||||
const opsiSales = ref([
|
||||
{ label: 'Semua Sales', value: 0 },
|
||||
]);
|
||||
|
||||
const produkDipilih = ref(0);
|
||||
const opsiProduk = ref([
|
||||
{ label: 'Semua Produk', value: 0 },
|
||||
]);
|
||||
|
||||
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) {
|
||||
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 handleDateChange = (newDateRange) => {
|
||||
// console.log('Date range changed:', newDateRange);
|
||||
// Reset pagination when date changes
|
||||
pagination.value.current_page = 1;
|
||||
fetchData(1);
|
||||
};
|
||||
|
||||
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: 0 },
|
||||
...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: 0 },
|
||||
...produkData.map(produk => ({
|
||||
label: produk.nama,
|
||||
value: produk.id,
|
||||
}))
|
||||
];
|
||||
} catch (error) {
|
||||
console.error('Gagal mengambil data produk:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchData = async (page = 1) => {
|
||||
if (!dateRange.value.start || !dateRange.value.end) return;
|
||||
|
||||
loading.value = true;
|
||||
pendapatanElements.value = [];
|
||||
|
||||
let queryParams = `start_date=${dateRange.value.start}&end_date=${dateRange.value.end}&page=${page}`;
|
||||
if (salesDipilih.value != 0 ) queryParams += `&sales_id=${salesDipilih.value}`;
|
||||
if (produkDipilih.value != 0) 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,
|
||||
};
|
||||
}
|
||||
// console.log('Data laporan nampan berhasil diambil:', data.value);
|
||||
} catch (error) {
|
||||
console.error('Gagal mengambil data laporan nampan');
|
||||
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) => {
|
||||
if (!dateRange.value.start || !dateRange.value.end) {
|
||||
alert('Silakan pilih rentang tanggal terlebih dahulu');
|
||||
return;
|
||||
}
|
||||
|
||||
exportFormat.value = option.value;
|
||||
isExportOpen.value = false;
|
||||
loadingExport.value = true;
|
||||
|
||||
try {
|
||||
const response = await axios.get(`/api/laporan/export/detail-pernampan`, {
|
||||
params: {
|
||||
start_date: dateRange.value.start,
|
||||
end_date: dateRange.value.end,
|
||||
format: exportFormat.value,
|
||||
page: pagination.value.current_page,
|
||||
sales_id: salesDipilih.value != 0 ? salesDipilih.value : null,
|
||||
produk_id: produkDipilih.value != 0 ? produkDipilih.value : null,
|
||||
nama_pembeli: namaPembeli.value || null,
|
||||
},
|
||||
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_${dateRange.value.start}_to_${dateRange.value.end}_${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);
|
||||
alert('Gagal mengekspor laporan. Silakan coba lagi.');
|
||||
} finally {
|
||||
loadingExport.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const closeDropdownsOnClickOutside = (event) => {
|
||||
if (exportDropdownRef.value && !exportDropdownRef.value.contains(event.target)) {
|
||||
isExportOpen.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// --- Lifecycle Hooks ---
|
||||
onMounted(() => {
|
||||
// Set default date range to today
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
dateRange.value = { start: today, end: today };
|
||||
|
||||
fetchSales();
|
||||
fetchProduk();
|
||||
|
||||
document.addEventListener('click', closeDropdownsOnClickOutside);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', closeDropdownsOnClickOutside);
|
||||
});
|
||||
|
||||
// Watch for filter changes (except date range which has its own handler)
|
||||
watch([salesDipilih, produkDipilih, namaPembeli], () => {
|
||||
if (dateRange.value.start && dateRange.value.end) {
|
||||
pagination.value.current_page = 1; // Reset to first page when filters change
|
||||
fetchData(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Watch for date range changes
|
||||
watch(dateRange, (newDateRange) => {
|
||||
if (newDateRange.start && newDateRange.end) {
|
||||
pagination.value.current_page = 1;
|
||||
fetchData(1);
|
||||
}
|
||||
}, { deep: true, immediate: true });
|
||||
</script>
|
||||
493
resources/js/components/DetailPerProduk.vue
Normal file
@ -0,0 +1,493 @@
|
||||
<template>
|
||||
<div class="my-6">
|
||||
<hr class="border-B mb-5" />
|
||||
|
||||
<!-- Filter Section -->
|
||||
<div class="flex flex-row my-3 gap-1 md:gap-5 lg:gap-8">
|
||||
<div class="mb-3 w-full">
|
||||
<DatePicker
|
||||
v-model="dateRange"
|
||||
label="Filter Tanggal"
|
||||
placeholder="Pilih rentang tanggal"
|
||||
:max-days="31"
|
||||
@change="handleDateChange"
|
||||
/>
|
||||
</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>
|
||||
|
||||
<!-- Export Section -->
|
||||
<div class="flex flex-row items-center justify-between mt-5 gap-3">
|
||||
<!-- Summary Cards -->
|
||||
<div v-if="loading">
|
||||
<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="flex gap-4" v-if="data?.rekap_interval">
|
||||
<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_interval.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_interval.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_interval.total_pendapatan }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else></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_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>
|
||||
|
||||
<!-- 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 DatePicker from './DatePicker.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 dateRange = ref({ start: '', end: '' });
|
||||
const data = ref(null);
|
||||
const loading = ref(false);
|
||||
const loadingExport = ref(false);
|
||||
|
||||
// Sorting state
|
||||
const sortBy = ref(null);
|
||||
const sortOrder = ref('asc');
|
||||
|
||||
const pagination = ref({
|
||||
current_page: 1,
|
||||
last_page: 1,
|
||||
total: 0,
|
||||
});
|
||||
|
||||
const pendapatanWidth = ref(0);
|
||||
const pendapatanElements = ref([]);
|
||||
|
||||
const salesDipilih = ref(0);
|
||||
const opsiSales = ref([
|
||||
{ label: 'Semua Sales', value: 0 },
|
||||
]);
|
||||
|
||||
const nampanDipilih = ref(0);
|
||||
const opsiNampan = ref([
|
||||
{ label: 'Semua Nampan', value: 0 },
|
||||
]);
|
||||
|
||||
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 handleDateChange = (newDateRange) => {
|
||||
// console.log('Date range changed:', newDateRange);
|
||||
// Reset pagination when date changes
|
||||
pagination.value.current_page = 1;
|
||||
fetchData(1);
|
||||
};
|
||||
|
||||
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: 0 },
|
||||
...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: 0 },
|
||||
{ label: 'Brankas', value: -1 },
|
||||
...nampanData.map(nampan => ({
|
||||
label: nampan.nama,
|
||||
value: nampan.id,
|
||||
}))
|
||||
];
|
||||
} catch (error) {
|
||||
console.error('Gagal mengambil data nampan:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchData = async (page = 1) => {
|
||||
if (!dateRange.value.start || !dateRange.value.end) return;
|
||||
|
||||
loading.value = true;
|
||||
pendapatanElements.value = [];
|
||||
|
||||
let queryParams = `start_date=${dateRange.value.start}&end_date=${dateRange.value.end}&page=${page}`;
|
||||
if (salesDipilih.value != 0 ) queryParams += `&sales_id=${salesDipilih.value}`;
|
||||
if (nampanDipilih.value != 0) queryParams += `&nampan_id=${nampanDipilih.value}`;
|
||||
if (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;
|
||||
|
||||
// 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.produk ? response.data.produk.length : 0,
|
||||
};
|
||||
}
|
||||
// console.log('Data laporan produk berhasil diambil:', data.value);
|
||||
} 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) => {
|
||||
if (!dateRange.value.start || !dateRange.value.end) {
|
||||
alert('Silakan pilih rentang tanggal terlebih dahulu');
|
||||
return;
|
||||
}
|
||||
|
||||
exportFormat.value = option.value;
|
||||
isExportOpen.value = false;
|
||||
loadingExport.value = true;
|
||||
|
||||
try {
|
||||
const response = await axios.get('/api/laporan/export/detail-perproduk', {
|
||||
params: {
|
||||
start_date: dateRange.value.start,
|
||||
end_date: dateRange.value.end,
|
||||
format: exportFormat.value,
|
||||
page: pagination.value.current_page,
|
||||
sales_id: salesDipilih.value != 0 ? salesDipilih.value : null,
|
||||
nampan_id: nampanDipilih.value != 0 ? nampanDipilih.value : null,
|
||||
nama_pembeli: namaPembeli.value || null,
|
||||
},
|
||||
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_${dateRange.value.start}_to_${dateRange.value.end}_${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);
|
||||
alert('Gagal mengekspor laporan. Silakan coba lagi.');
|
||||
} finally {
|
||||
loadingExport.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const closeDropdownsOnClickOutside = (event) => {
|
||||
if (exportDropdownRef.value && !exportDropdownRef.value.contains(event.target)) {
|
||||
isExportOpen.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// --- Lifecycle Hooks ---
|
||||
onMounted(() => {
|
||||
// Set default date range to today
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
dateRange.value = { start: today, end: today };
|
||||
|
||||
fetchSales();
|
||||
fetchNampan();
|
||||
|
||||
document.addEventListener('click', closeDropdownsOnClickOutside);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', closeDropdownsOnClickOutside);
|
||||
});
|
||||
|
||||
// Watch for filter changes (except date range which has its own handler)
|
||||
watch([salesDipilih, nampanDipilih, namaPembeli], () => {
|
||||
if (dateRange.value.start && dateRange.value.end) {
|
||||
pagination.value.current_page = 1; // Reset to first page when filters change
|
||||
fetchData(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Watch for date range changes
|
||||
watch(dateRange, (newDateRange) => {
|
||||
if (newDateRange.start && newDateRange.end) {
|
||||
pagination.value.current_page = 1;
|
||||
fetchData(1);
|
||||
}
|
||||
}, { deep: true, immediate: true });
|
||||
</script>
|
||||
210
resources/js/components/EditAkun.vue
Normal file
@ -0,0 +1,210 @@
|
||||
<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">Edit Akun</h2>
|
||||
|
||||
<form @submit.prevent="updateAkun" class="space-y-3">
|
||||
<!-- Nama -->
|
||||
<div>
|
||||
<label for="nama" class="block text-sm font-medium">Nama</label>
|
||||
<InputField
|
||||
v-model="form.nama"
|
||||
id="nama"
|
||||
type="text"
|
||||
:required="true"
|
||||
@input="clearError('nama')"
|
||||
/>
|
||||
<p v-if="errors.nama" class="text-red-500 text-sm">{{ errors.nama }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Password -->
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium">Password</label>
|
||||
<InputPassword
|
||||
v-model="form.password"
|
||||
id="password"
|
||||
type="password"
|
||||
:required="false"
|
||||
@input="clearError('password')"
|
||||
/>
|
||||
<p class="text-sm">Kosongkan jika tidak ingin ubah password</p>
|
||||
<p v-if="errors.password" class="text-red-500 text-sm">{{ errors.password }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Confirm Password -->
|
||||
<div v-if="form.password">
|
||||
<label for="confirmPassword" class="block text-sm font-medium">Konfirmasi Password</label>
|
||||
<InputPassword
|
||||
v-model="form.confirmPassword"
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
:required="false"
|
||||
@input="clearError('confirmPassword')"
|
||||
/>
|
||||
<p v-if="errors.confirmPassword" class="text-red-500 text-sm">
|
||||
{{ errors.confirmPassword }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Role -->
|
||||
<div>
|
||||
<label for="role" class="block text-sm font-medium">Peran</label>
|
||||
|
||||
<!-- 🔒 Kalau akun sendiri tampil readonly -->
|
||||
<template v-if="isEditingSelf">
|
||||
<p class="mt-1 px-3 py-2 border rounded bg-gray-100 text-gray-700">
|
||||
{{ form.role === 'owner' ? 'Owner' : 'Kasir' }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<!-- 🔓 Kalau akun lain bisa diubah -->
|
||||
<template v-else>
|
||||
<InputSelect
|
||||
v-model="form.role"
|
||||
:options="[
|
||||
{ value: 'owner', label: 'Owner' },
|
||||
{ value: 'kasir', label: 'Kasir' }
|
||||
]"
|
||||
placeholder="-- Pilih Peran --"
|
||||
@change="clearError('role')"
|
||||
/>
|
||||
<p v-if="errors.role" class="text-red-500 text-sm">{{ errors.role }}</p>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 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-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:disabled="!isFormValid"
|
||||
>
|
||||
Ubah
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Error global -->
|
||||
<p v-if="errorMessage" class="text-red-500 text-sm mt-3">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import axios from "axios";
|
||||
import InputField from "@/components/InputField.vue";
|
||||
import InputSelect from "@/components/InputSelect.vue";
|
||||
import InputPassword from "./InputPassword.vue";
|
||||
|
||||
const props = defineProps({
|
||||
akun: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["refresh", "close"]);
|
||||
|
||||
const form = ref({
|
||||
nama: props.akun?.nama || "",
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
role: props.akun?.role || "",
|
||||
});
|
||||
|
||||
const errors = ref({ nama: "", password: "", confirmPassword: "", role: "" });
|
||||
const errorMessage = ref("");
|
||||
const loggedInId = ref(localStorage.getItem("userId")); // 🔥 ambil dari localStorage
|
||||
|
||||
const isFormValid = computed(() => {
|
||||
if (form.value.password && form.value.password !== form.value.confirmPassword) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
form.value.nama.trim() &&
|
||||
form.value.role &&
|
||||
!errors.value.nama &&
|
||||
!errors.value.password &&
|
||||
!errors.value.confirmPassword &&
|
||||
!errors.value.role
|
||||
);
|
||||
});
|
||||
|
||||
// 🔥 ini cek apakah akun yang diedit adalah akun sendiri
|
||||
const isEditingSelf = computed(() => {
|
||||
return String(props.akun.id) === String(loggedInId.value);
|
||||
});
|
||||
|
||||
const clearError = (field) => {
|
||||
errors.value[field] = "";
|
||||
errorMessage.value = "";
|
||||
};
|
||||
|
||||
const validateForm = () => {
|
||||
let valid = true;
|
||||
errors.value = { nama: "", password: "", confirmPassword: "", role: "" };
|
||||
|
||||
if (!form.value.nama) {
|
||||
errors.value.nama = "Nama wajib diisi";
|
||||
valid = false;
|
||||
}
|
||||
if (form.value.password && form.value.password.length < 6) {
|
||||
errors.value.password = "Password minimal 6 karakter";
|
||||
valid = false;
|
||||
}
|
||||
if (form.value.password && form.value.password !== form.value.confirmPassword) {
|
||||
errors.value.confirmPassword = "Konfirmasi password tidak cocok";
|
||||
valid = false;
|
||||
}
|
||||
if (!form.value.role) {
|
||||
errors.value.role = "Role wajib dipilih";
|
||||
valid = false;
|
||||
}
|
||||
|
||||
return valid;
|
||||
};
|
||||
|
||||
const updateAkun = async () => {
|
||||
if (!validateForm()) return;
|
||||
|
||||
try {
|
||||
const payload = { ...form.value };
|
||||
if (!payload.password) delete payload.password;
|
||||
delete payload.confirmPassword;
|
||||
|
||||
await axios.put(`/api/user/${props.akun.id}`, payload, {
|
||||
headers: { Authorization: `Bearer ${localStorage.getItem("token")}` },
|
||||
});
|
||||
|
||||
emit("refresh");
|
||||
emit("close");
|
||||
} catch (err) {
|
||||
if (err.response?.status === 422 && err.response.data.errors) {
|
||||
const backendErrors = err.response.data.errors;
|
||||
Object.keys(backendErrors).forEach((key) => {
|
||||
errors.value[key] = backendErrors[key][0];
|
||||
});
|
||||
} else {
|
||||
errorMessage.value = err.response?.data?.message || "Gagal update akun.";
|
||||
}
|
||||
console.error("Gagal update akun:", err);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
// console.log("Akun.id:", props.akun.id);
|
||||
// console.log("LoggedInId:", loggedInId.value);
|
||||
// console.log("isEditingSelf:", isEditingSelf.value);
|
||||
});
|
||||
</script>
|
||||
|
||||
66
resources/js/components/EditKategori.vue
Normal file
@ -0,0 +1,66 @@
|
||||
<template>
|
||||
<div class="fixed inset-0 flex items-center justify-center bg-black/65 z-50">
|
||||
<div class="bg-white rounded-lg shadow-lg w-[400px] p-6 relative">
|
||||
|
||||
<!-- Tombol close -->
|
||||
<button @click="$emit('close')" class="absolute top-3 right-3 text-gray-600 hover:text-black">
|
||||
✕
|
||||
</button>
|
||||
|
||||
<!-- Judul -->
|
||||
<h2 class="text-xl font-bold text-center text-D mb-4">Edit Kategori</h2>
|
||||
|
||||
<!-- Input Nama Kategori -->
|
||||
<div>
|
||||
<label for="editKategori" class="block text-sm font-medium text-D mb-1">Nama Kategori</label>
|
||||
<InputField
|
||||
v-model="editNamaKategori"
|
||||
type="text"
|
||||
id="editKategori"
|
||||
placeholder="Masukkan nama kategori"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Tombol Aksi -->
|
||||
<div class="flex justify-end gap-3">
|
||||
<button @click="$emit('close')" class="px-4 py-2 bg-gray-300 rounded-md hover:bg-gray-400">
|
||||
Batal
|
||||
</button>
|
||||
<button @click="updateKategori" class="px-4 py-2 bg-B text-D rounded-md hover:bg-A">
|
||||
Ubah
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from "vue";
|
||||
import InputField from "./InputField.vue";
|
||||
|
||||
const props = defineProps({
|
||||
kategori: { type: Object, required: true },
|
||||
});
|
||||
|
||||
const editNamaKategori = ref("");
|
||||
|
||||
watch(
|
||||
() => props.kategori,
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
editNamaKategori.value = newVal.nama;
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const updateKategori = () => {
|
||||
if (editNamaKategori.value.trim() === "") {
|
||||
alert("Nama kategori tidak boleh kosong!");
|
||||
return;
|
||||
}
|
||||
|
||||
// Emit hasil update ke parent
|
||||
emit("update", { ...props.kategori, nama: editNamaKategori.value });
|
||||
};
|
||||
</script>
|
||||
78
resources/js/components/EditSales.vue
Normal file
@ -0,0 +1,78 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="fixed inset-0 bg-black/65 flex items-center justify-center z-50"
|
||||
>
|
||||
<div class="bg-white rounded-lg shadow-lg w-full max-w-lg p-6 relative">
|
||||
<h2 class="text-xl font-bold mb-4">Ubah Sales</h2>
|
||||
|
||||
<form @submit.prevent="handleSubmit" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Nama Sales</label>
|
||||
<InputField v-model="form.nama" type="text" class="w-full px-3 py-2 border rounded-md focus:ring-2 focus:ring-[#c6a77d] focus:outline-none" required />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">No HP</label>
|
||||
<InputField v-model="form.no_hp" type="text" class="w-full px-3 py-2 border rounded-md focus:ring-2 focus:ring-[#c6a77d] focus:outline-none" required />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Alamat</label>
|
||||
<textarea
|
||||
v-model="form.alamat"
|
||||
class="mt-1 block w-full rounded-md shadow-sm sm:text-sm bg-A text-D border-B focus:border-C focus:ring focus:ring-D focus:ring-opacity-50 p-2"
|
||||
required
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2 mt-6">
|
||||
<button type="button" @click="$emit('close')" class="px-4 py-2 bg-gray-300 rounded hover:bg-gray-400">Batal</button>
|
||||
<button type="submit" class="px-4 py-2 bg-C text-D rounded hover:bg-C">Ubah</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from "vue";
|
||||
import axios from "axios";
|
||||
import InputField from "./InputField.vue";
|
||||
|
||||
const props = defineProps({
|
||||
isOpen: Boolean,
|
||||
sales: Object,
|
||||
});
|
||||
|
||||
const emit = defineEmits(["close"]);
|
||||
|
||||
const form = ref({
|
||||
nama: "",
|
||||
no_hp: "",
|
||||
alamat: "",
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.sales,
|
||||
(val) => {
|
||||
if (val) {
|
||||
form.value = { ...val };
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
await axios.put(`/api/sales/${props.sales.id}`, form.value, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||
},
|
||||
});;
|
||||
emit("close");
|
||||
} catch (error) {
|
||||
console.error("Error updating sales:", error);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
26
resources/js/components/Footer.vue
Normal file
@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<footer class="bg-B py-4 px-6 flex flex-col md:flex-row items-center justify-between">
|
||||
<!-- Left: Logo -->
|
||||
<div class="flex items-center gap-2">
|
||||
<img :src="logo" alt="Logo" class="h-10">
|
||||
</div>
|
||||
|
||||
<!-- Center: Copyright -->
|
||||
<div class="text-sm text-D font-medium text-center">
|
||||
Abbauf Tech © 2025 Semua hak dilindungi
|
||||
</div>
|
||||
|
||||
<!-- Right: Social Icons -->
|
||||
<div class="flex items-center gap-4 text-D mt-2 md:mt-0">
|
||||
<a href="#" class="hover:text-sky-600"><i class="fab fa-facebook"></i></a>
|
||||
<a href="#" class="hover:text-sky-600"><i class="fab fa-twitter"></i></a>
|
||||
<a href="#" class="hover:text-sky-600"><i class="fab fa-instagram"></i></a>
|
||||
<a href="#" class="hover:text-sky-600"><i class="fab fa-youtube"></i></a>
|
||||
<a href="#" class="hover:text-sky-600"><i class="fab fa-vk"></i></a>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import logo from '@/../images/logo.png'
|
||||
</script>
|
||||
@ -1,17 +0,0 @@
|
||||
<script setup>
|
||||
const items = ['Manajemen Produk', 'Kasir', 'Laporan', 'Akun'];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-25 shadow-lg shadow-D rounded-b-md">
|
||||
<div class="bg-D h-5 rounded-b-md shadow-lg">
|
||||
<div class="h-15"></div>
|
||||
<div class="w-full px-50 flex justify-between items-center h-5">
|
||||
<router-link to="/" v-for="item in items"
|
||||
class="text-center text-lg text-D hover:underline cursor-pointer">
|
||||
{{ item }}
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
28
resources/js/components/InputField.vue
Normal file
@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<input
|
||||
:type="type"
|
||||
:value="modelValue"
|
||||
@input="$emit('update:modelValue', $event.target.value)"
|
||||
:placeholder="placeholder"
|
||||
class="mt-1 block w-full rounded-md shadow-sm sm:text-sm bg-A text-D border-B focus:border-C focus:ring focus:outline-none focus:ring-D focus:ring-opacity-50 p-2"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
type: {
|
||||
type: String,
|
||||
default: 'text',
|
||||
},
|
||||
modelValue: {
|
||||
type: [String, Number],
|
||||
default: '',
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
</script>
|
||||
46
resources/js/components/InputPassword.vue
Normal file
@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<div class="relative mb-1">
|
||||
<input
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
:value="modelValue"
|
||||
:placeholder="placeholder"
|
||||
@input="$emit('update:modelValue', $event.target.value)"
|
||||
class="mt-1 block w-full rounded-md shadow-sm sm:text-sm
|
||||
bg-A text-D border-B focus:border-C
|
||||
focus:ring focus:ring-D focus:ring-opacity-50 p-2 pr-10"
|
||||
/>
|
||||
|
||||
<!-- Tombol show/hide password -->
|
||||
<button
|
||||
type="button"
|
||||
@click="togglePassword"
|
||||
class="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-500 hover:text-gray-700 focus:outline-none"
|
||||
>
|
||||
<i v-if="showPassword" class="fas fa-eye"></i>
|
||||
<i v-else class="fas fa-eye-slash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: "Password",
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:modelValue"]);
|
||||
|
||||
const showPassword = ref(false);
|
||||
|
||||
const togglePassword = () => {
|
||||
showPassword.value = !showPassword.value;
|
||||
};
|
||||
</script>
|
||||
31
resources/js/components/InputSelect.vue
Normal file
@ -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>
|
||||
306
resources/js/components/KasirForm.vue
Normal file
@ -0,0 +1,306 @@
|
||||
<template>
|
||||
<ConfirmDeleteModal v-if="showDeleteModal" :isOpen="showDeleteModal" title="Konfirmasi"
|
||||
message="Yakin ingin menghapus item ini?" @confirm="hapusPesanan" @cancel="closeDeleteModal" />
|
||||
|
||||
<!-- ==== TAMBAHAN: Struk Overlay ==== -->
|
||||
<StrukOverlay v-if="showStruk" :isOpen="showStruk" :pesanan="pesanan" :total="total" @close="closeStruk" />
|
||||
<!-- ==== END TAMBAHAN ==== -->
|
||||
|
||||
<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>
|
||||
<input
|
||||
type="text"
|
||||
v-model="hargaJualFormatted"
|
||||
@input="formatHargaInput"
|
||||
@keypress="onlyNumbers"
|
||||
placeholder="Masukkan Harga Jual"
|
||||
class="bg-A focus:outline-none focus:border-C focus:ring focus:ring-D focus:ring-opacity-50 p-2 w-full rounded-md border border-B shadow-sm sm:text-sm"
|
||||
/>
|
||||
</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.nampan ? item.nampan.nama : "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";
|
||||
import StrukOverlay from "./StrukOverlay.vue";
|
||||
|
||||
const kodeItem = ref("");
|
||||
const info = ref("");
|
||||
const error = ref("");
|
||||
const hargaJual = ref(null);
|
||||
const hargaJualFormatted = ref("");
|
||||
const item = ref(null);
|
||||
const loadingItem = ref(false);
|
||||
const pesanan = ref([]);
|
||||
const showDeleteModal = ref(false)
|
||||
const deleteIndex = ref(null)
|
||||
|
||||
const showStruk = ref(false);
|
||||
|
||||
let errorTimeout = null;
|
||||
let infoTimeout = null;
|
||||
|
||||
// Format angka dengan pemisah ribuan
|
||||
const formatNumber = (num) => {
|
||||
if (!num) return "";
|
||||
return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ".");
|
||||
};
|
||||
|
||||
// Menghapus format dan mengambil angka asli
|
||||
const unformatNumber = (str) => {
|
||||
if (!str) return null;
|
||||
const cleaned = str.replace(/\./g, "");
|
||||
const number = parseInt(cleaned);
|
||||
return isNaN(number) ? null : number;
|
||||
};
|
||||
|
||||
// Handler untuk format input harga
|
||||
const formatHargaInput = (event) => {
|
||||
const value = event.target.value;
|
||||
// Hapus semua karakter selain angka
|
||||
const cleanValue = value.replace(/\D/g, "");
|
||||
|
||||
if (cleanValue) {
|
||||
// Format dengan pemisah ribuan
|
||||
const formatted = formatNumber(cleanValue);
|
||||
hargaJualFormatted.value = formatted;
|
||||
hargaJual.value = parseInt(cleanValue);
|
||||
} else {
|
||||
hargaJualFormatted.value = "";
|
||||
hargaJual.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
// Hanya izinkan angka saat mengetik
|
||||
const onlyNumbers = (event) => {
|
||||
const char = String.fromCharCode(event.which);
|
||||
if (!/[0-9]/.test(char)) {
|
||||
event.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
// Format harga untuk tampilan
|
||||
hargaJualFormatted.value = formatNumber(item.value.produk.harga_jual);
|
||||
|
||||
// console.log(item.value);
|
||||
|
||||
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.nampan ? 'Nampan ' + item.value.nampan.nama : "Brankas"}`;
|
||||
|
||||
infoTimeout = setTimeout(() => {
|
||||
info.value = "";
|
||||
}, 5000);
|
||||
} catch (err) {
|
||||
error.value = "Item tidak ditemukan";
|
||||
info.value = "";
|
||||
hargaJual.value = null;
|
||||
hargaJualFormatted.value = "";
|
||||
item.value = null;
|
||||
|
||||
errorTimeout = setTimeout(() => {
|
||||
error.value = "";
|
||||
}, 5000);
|
||||
} 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 = "";
|
||||
}, 5000);
|
||||
return;
|
||||
}
|
||||
|
||||
// harga deal
|
||||
item.value.kode_item = Number(kodeItem.value);
|
||||
item.value.harga_deal = Number(hargaJual.value);
|
||||
item.value.posisi = item.value.nampan ? item.value.nampan.nama : "Brankas";
|
||||
|
||||
pesanan.value.push(item.value);
|
||||
|
||||
// Reset input fields
|
||||
kodeItem.value = "";
|
||||
hargaJual.value = null;
|
||||
hargaJualFormatted.value = "";
|
||||
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()
|
||||
}
|
||||
|
||||
// ==== MODIFIKASI: konfirmasiPenjualan sekarang menampilkan struk ====
|
||||
const konfirmasiPenjualan = () => {
|
||||
if (pesanan.value.length === 0) {
|
||||
error.value = "Belum ada item yang dipesan.";
|
||||
clearTimeout(errorTimeout);
|
||||
errorTimeout = setTimeout(() => {
|
||||
error.value = "";
|
||||
}, 5000);
|
||||
return;
|
||||
}
|
||||
|
||||
// Tampilkan struk overlay
|
||||
showStruk.value = true;
|
||||
};
|
||||
// ==== END MODIFIKASI ====
|
||||
|
||||
// ==== TAMBAHAN: Fungsi untuk menutup struk ====
|
||||
const closeStruk = () => {
|
||||
showStruk.value = false;
|
||||
};
|
||||
// ==== END TAMBAHAN ====
|
||||
|
||||
const total = computed(() => {
|
||||
let sum = 0;
|
||||
pesanan.value.forEach((item) => {
|
||||
sum += item.harga_deal;
|
||||
});
|
||||
return sum;
|
||||
});
|
||||
</script>
|
||||
222
resources/js/components/KasirTransaksiList.vue
Normal file
@ -0,0 +1,222 @@
|
||||
<template>
|
||||
<div class="space-y-3">
|
||||
|
||||
|
||||
<!-- Summary Card -->
|
||||
<div class="mt-3 bg-A border border-C rounded-lg p-3">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-D font-medium">Transaksi Hari Ini</span>
|
||||
<span class="text-D-700 font-semibold">
|
||||
Rp{{ totalPendapatan.toLocaleString() }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-xs text-D mt-1">
|
||||
<span>{{ transaksi.length }} transaksi</span>
|
||||
<span>{{ totalItems }} item terjual</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="flex items-center justify-center py-8">
|
||||
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500"></div>
|
||||
<span class="ml-2 text-gray-600 text-sm">Memuat transaksi hari ini...</span>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div v-else-if="transaksi.length > 0" class="overflow-x-auto">
|
||||
<table class="w-full min-w-[500px] border border-gray-200 rounded-lg text-sm">
|
||||
<thead class="bg-C text-D text-center">
|
||||
<tr>
|
||||
<th class="border border-gray-200 p-2">Waktu</th>
|
||||
<th class="border border-gray-200 p-2">Kode Transaksi</th>
|
||||
<th class="border border-gray-200 p-2">Total</th>
|
||||
<th class="border border-gray-200 p-2">Aksi</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="trx in transaksi" :key="trx.id" class="hover:bg-gray-50 border-b border-gray-100">
|
||||
<td class="border border-gray-200 p-2">
|
||||
<div class="text-xs space-y-1">
|
||||
<div class="font-medium text-gray-600">{{ formatTime(trx.created_at) }}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="border border-gray-200 p-2">
|
||||
{{ trx.kode_transaksi }}
|
||||
</td>
|
||||
<td class="border border-gray-200 p-2 text-right">
|
||||
<span class="text-sm">
|
||||
Rp{{ (trx.pendapatan || 0).toLocaleString() }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="border border-gray-200 p-2 text-center">
|
||||
<button
|
||||
@click="lihatDetail(trx)"
|
||||
class="px-3 py-1 bg-blue-500 text-white rounded-md hover:bg-blue-600 transition-colors text-xs whitespace-nowrap"
|
||||
:disabled="isDetailLoading && selectedTransaksi.id === trx.id"
|
||||
>
|
||||
<span v-if="isDetailLoading && selectedTransaksi.id === trx.id">
|
||||
<i class="fas fa-spinner fa-spin mr-1"></i>
|
||||
Loading...
|
||||
</span>
|
||||
<span v-else>Lihat Detail</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Pagination -->
|
||||
</div>
|
||||
<div v-if="pagination" class="mt-2 p-2 bg-gray-50 rounded-lg border border-gray-200">
|
||||
<div class="flex items-center justify-between text-xs text-gray-600">
|
||||
<div class="flex items-center gap-2">
|
||||
<span>Menampilkan {{ pagination.from }} - {{ pagination.to }} dari {{ pagination.total }} transaksi hari ini</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
@click="$emit('page-change', pagination.current_page - 1)"
|
||||
:disabled="pagination.current_page === 1"
|
||||
class="px-2 py-1 text-xs border border-gray-300 rounded hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
← Prev
|
||||
</button>
|
||||
|
||||
<span class="px-2 text-gray-700">
|
||||
{{ pagination.current_page }} / {{ pagination.last_page }}
|
||||
</span>
|
||||
|
||||
<button
|
||||
@click="$emit('page-change', pagination.current_page + 1)"
|
||||
:disabled="pagination.current_page === pagination.last_page"
|
||||
class="px-2 py-1 text-xs border border-gray-300 rounded hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Next →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else-if="!loading" class="text-center py-12 bg-gray-50 rounded-lg border border-gray-200">
|
||||
<div class="text-gray-500 space-y-3">
|
||||
<svg class="w-16 h-16 mx-auto text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
||||
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1" />
|
||||
</svg>
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm font-medium text-gray-900">Belum ada transaksi</p>
|
||||
<p class="text-xs text-gray-500">Hari ini masih sepi...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal Detail Transaksi -->
|
||||
<StrukView
|
||||
:is-open="isDetailOpen"
|
||||
:transaksi="selectedTransaksi"
|
||||
@close="closeDetail"
|
||||
/>
|
||||
|
||||
<!-- Loading Detail Modal -->
|
||||
<div v-if="isDetailLoading" class="fixed inset-0 bg-black/75 flex items-center justify-center z-[10000]">
|
||||
<div class="bg-white p-6 rounded-lg flex items-center gap-3 shadow-xl max-w-sm w-full mx-4">
|
||||
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500"></div>
|
||||
<span class="text-gray-700 text-sm">Memuat detail transaksi...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import axios from 'axios'
|
||||
import StrukView from './StrukView.vue'
|
||||
|
||||
const props = defineProps({
|
||||
transaksi: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
pagination: {
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['page-change'])
|
||||
|
||||
// Modal state
|
||||
const isDetailOpen = ref(false)
|
||||
const selectedTransaksi = ref({})
|
||||
const isDetailLoading = ref(false)
|
||||
|
||||
// Computed properties
|
||||
const totalPendapatan = computed(() => {
|
||||
return props.transaksi.reduce((total, trx) => total + (trx.pendapatan || 0), 0)
|
||||
})
|
||||
|
||||
const totalItems = computed(() => {
|
||||
return props.transaksi.reduce((total, trx) => total + (trx.total_items || 0), 0)
|
||||
})
|
||||
|
||||
// Format functions
|
||||
const formatDate = (dateString) => {
|
||||
return new Date(dateString).toLocaleDateString('id-ID', {
|
||||
weekday: 'short',
|
||||
day: 'numeric',
|
||||
month: 'short'
|
||||
})
|
||||
}
|
||||
|
||||
const formatTime = (dateString) => {
|
||||
return new Date(dateString).toLocaleTimeString('id-ID', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
// Lihat detail transaksi
|
||||
const lihatDetail = async (trx) => {
|
||||
try {
|
||||
isDetailLoading.value = true
|
||||
// console.log('Fetching detail untuk transaksi:', trx.kode_transaksi)
|
||||
|
||||
const response = await axios.get(`/api/transaksi/${trx.id}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||
},
|
||||
})
|
||||
|
||||
// console.log('Response detail transaksi:', response.data)
|
||||
selectedTransaksi.value = response.data
|
||||
isDetailOpen.value = true
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching transaksi detail:', error)
|
||||
|
||||
let errorMessage = 'Gagal memuat detail transaksi'
|
||||
if (error.response) {
|
||||
errorMessage += `: ${error.response.status} - ${error.response.data?.message || error.response.statusText}`
|
||||
} else if (error.request) {
|
||||
errorMessage += ': Tidak ada respon dari server'
|
||||
} else {
|
||||
errorMessage += `: ${error.message}`
|
||||
}
|
||||
|
||||
alert(errorMessage)
|
||||
} finally {
|
||||
isDetailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Tutup modal detail
|
||||
const closeDetail = () => {
|
||||
isDetailOpen.value = false
|
||||
selectedTransaksi.value = {}
|
||||
}
|
||||
</script>
|
||||
96
resources/js/components/Modal.vue
Normal file
@ -0,0 +1,96 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="modal">
|
||||
<div
|
||||
v-if="active"
|
||||
class="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"
|
||||
@click="handleOverlayClick"
|
||||
>
|
||||
<div
|
||||
class="bg-white rounded-lg shadow-2xl max-h-[90vh] overflow-y-auto relative"
|
||||
:class="sizeClass"
|
||||
@click.stop
|
||||
>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, watch, onBeforeUnmount } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
active: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: 'md',
|
||||
validator: (value) => ['xs', 'sm', 'md', 'lg', 'xl', '2xl', '3xl', '4xl', 'full'].includes(value)
|
||||
},
|
||||
clickOutside: {
|
||||
type: [Boolean, String],
|
||||
default: true
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
const sizeClass = computed(() => {
|
||||
const sizes = {
|
||||
xs: 'w-full max-w-xs',
|
||||
sm: 'w-full max-w-sm',
|
||||
md: 'w-full max-w-md',
|
||||
lg: 'w-full max-w-lg',
|
||||
xl: 'w-full max-w-xl',
|
||||
'2xl': 'w-full max-w-2xl',
|
||||
'3xl': 'w-full max-w-3xl',
|
||||
'4xl': 'w-full max-w-4xl',
|
||||
full: 'w-[95vw] h-[95vh] max-w-none max-h-none'
|
||||
}
|
||||
return sizes[props.size] || sizes.md
|
||||
})
|
||||
|
||||
const handleOverlayClick = () => {
|
||||
if (clickOutside.value) {
|
||||
emit('close')
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.active, (newVal) => {
|
||||
if (newVal) {
|
||||
document.body.style.overflow = 'hidden'
|
||||
} else {
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.body.style.overflow = ''
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal-enter-active,
|
||||
.modal-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.modal-enter-from,
|
||||
.modal-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.modal-enter-from .bg-white,
|
||||
.modal-leave-to .bg-white {
|
||||
transform: scale(0.95) translateY(-20px);
|
||||
}
|
||||
|
||||
.modal-enter-active .bg-white,
|
||||
.modal-leave-active .bg-white {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
</style>
|
||||
96
resources/js/components/NavDesktop.vue
Normal file
@ -0,0 +1,96 @@
|
||||
<script setup>
|
||||
import { inject } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
|
||||
const {
|
||||
logo,
|
||||
items,
|
||||
openDropdownIndex,
|
||||
toggleDropdown,
|
||||
logout
|
||||
} = inject('navigationData');
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
// Function to check if a menu item or its subItems are active
|
||||
const isMenuActive = (item) => {
|
||||
if (item.route) {
|
||||
return route.path === item.route;
|
||||
}
|
||||
if (item.subItems) {
|
||||
return item.subItems.some(sub => route.path === sub.route);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
</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',
|
||||
{ 'underline underline-offset-4': isMenuActive(item) }
|
||||
]">
|
||||
{{ 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',
|
||||
{ 'underline underline-offset-4': route.path === sub.route }
|
||||
]">
|
||||
{{ 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',
|
||||
{ 'underline underline-offset-4': isMenuActive(item) }
|
||||
]">
|
||||
{{ 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>
|
||||
112
resources/js/components/NavMobile.vue
Normal file
@ -0,0 +1,112 @@
|
||||
<script setup>
|
||||
import { inject } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
|
||||
const {
|
||||
logo,
|
||||
items,
|
||||
isMobileMenuOpen,
|
||||
openDropdownIndex,
|
||||
toggleDropdown,
|
||||
toggleMobileMenu,
|
||||
closeMobileMenu,
|
||||
logout
|
||||
} = inject('navigationData');
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
// Function to check if a menu item or its subItems are active
|
||||
const isMenuActive = (item) => {
|
||||
if (item.route) {
|
||||
return route.path === item.route;
|
||||
}
|
||||
if (item.subItems) {
|
||||
return item.subItems.some(sub => route.path === sub.route);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
</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',
|
||||
{ 'bg-C': isMenuActive(item) }
|
||||
]">
|
||||
<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',
|
||||
{ 'bg-C': route.path === sub.route }
|
||||
]">
|
||||
{{ 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',
|
||||
{ 'bg-C': isMenuActive(item) }
|
||||
]">
|
||||
{{ item.label }}
|
||||
</router-link>
|
||||
</div>
|
||||
</template>
|
||||
</nav>
|
||||
|
||||
<div class="absolute bottom-0 w-full px-4 py-3 bg-A border-t border-B">
|
||||
<button @click="logout"
|
||||
class="block w-full text-left px-3 py-2 text-lg font-bold text-red-400 hover:text-white hover:bg-red-400 rounded-md transition-colors duration-200">
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isMobileMenuOpen" @click="closeMobileMenu" class="fixed inset-0 bg-black/75 z-40"></div>
|
||||
</div>
|
||||
</template>
|
||||
109
resources/js/components/NavigationComponent.vue
Normal file
@ -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: "Produk", route: "/produk" },
|
||||
{ label: "Nampan", route: "/nampan" },
|
||||
{ label: "Brankas", route: "/brankas" },
|
||||
{ label: "Kategori", route: "/kategori" },
|
||||
{ label: "Sales", route: "/sales" },
|
||||
]
|
||||
},
|
||||
{ label: "Kasir", route: "/kasir" },
|
||||
{ label: "Laporan", route: "/laporan" },
|
||||
{ label: "Akun", route: "/akun" },
|
||||
];
|
||||
|
||||
const role = localStorage.getItem("role");
|
||||
|
||||
const items = computed(() => {
|
||||
if (role === "owner") {
|
||||
return baseItems;
|
||||
}
|
||||
if (role === "kasir") {
|
||||
return baseItems.filter(item => !["Akun", "Laporan"].includes(item.label));
|
||||
}
|
||||
return baseItems;
|
||||
});
|
||||
|
||||
const toggleDropdown = (index = null) => {
|
||||
if (index !== null) {
|
||||
openDropdownIndex.value = openDropdownIndex.value === index ? null : index;
|
||||
} else {
|
||||
isOpen.value = !isOpen.value;
|
||||
}
|
||||
};
|
||||
|
||||
const toggleMobileMenu = () => {
|
||||
isMobileMenuOpen.value = !isMobileMenuOpen.value;
|
||||
isOpen.value = false;
|
||||
openDropdownIndex.value = null;
|
||||
};
|
||||
|
||||
const closeMobileMenu = () => {
|
||||
isMobileMenuOpen.value = false;
|
||||
isOpen.value = false;
|
||||
openDropdownIndex.value = null;
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
try {
|
||||
await axios.post("/api/logout", null, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||
},
|
||||
});
|
||||
localStorage.removeItem("token");
|
||||
localStorage.removeItem("role");
|
||||
|
||||
window.location.href = "/";
|
||||
} catch (error) {
|
||||
if (error.response && error.response.status === 401) {
|
||||
localStorage.removeItem("token");
|
||||
localStorage.removeItem("role");
|
||||
window.location.href = "/";
|
||||
} else {
|
||||
console.error("Logout failed:", error);
|
||||
}
|
||||
}
|
||||
closeMobileMenu();
|
||||
};
|
||||
|
||||
// Provide shared data to child components
|
||||
provide("navigationData", {
|
||||
logo,
|
||||
items,
|
||||
isOpen,
|
||||
isMobileMenuOpen,
|
||||
openDropdownIndex,
|
||||
toggleDropdown,
|
||||
toggleMobileMenu,
|
||||
closeMobileMenu,
|
||||
logout
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative">
|
||||
<!-- Desktop Navigation -->
|
||||
<NavDesktop />
|
||||
|
||||
<!-- Mobile Navigation -->
|
||||
<NavMobile />
|
||||
|
||||
<!-- Click Outside Handler for Desktop Dropdown -->
|
||||
<div v-if="openDropdownIndex !== null && !isMobileMenuOpen" @click="openDropdownIndex = null"
|
||||
class="fixed inset-0 z-10"></div>
|
||||
</div>
|
||||
</template>
|
||||
@ -1,53 +1,49 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="relative">
|
||||
<!-- Card Produk -->
|
||||
<div
|
||||
class="border border-C rounded-md aspect-square flex items-center justify-center hover:shadow-md transition cursor-pointer"
|
||||
@click="showDetail = true"
|
||||
class="relative z-0 border border-C rounded-md aspect-square flex items-center justify-center hover:shadow-md transition cursor-pointer overflow-hidden"
|
||||
@click="$emit('click', product.id)"
|
||||
>
|
||||
<!-- Foto Produk -->
|
||||
<img
|
||||
v-if="product.foto && product.foto.length > 0"
|
||||
:src="product.foto[0].url"
|
||||
:alt="product.nama"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
<span v-else class="text-gray-400 text-sm">[tidak ada foto]</span>
|
||||
|
||||
<!-- Nama Produk di bawah -->
|
||||
<div
|
||||
class="absolute bottom-0 w-full bg-black/60 text-white text-center text-sm py-1"
|
||||
>
|
||||
<span class="text-gray-700 font-medium text-center px-2">
|
||||
{{ product.nama }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Overlay Detail -->
|
||||
<!-- Notifikasi Stok Menipis (di luar card) -->
|
||||
<div
|
||||
v-if="showDetail"
|
||||
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
|
||||
v-if="isStockLow"
|
||||
class="absolute -top-2 -right-2 group z-20"
|
||||
>
|
||||
<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>
|
||||
<!-- Bulatan Merah -->
|
||||
<div class="w-5 h-5 bg-red-500 rounded-full flex items-center justify-center cursor-help border-2 border-white shadow-md">
|
||||
<span class="text-white text-xs font-bold">!</span>
|
||||
</div>
|
||||
|
||||
<!-- Tooltip -->
|
||||
<div class="absolute top-6 -right-4 bg-red-600 text-white text-xs px-3 py-2 rounded shadow-lg opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none whitespace-nowrap z-30">
|
||||
Stok menipis: {{ product.items_count }} pcs
|
||||
<!-- Arrow untuk tooltip -->
|
||||
<div class="absolute -top-1 left-1/2 transform -translate-x-1/2 w-0 h-0 border-l-2 border-r-2 border-b-2 border-transparent border-b-red-600"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
product: {
|
||||
@ -56,10 +52,8 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const showDetail = ref(false);
|
||||
|
||||
// Format rupiah
|
||||
function formatHarga(value) {
|
||||
return new Intl.NumberFormat("id-ID").format(value);
|
||||
}
|
||||
// Computed untuk mengecek apakah stok menipis (kurang dari 5)
|
||||
const isStockLow = computed(() => {
|
||||
return props.product.items_count < 5;
|
||||
});
|
||||
</script>
|
||||
|
||||
282
resources/js/components/RingkasanLaporan.vue
Normal file
@ -0,0 +1,282 @@
|
||||
<template>
|
||||
<div class="flex flex-row items-center justify-end mt-5 gap-3">
|
||||
<div class="relative w-32" ref="filterDropdownRef">
|
||||
<button @click="isFilterOpen = !isFilterOpen" type="button"
|
||||
class="flex items-center justify-between w-full px-3 py-2 text-sm text-left bg-C border rounded-md border-C hover:bg-C/80 focus:outline-none">
|
||||
<span>{{ selectedFilterLabel }}</span>
|
||||
<i class="fas fa-chevron-down"></i>
|
||||
</button>
|
||||
<div v-if="isFilterOpen" class="absolute z-10 w-full mt-1 bg-C border rounded-md shadow-lg border-C">
|
||||
<ul class="py-1">
|
||||
<li v-for="option in filterOptions" :key="option.value" @click="selectFilter(option)"
|
||||
class="px-3 py-2 text-sm cursor-pointer text-D hover:bg-B">
|
||||
{{ option.label }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative w-40" ref="exportDropdownRef">
|
||||
<button v-if="loadingExport" type="button"
|
||||
class="flex items-center w-full px-3 py-2 text-sm text-left bg-C/80 border rounded-md border-C/80" disabled>
|
||||
<i class="fas fa-spinner fa-spin mr-2"></i> Memproses...
|
||||
</button>
|
||||
<button v-else @click="isExportOpen = !isExportOpen" type="button"
|
||||
class="flex items-center justify-between w-full px-3 py-2 text-sm text-left bg-C border rounded-md border-C hover:bg-C/80 focus:outline-none">
|
||||
<span :class="{ 'text-D': !exportFormat }">{{ selectedExportLabel }}</span>
|
||||
<i class="fas fa-chevron-down"></i>
|
||||
</button>
|
||||
<div v-if="isExportOpen" class="absolute z-10 w-full mt-1 bg-C border rounded-md shadow-lg border-C">
|
||||
<ul class="py-1">
|
||||
<li v-for="option in exportOptions" :key="option.value" @click="selectExport(option)"
|
||||
class="px-3 py-2 text-sm cursor-pointer text-D hover:bg-B">
|
||||
{{ option.label }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 overflow-x-auto">
|
||||
<table class="w-full border-collapse border border-C rounded-md">
|
||||
<thead>
|
||||
<tr class="bg-C text-D rounded-t-md">
|
||||
<th class="border-x border-C px-3 py-3">Tanggal</th>
|
||||
<th class="border-x border-C px-3 py-3">Nama Sales</th>
|
||||
<th class="border-x border-C px-3 py-3">Jumlah Item Terjual</th>
|
||||
<th class="border-x border-C px-3 py-3">Total Berat Terjual</th>
|
||||
<th class="border-x border-C px-3 py-3">Total Pendapatan</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="loading">
|
||||
<td colspan="5" class="p-4">
|
||||
<div class="flex items-center justify-center w-full h-30">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-D"></div>
|
||||
<span class="ml-2 text-gray-600">Memuat data...</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-else-if="!ringkasanLaporan.length">
|
||||
<td colspan="5" class="p-4 text-center">Tidak ada data untuk ditampilkan.</td>
|
||||
</tr>
|
||||
<template v-else v-for="item in ringkasanLaporan" :key="item.tanggal">
|
||||
<template v-if="item.sales && item.sales.length > 0">
|
||||
<tr class="text-center border-y border-C hover:bg-A">
|
||||
<td class="px-3 py-2 border-x border-C bg-white" :rowspan="item.sales.length">{{
|
||||
item.tanggal }}</td>
|
||||
<td class="px-3 py-2 border-x border-C text-left">{{ item.sales[0].nama }}</td>
|
||||
<td class="px-3 py-2 border-x border-C">{{ item.sales[0].item_terjual }}</td>
|
||||
<td class="px-3 py-2 border-x border-C">{{ item.sales[0].berat_terjual }}</td>
|
||||
<td class="flex justify-center">
|
||||
<div :ref="el => { if (el) pendapatanElements.push(el) }" :style="pendapatanStyle" :class="item.sales[0].pendapatan == '-' ? 'text-center' : 'text-right'">
|
||||
{{ item.sales[0].pendapatan }}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-for="sales in item.sales.slice(1)" :key="sales.nama"
|
||||
class="text-center border-y border-C hover:bg-A">
|
||||
<td class="px-3 py-2 text-left border-x border-C">{{ sales.nama }}</td>
|
||||
<td class="px-3 py-2 border-x border-C">{{ sales.item_terjual }}</td>
|
||||
<td class="px-3 py-2 border-x border-C">{{ sales.berat_terjual }}</td>
|
||||
<td class="flex justify-center">
|
||||
<div :ref="el => { if (el) pendapatanElements.push(el) }" :style="pendapatanStyle" :class="sales.pendapatan == '-' ? 'text-center' : 'text-right'">
|
||||
{{ sales.pendapatan }}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="font-semibold text-center border-y border-C bg-B hover:bg-C/80">
|
||||
<td class="px-3 py-2 border-x border-C" colspan="2">Total</td>
|
||||
<td class="px-3 py-2 border-x border-C">{{ item.total_item_terjual }}</td>
|
||||
<td class="px-3 py-2 border-x border-C">{{ item.total_berat }}</td>
|
||||
<td class="flex justify-center">
|
||||
<div :ref="el => { if (el) pendapatanElements.push(el) }" :style="pendapatanStyle" :class="item.sales[0].pendapatan == '-' ? 'text-center' : 'text-right'">
|
||||
{{ item.total_pendapatan }}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<template v-else>
|
||||
<tr class="text-center border-y border-C hover:bg-A">
|
||||
<td class="px-3 py-2 border-x border-C">{{ item.tanggal }}</td>
|
||||
<td colspan="4" class="px-3 py-2 italic text-gray-500 border-x border-C">Tidak ada transaksi
|
||||
pada hari ini</td>
|
||||
</tr>
|
||||
</template>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-if="pagination.total > 0 && pagination.last_page > 1" class="flex items-center justify-end gap-2 mt-4">
|
||||
<button @click="goToPage(pagination.current_page - 1)" :disabled="pagination.current_page === 1 || loading"
|
||||
class="px-2 py-1 text-sm font-medium border rounded-md bg-C border-C disabled:opacity-50 disabled:cursor-not-allowed hover:bg-C/80">
|
||||
Sebelumnya
|
||||
</button>
|
||||
<span class="text-sm text-D">
|
||||
Halaman {{ pagination.current_page }} dari {{ pagination.last_page }}
|
||||
</span>
|
||||
<button @click="goToPage(pagination.current_page + 1)"
|
||||
:disabled="(pagination.current_page === pagination.last_page) || loading"
|
||||
class="px-2 py-1 text-sm font-medium border rounded-md bg-C border-C disabled:opacity-50 disabled:cursor-not-allowed hover:bg-C/80">
|
||||
Berikutnya
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, watch, computed, nextTick } from "vue";
|
||||
import axios from "axios";
|
||||
|
||||
// --- State ---
|
||||
const isFilterOpen = ref(false);
|
||||
const isExportOpen = ref(false);
|
||||
const filterDropdownRef = ref(null);
|
||||
const exportDropdownRef = ref(null);
|
||||
|
||||
const filterOptions = ref([
|
||||
{ value: 'bulan', label: 'Bulanan' },
|
||||
{ value: 'hari', label: 'Harian' }
|
||||
]);
|
||||
const exportOptions = ref([
|
||||
{ value: 'pdf', label: 'Pdf' },
|
||||
{ value: 'xlsx', label: 'Excel' },
|
||||
{ value: 'csv', label: 'Csv' }
|
||||
]);
|
||||
|
||||
const filterRingkasan = ref("bulan");
|
||||
const loadingExport = ref(false);
|
||||
const exportFormat = ref(null);
|
||||
const ringkasanLaporan = ref([]);
|
||||
const loading = ref(false);
|
||||
const pagination = ref({
|
||||
current_page: 1,
|
||||
last_page: 1,
|
||||
total: 0,
|
||||
});
|
||||
|
||||
const pendapatanWidth = ref(0);
|
||||
const pendapatanElements = ref([]);
|
||||
|
||||
// --- Computed ---
|
||||
const selectedFilterLabel = computed(() => {
|
||||
return filterOptions.value.find(opt => opt.value === filterRingkasan.value)?.label;
|
||||
});
|
||||
|
||||
const selectedExportLabel = computed(() => {
|
||||
return exportOptions.value.find(opt => opt.value === exportFormat.value)?.label || 'Export Laporan';
|
||||
});
|
||||
|
||||
const pendapatanStyle = computed(() => ({
|
||||
minWidth: `${pendapatanWidth.value}px`,
|
||||
padding: '0.5rem 0.75rem'
|
||||
}));
|
||||
|
||||
// --- Watchers ---
|
||||
watch(ringkasanLaporan, async (newValue) => {
|
||||
if (newValue && newValue.length > 0) {
|
||||
await nextTick();
|
||||
let maxWidth = 0;
|
||||
pendapatanElements.value.forEach(el => {
|
||||
if (el && el.scrollWidth > maxWidth) {
|
||||
maxWidth = el.scrollWidth;
|
||||
}
|
||||
});
|
||||
pendapatanWidth.value = maxWidth;
|
||||
}
|
||||
}, { deep: true });
|
||||
|
||||
// --- Methods ---
|
||||
const fetchRingkasan = async (page = 1) => {
|
||||
loading.value = true;
|
||||
pendapatanElements.value = [];
|
||||
try {
|
||||
const response = await axios.get(`/api/laporan/ringkasan?filter=${filterRingkasan.value}&page=${page}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||
},
|
||||
});;
|
||||
ringkasanLaporan.value = response.data.data;
|
||||
pagination.value = {
|
||||
current_page: response.data.current_page,
|
||||
last_page: response.data.last_page,
|
||||
total: response.data.total,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error fetching laporan:", error);
|
||||
ringkasanLaporan.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const goToPage = (page) => {
|
||||
if (page >= 1 && page <= pagination.value.last_page) {
|
||||
fetchRingkasan(page);
|
||||
}
|
||||
};
|
||||
|
||||
const selectFilter = (option) => {
|
||||
filterRingkasan.value = option.value;
|
||||
isFilterOpen.value = false;
|
||||
goToPage(1);
|
||||
};
|
||||
|
||||
const selectExport = (option) => {
|
||||
isExportOpen.value = false;
|
||||
triggerDownload(option.value);
|
||||
};
|
||||
|
||||
const triggerDownload = async (format) => {
|
||||
loadingExport.value = true;
|
||||
|
||||
try {
|
||||
const response = await axios.get('/api/laporan/export/ringkasan', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||
},
|
||||
responseType: 'blob',
|
||||
params: {
|
||||
filter: filterRingkasan.value,
|
||||
format: format,
|
||||
page: pagination.value.current_page,
|
||||
},
|
||||
});
|
||||
|
||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||
const link = document.createElement('a');
|
||||
const fileName = `laporan_${filterRingkasan.value}_${new Date().toISOString().split('T')[0]}.${format}`;
|
||||
|
||||
link.href = url;
|
||||
link.setAttribute('download', fileName);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
|
||||
link.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
} catch (error) {
|
||||
console.error("Gagal mengunduh laporan:", error);
|
||||
alert("Terjadi kesalahan saat membuat laporan.");
|
||||
} finally {
|
||||
loadingExport.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const closeDropdownsOnClickOutside = (event) => {
|
||||
if (filterDropdownRef.value && !filterDropdownRef.value.contains(event.target)) {
|
||||
isFilterOpen.value = false;
|
||||
}
|
||||
if (exportDropdownRef.value && !exportDropdownRef.value.contains(event.target)) {
|
||||
isExportOpen.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchRingkasan(pagination.value.current_page);
|
||||
document.addEventListener('click', closeDropdownsOnClickOutside);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', closeDropdownsOnClickOutside);
|
||||
});
|
||||
</script>
|
||||
397
resources/js/components/RiwayatTransaksi.vue
Normal file
@ -0,0 +1,397 @@
|
||||
<template>
|
||||
<div class="my-6">
|
||||
<!-- Divider -->
|
||||
<hr class="border-B mb-5" />
|
||||
|
||||
<!-- Filter Section -->
|
||||
<div class="flex flex-col md:flex-row justify-between my-3 gap-3 md:gap-5">
|
||||
<!-- Date Range Filter -->
|
||||
<div class="w-full md:w-1/3">
|
||||
<DatePicker v-model="dateRange" label="Filter Tanggal" placeholder="Pilih rentang tanggal" :max-days="31"
|
||||
@change="handleDateChange" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col sm:flex-row w-full md:w-1/3">
|
||||
<div class="flex-1 min-w-0">
|
||||
<input placeholder="Cari kode transaksi atau nama pembeli" v-model="searchQuery"
|
||||
class="mt-1 block w-full rounded-l-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>
|
||||
<button @click="handleSearch"
|
||||
class="mt-1 px-4 py-2 bg-C hover:bg-C/80 text-D rounded-r-md text-sm font-medium transition-colors">
|
||||
Cari
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Table Section -->
|
||||
<div class="mt-6 overflow-x-auto">
|
||||
<div class="bg-white rounded-md border border-C overflow-hidden">
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr class="bg-C text-D">
|
||||
<th class="border-x border-C px-3 py-3 text-left">
|
||||
<button @click="handleSort('created_at')"
|
||||
class="flex items-center justify-between w-full text-left hover:text-D/80 transition-colors">
|
||||
<span>Tanggal & Waktu</span>
|
||||
<i :class="getSortIcon('created_at')" class="ml-2"></i>
|
||||
</button>
|
||||
</th>
|
||||
<th class="border-x border-C px-3 py-3 text-left">
|
||||
<button @click="handleSort('kode_transaksi')"
|
||||
class="flex items-center justify-between w-full text-left hover:text-D/80 transition-colors">
|
||||
<span>Kode Transaksi</span>
|
||||
<i :class="getSortIcon('kode_transaksi')" class="ml-2"></i>
|
||||
</button>
|
||||
</th>
|
||||
<th class="border-x border-C px-3 py-3 text-left">
|
||||
<button @click="handleSort('nama_pembeli')"
|
||||
class="flex items-center justify-between w-full hover:text-D/80 transition-colors">
|
||||
<span>Nama Pembeli</span>
|
||||
<i :class="getSortIcon('nama_pembeli')" class="ml-2"></i>
|
||||
</button>
|
||||
</th>
|
||||
<th class="border-x border-C px-3 py-3 text-left">
|
||||
<button @click="handleSort('total_harga')"
|
||||
class="flex items-center justify-center w-full hover:text-D/80 transition-colors">
|
||||
<span>Total</span>
|
||||
<i :class="getSortIcon('total_harga')" class="ml-2"></i>
|
||||
</button>
|
||||
</th>
|
||||
<th class="border-x border-C px-3 py-3 text-center">
|
||||
<span>Jml</span>
|
||||
</th>
|
||||
<th class="border-r border-C px-3 py-3 text-center">
|
||||
<span>Aksi</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody class="divide-y divide-C/20">
|
||||
<!-- Loading Row -->
|
||||
<tr v-if="loading">
|
||||
<td :colspan="tableColumns" class="p-8 text-center">
|
||||
<div class="flex items-center justify-center">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-D"></div>
|
||||
<span class="ml-2 text-D/70">Memuat riwayat transaksi...</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Empty State Row -->
|
||||
<tr v-else-if="filteredTransaksi.length === 0 && !loading">
|
||||
<td :colspan="tableColumns" class="p-12 text-center">
|
||||
<div class="text-D/50 space-y-2">
|
||||
<svg class="w-16 h-16 mx-auto text-D/30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
||||
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 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" />
|
||||
</svg>
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm font-medium">Tidak ada transaksi ditemukan.</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Data Rows -->
|
||||
<template v-else v-for="trx in sortedTransaksi" :key="trx.id">
|
||||
<tr class="hover:bg-A/50 transition-colors">
|
||||
<!-- Tanggal & Waktu -->
|
||||
<td class="border-x border-C px-3 py-3">
|
||||
<div class="flex flex-row text-sm gap-2">
|
||||
<div class="text-D">{{ formatDate(trx.created_at) }},</div>
|
||||
<div class="text-D/60"> {{ formatTime(trx.created_at) }}</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<!-- Kode Transaksi -->
|
||||
<td class="text-sm border-x border-C px-3 py-3">
|
||||
{{ trx.kode_transaksi }}
|
||||
</td>
|
||||
|
||||
<!-- Nama pembeli -->
|
||||
<td class="text-sm border-x border-C px-3 py-3">
|
||||
{{ trx.nama_pembeli || '-' }}
|
||||
</td>
|
||||
|
||||
<!-- Total -->
|
||||
<td class="text-sm border-x border-C px-3 py-3 text-center">
|
||||
Rp{{ (trx.total_harga || 0).toLocaleString('id-ID') }}
|
||||
</td>
|
||||
|
||||
<!-- Jumlah Item -->
|
||||
<td class="text-sm border-x border-C px-3 py-3 text-center">
|
||||
{{ trx.total_items || 0 }}
|
||||
</td>
|
||||
|
||||
<!-- Aksi -->
|
||||
<td class="border-r border-C px-3 py-3 text-center">
|
||||
<button @click="lihatDetail(trx)"
|
||||
class="inline-flex items-center px-3 py-1.5 bg-C hover:bg-C/80 text-D rounded-md text-xs font-medium transition-colors"
|
||||
:disabled="isDetailLoading">
|
||||
<i v-if="isDetailLoading && selectedTransaksi.id === trx.id"
|
||||
class="fas fa-spinner fa-spin mr-1"></i>
|
||||
<span>Lihat Detail</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div v-if="pagination && pagination.total > 0 && pagination.last_page > 1"
|
||||
class="flex items-center justify-between gap-4 mt-6 px-1">
|
||||
<div class="text-sm text-D/70">
|
||||
Menampilkan {{ pagination.from }} - {{ pagination.to }} dari {{ pagination.total }} transaksi
|
||||
<span v-if="filteredTransaksi.length !== pagination.per_page" class="ml-2 text-blue-600">
|
||||
({{ filteredTransaksi.length }} sesuai filter)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<button @click="goToPage(pagination.current_page - 1)" :disabled="pagination.current_page === 1 || loading"
|
||||
class="px-3 py-2 text-sm font-medium border rounded-md bg-A border-C disabled:opacity-50 disabled:cursor-not-allowed hover:bg-C/50 transition-colors">
|
||||
<i class="fas fa-chevron-left mr-1"></i>
|
||||
Sebelumnya
|
||||
</button>
|
||||
|
||||
<span class="text-sm text-D/70 px-3">
|
||||
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-3 py-2 text-sm font-medium border rounded-md bg-A border-C disabled:opacity-50 disabled:cursor-not-allowed hover:bg-C/50 transition-colors">
|
||||
Berikutnya
|
||||
<i class="fas fa-chevron-right ml-1"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal Detail Transaksi -->
|
||||
<StrukView :is-open="isDetailOpen" :transaksi="selectedTransaksi" @close="closeDetail" />
|
||||
|
||||
<!-- Loading Overlay for Detail -->
|
||||
<div v-if="isDetailLoading" class="fixed inset-0 bg-black/60 flex items-center justify-center z-[9999] p-4">
|
||||
<div class="bg-white rounded-lg p-6 flex items-center gap-3 shadow-xl max-w-md w-full">
|
||||
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-D"></div>
|
||||
<span class="text-D/80">Memuat detail transaksi...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import axios from 'axios'
|
||||
import DatePicker from '@/components/DatePicker.vue'
|
||||
import StrukView from '@/components/StrukView.vue'
|
||||
|
||||
// Props & Emits
|
||||
const props = defineProps({
|
||||
initialData: {
|
||||
type: Object,
|
||||
default: () => ({ data: [], pagination: null })
|
||||
}
|
||||
})
|
||||
|
||||
// Reactive State
|
||||
const transaksi = ref(props.initialData.data || [])
|
||||
const pagination = ref(props.initialData.pagination || null)
|
||||
const loading = ref(false)
|
||||
const isDetailLoading = ref(false)
|
||||
|
||||
// Filter State
|
||||
const dateRange = ref({
|
||||
start: new Date().toISOString().split('T')[0],
|
||||
end: new Date().toISOString().split('T')[0]
|
||||
})
|
||||
const statusDipilih = ref('')
|
||||
const pembayaranDipilih = ref('')
|
||||
const searchQuery = ref('')
|
||||
|
||||
// Sort State
|
||||
const sortField = ref('created_at')
|
||||
const sortDirection = ref('desc')
|
||||
|
||||
// Modal State
|
||||
const isDetailOpen = ref(false)
|
||||
const selectedTransaksi = ref({})
|
||||
|
||||
// Computed
|
||||
const filteredTransaksi = computed(() => {
|
||||
let filtered = [...transaksi.value]
|
||||
|
||||
if (dateRange.value.start && dateRange.value.end) {
|
||||
const startDate = new Date(dateRange.value.start)
|
||||
const endDate = new Date(dateRange.value.end)
|
||||
endDate.setHours(23, 59, 59, 999)
|
||||
|
||||
filtered = filtered.filter(trx => {
|
||||
const trxDate = new Date(trx.created_at)
|
||||
return trxDate >= startDate && trxDate <= endDate
|
||||
})
|
||||
}
|
||||
|
||||
// Status filter
|
||||
if (statusDipilih.value) {
|
||||
filtered = filtered.filter(trx => trx.status === statusDipilih.value)
|
||||
}
|
||||
|
||||
// Payment method filter
|
||||
if (pembayaranDipilih.value) {
|
||||
filtered = filtered.filter(trx => trx.metode_pembayaran === pembayaranDipilih.value)
|
||||
}
|
||||
|
||||
// Removed searchQuery filter to prevent client-side filtering
|
||||
return filtered
|
||||
})
|
||||
|
||||
const sortedTransaksi = computed(() => {
|
||||
return [...filteredTransaksi.value].sort((a, b) => {
|
||||
let aVal = a[sortField.value] || ''
|
||||
let bVal = b[sortField.value] || ''
|
||||
|
||||
// Handle numeric fields
|
||||
if (['total_harga', 'total_items'].includes(sortField.value)) {
|
||||
aVal = parseFloat(aVal) || 0
|
||||
bVal = parseFloat(bVal) || 0
|
||||
}
|
||||
|
||||
if (aVal < bVal) return sortDirection.value === 'asc' ? -1 : 1
|
||||
if (aVal > bVal) return sortDirection.value === 'asc' ? 1 : -1
|
||||
return 0
|
||||
})
|
||||
})
|
||||
|
||||
const tableColumns = computed(() => 6)
|
||||
|
||||
// Methods
|
||||
const fetchTransaksi = async (page = 1) => {
|
||||
try {
|
||||
loading.value = true
|
||||
|
||||
const params = new URLSearchParams({
|
||||
page,
|
||||
limit: 10,
|
||||
start_date: dateRange.value.start,
|
||||
end_date: dateRange.value.end,
|
||||
status: statusDipilih.value,
|
||||
search: searchQuery.value,
|
||||
})
|
||||
|
||||
const response = await axios.get(`/api/transaksi?${params}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`
|
||||
}
|
||||
})
|
||||
|
||||
transaksi.value = response.data.data || []
|
||||
pagination.value = response.data.pagination || null
|
||||
|
||||
// console.log("data", transaksi.value)
|
||||
} catch (error) {
|
||||
console.error('Error fetching transaksi:', error)
|
||||
transaksi.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleDateChange = (newRange) => {
|
||||
dateRange.value = newRange
|
||||
pagination.value = null // Reset pagination
|
||||
fetchTransaksi(1)
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
pagination.value = null
|
||||
fetchTransaksi(1)
|
||||
}
|
||||
|
||||
const handleSort = (field) => {
|
||||
if (sortField.value === field) {
|
||||
sortDirection.value = sortDirection.value === 'asc' ? 'desc' : 'asc'
|
||||
} else {
|
||||
sortField.value = field
|
||||
sortDirection.value = 'asc'
|
||||
}
|
||||
|
||||
fetchTransaksi(1)
|
||||
}
|
||||
|
||||
const getSortIcon = (field) => {
|
||||
if (sortField.value !== field) return 'fas fa-sort text-D/40'
|
||||
|
||||
if (sortDirection.value === 'asc') return 'fas fa-sort-up text-D'
|
||||
return 'fas fa-sort-down text-D'
|
||||
}
|
||||
|
||||
const goToPage = (page) => {
|
||||
if (page >= 1 && page <= (pagination.value?.last_page || 1)) {
|
||||
fetchTransaksi(page)
|
||||
}
|
||||
}
|
||||
|
||||
const lihatDetail = async (trx) => {
|
||||
try {
|
||||
isDetailLoading.value = true
|
||||
selectedTransaksi.value = trx // Show loading state first
|
||||
|
||||
const response = await axios.get(`/api/transaksi/${trx.id}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`
|
||||
}
|
||||
})
|
||||
|
||||
selectedTransaksi.value = {
|
||||
...response.data,
|
||||
total_items: response.data.itemTransaksi?.length || 0
|
||||
}
|
||||
isDetailOpen.value = true
|
||||
} catch (error) {
|
||||
console.error('Error fetching detail:', error)
|
||||
alert('Gagal memuat detail transaksi: ' + (error.response?.data?.message || error.message))
|
||||
} finally {
|
||||
isDetailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const closeDetail = () => {
|
||||
isDetailOpen.value = false
|
||||
selectedTransaksi.value = {}
|
||||
}
|
||||
|
||||
// Helper Functions
|
||||
const formatDate = (dateString) => {
|
||||
return new Date(dateString).toLocaleDateString('id-ID', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric'
|
||||
})
|
||||
}
|
||||
|
||||
const formatTime = (dateString) => {
|
||||
return new Date(dateString).toLocaleTimeString('id-ID', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
fetchTransaksi()
|
||||
})
|
||||
|
||||
// Watchers
|
||||
import { watch } from 'vue'
|
||||
|
||||
watch([statusDipilih, pembayaranDipilih], () => {
|
||||
pagination.value = null
|
||||
fetchTransaksi(1)
|
||||
}, { deep: true })
|
||||
</script>
|
||||
404
resources/js/components/StrukOverlay.vue
Normal file
@ -0,0 +1,404 @@
|
||||
<template>
|
||||
<div v-if="isOpen"
|
||||
class="text-D pt-serif-regular-italic 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-yellow-500 h-8 w-full">
|
||||
<div class="bg-D h-6 w-full"></div>
|
||||
</div>
|
||||
|
||||
<div class="p-6 text-sm flex flex-col h-full relative">
|
||||
<div class="relative flex items-center justify-between top-0 pb-1 mb-2">
|
||||
<div class="flex flex-col gap-2 -mt-5">
|
||||
<p class="flex items-center gap-2">
|
||||
<i class="fab fa-instagram text-pink-600 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>
|
||||
<p class=" text-sm">{{ generateTransactionCode() }}</p>
|
||||
</div>
|
||||
|
||||
<div class="absolute inset-x-0 top-[-48px] flex flex-col items-center">
|
||||
<img :src="logo" alt="Logo" class="h-40" />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-[130px_1fr] gap-y-0 text-xs items-center -mt-5 relative z-10">
|
||||
<div class="text-right font-semibold pr-3">Tanggal :</div>
|
||||
<p class="mt-1 text-left pl-2">{{ getCurrentDate() }}</p>
|
||||
|
||||
<div class="text-right font-semibold pr-3">Nama :</div>
|
||||
<inputField v-model="namaPembeli" class="h-7 text-sm rounded bg-blue-200 w-full" />
|
||||
|
||||
<div class="text-right font-semibold pr-3">Alamat :</div>
|
||||
<inputField v-model="alamat" class="h-7 px-2 text-sm rounded bg-blue-200 w-full" />
|
||||
|
||||
<div class="text-right font-semibold pr-3">No.Hp :</div>
|
||||
<inputField v-model="nomorTelepon" class="h-7 px-2 text-sm rounded bg-blue-200 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex mb-1 gap-179">
|
||||
<div class="flex gap-4">
|
||||
<img :src="logo_bca" alt="Logo_bca" class="h-5" />
|
||||
<img :src="logo_bri" alt="Logo_bri" class="h-5" />
|
||||
<img :src="logo_bni" alt="Logo_bni" class="h-5" />
|
||||
</div>
|
||||
<div class="flex gap-4">
|
||||
<img :src="logo_mastercard" alt="Logo_mastercard" class="h-5" />
|
||||
<img :src="logo_visa" alt="Logo_visa" class="h-5" />
|
||||
<img :src="logo_mandiri" alt="Logo_mandiri" class="h-5" />
|
||||
</div>
|
||||
</div>
|
||||
<table class="w-full border-D text-sm table-fixed border-b">
|
||||
<thead>
|
||||
<tr class="border-b border-t border-D">
|
||||
<th class="w-[40px] border-r text-lg border-D">Jml</th>
|
||||
<th class="w-[425px] py-2 text-lg border-r border-D">Item</th>
|
||||
<th class="w-[70px] border-r text-lg border-D">Posisi</th>
|
||||
<th class="w-[40px] border-r text-lg border-D">Berat</th>
|
||||
<th class="w-[40px] border-r text-lg border-D">Kadar</th>
|
||||
<th class="w-[175px] text-lg">Harga</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
<!-- Item rows dengan dynamic height -->
|
||||
<tr v-for="(item, index) in props.pesanan" :key="index"
|
||||
class="text-center"
|
||||
:style="getRowStyle()">
|
||||
<td class="border-r border-D">
|
||||
<span v-if="item.harga_deal">1</span>
|
||||
</td>
|
||||
<td class="flex items-center gap-2 p-2 border-r border-D" :style="getRowStyle()">
|
||||
<template v-if="item.produk?.foto?.[0]?.url">
|
||||
<img :src="item.produk.foto[0].url"
|
||||
:class="getImageClass()"
|
||||
class="object-cover" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<div :class="getImageClass()"></div>
|
||||
</template>
|
||||
<span :class="getTextClass()">{{ item.produk?.nama || '' }}</span>
|
||||
</td>
|
||||
<td class="border-r border-D">{{ item.produk.nama ? (item.nampan?.nama || 'Brankas') : '' }}</td>
|
||||
<td class="border-r border-D">
|
||||
<span v-if="item.produk?.berat">{{ item.produk.berat }}g</span>
|
||||
</td>
|
||||
<td class="border-r border-D">
|
||||
<span v-if="item.produk?.kadar">{{ item.produk.kadar }}k</span>
|
||||
</td>
|
||||
<td>
|
||||
<span v-if="item.harga_deal">Rp{{ item.harga_deal.toLocaleString() }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<!-- Bagian bawah -->
|
||||
<div class="flex text-sm mt-2">
|
||||
<!-- PERHATIAN -->
|
||||
<div class="w-[40%] p-2 text-left">
|
||||
<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 dipotong ongkos bikin, barang rusak
|
||||
lain harga.</li>
|
||||
<li>Barang yang sudah dibeli berarti sudah diperiksa dan disetujui.</li>
|
||||
<li class="text-red-500">Surat ini harap dibawa pada saat menjual kembali.</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<!-- SALES -->
|
||||
<div class="w-[20%] p-2 flex flex-col items-center justify-center">
|
||||
<p><strong>Hormat Kami</strong></p>
|
||||
<inputSelect v-model="selectedSales" :options="salesOptions"
|
||||
class="mt-16 text-sm rounded bg-B cursor-pointer !w-[160px] text-center [option]:text-left" />
|
||||
</div>
|
||||
|
||||
<!-- ONGKOS & TOTAL -->
|
||||
<div class="ml-auto w-[25%] p-2 flex flex-col justify-between">
|
||||
<div class="space-y-4">
|
||||
<!-- Ongkos bikin -->
|
||||
<div class="flex items-start justify-between ">
|
||||
<div class="flex flex-col ">
|
||||
<p class="font-semibold">Ongkos bikin</p>
|
||||
<p class="text-red-500 text-xs">diluar harga jual</p>
|
||||
</div>
|
||||
<div class="flex items-center w-40">
|
||||
<p>Rp</p>
|
||||
<input type="text" v-model="ongkosBikinFormatted" @input="formatInput"
|
||||
class="h-7 px-2 text-sm rounded bg-blue-200 text-left w-full" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Total -->
|
||||
<div class="flex items-center justify-between -mt-4">
|
||||
<p class="font-semibold">Total Harga</p>
|
||||
<div class="flex items-center w-40">
|
||||
<p>Rp</p>
|
||||
<p class="px-3 pl-0 py-1 text-left text-sm w-full">
|
||||
{{ grandTotal.toLocaleString() }},-
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tombol -->
|
||||
<div class="flex justify-end gap-2 mt-4">
|
||||
<button @click="$emit('close')" class="bg-gray-400 text-white px-6 py-2 rounded">
|
||||
Batal
|
||||
</button>
|
||||
<button @click="handleSimpan" class="bg-C text-white px-6 py-2 rounded">
|
||||
Simpan
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="absolute p-8 bottom-0 left-0 w-full text-left text-xs bg-D text-white py-1">
|
||||
Terima kasih sudah berbelanja dengan kami
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Simple Toast Alert -->
|
||||
<div v-if="showToast"
|
||||
class="fixed top-4 left-1/2 transform -translate-x-1/2 z-[10001]
|
||||
transition-all duration-300 ease-in-out"
|
||||
:class="toastClasses">
|
||||
<div class="flex items-center gap-2 px-4 py-3 rounded-lg shadow-lg max-w-sm">
|
||||
<!-- Icon -->
|
||||
<div class="flex-shrink-0">
|
||||
<svg v-if="toastType === 'error'" class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<svg v-else-if="toastType === 'success'" class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<svg v-else class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Message -->
|
||||
<p class="text-sm font-medium">{{ toastMessage }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import logo from '@/../images/logo.png'
|
||||
import logo_bca from '@/../images/logo_bca.png'
|
||||
import logo_bri from '@/../images/logo_bri.png'
|
||||
import logo_bni from '@/../images/logo_bni.png'
|
||||
import logo_mastercard from '@/../images/logo_mastercard.png'
|
||||
import logo_visa from '@/../images/logo_visa.png'
|
||||
import logo_mandiri from '@/../images/logo_mandiri.png'
|
||||
import inputField from '@/components/InputField.vue'
|
||||
import inputSelect from '@/components/InputSelect.vue'
|
||||
|
||||
import axios from 'axios'
|
||||
|
||||
const props = defineProps({
|
||||
isOpen: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
pesanan: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
total: {
|
||||
type: Number,
|
||||
default: 0
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'confirm'])
|
||||
|
||||
const namaPembeli = ref('')
|
||||
const nomorTelepon = ref('')
|
||||
const alamat = ref('')
|
||||
const ongkosBikin = ref(0)
|
||||
const selectedSales = ref(null)
|
||||
const salesOptions = ref([])
|
||||
const ongkosBikinFormatted = ref("")
|
||||
|
||||
// Simple Toast State
|
||||
const showToast = ref(false)
|
||||
const toastType = ref('error') // 'error', 'success', 'info'
|
||||
const toastMessage = ref('')
|
||||
|
||||
const toastClasses = computed(() => {
|
||||
const baseClasses = 'text-white'
|
||||
const typeClasses = {
|
||||
error: 'bg-red-500',
|
||||
success: 'bg-green-500',
|
||||
info: 'bg-blue-500'
|
||||
}
|
||||
return `${baseClasses} ${typeClasses[toastType.value]}`
|
||||
})
|
||||
|
||||
const grandTotal = computed(() => {
|
||||
return props.total + (ongkosBikin.value || 0)
|
||||
})
|
||||
|
||||
// Fungsi untuk menentukan style row berdasarkan jumlah item
|
||||
const getRowStyle = () => {
|
||||
if (props.pesanan.length === 1) {
|
||||
return { height: '126px' } // 2x lipat dari tinggi normal (48px)
|
||||
}
|
||||
return { height: '63px' } // Tinggi normal
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Fungsi untuk menentukan class gambar berdasarkan jumlah item
|
||||
const getImageClass = () => {
|
||||
if (props.pesanan.length === 1) {
|
||||
return 'w-25 h-25' // 2x lipat dari ukuran normal (w-10 h-10)
|
||||
}
|
||||
return 'w-12 h-12' // Ukuran normal
|
||||
}
|
||||
|
||||
// Fungsi untuk menentukan class text berdasarkan jumlah item
|
||||
const getTextClass = () => {
|
||||
if (props.pesanan.length === 1) {
|
||||
return 'text-lg font-medium' // Text lebih besar untuk single item
|
||||
}
|
||||
return 'text-sm' // Text normal
|
||||
}
|
||||
|
||||
const getCurrentDate = () => {
|
||||
const days = ['Minggu', 'Senin', 'Selasa', 'Rabu', 'Kamis', 'Jumat', 'Sabtu']
|
||||
const months = ['01', '02', '03', '04', '05', '06', '07', '08', '09', '10', '11', '12']
|
||||
|
||||
const now = new Date()
|
||||
const dayName = days[now.getDay()]
|
||||
const day = String(now.getDate()).padStart(2, '0')
|
||||
const month = months[now.getMonth()]
|
||||
const year = now.getFullYear()
|
||||
|
||||
return `${dayName}/${day}-${month}-${year}`
|
||||
}
|
||||
|
||||
const generateTransactionCode = () => {
|
||||
const now = new Date()
|
||||
const timestamp = now.getTime().toString().slice(-6)
|
||||
return `TRS-${timestamp}`
|
||||
}
|
||||
|
||||
// Simple Toast Function
|
||||
const showSimpleToast = (type, message, duration = 3000) => {
|
||||
toastType.value = type
|
||||
toastMessage.value = message
|
||||
showToast.value = true
|
||||
|
||||
setTimeout(() => {
|
||||
showToast.value = false
|
||||
}, duration)
|
||||
}
|
||||
|
||||
const fetchSales = async () => {
|
||||
try {
|
||||
const response = await axios.get('/api/sales', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||
},
|
||||
})
|
||||
|
||||
salesOptions.value = response.data.map(sales => ({
|
||||
value: sales.id,
|
||||
label: sales.nama
|
||||
}))
|
||||
|
||||
if (salesOptions.value.length > 0) {
|
||||
selectedSales.value = salesOptions.value[0].value
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching sales:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSimpan = () => {
|
||||
if (!namaPembeli.value.trim()) {
|
||||
showSimpleToast('error', 'Nama pembeli harus diisi!')
|
||||
return
|
||||
}
|
||||
|
||||
if (!nomorTelepon.value.trim()) {
|
||||
showSimpleToast('error', 'Nomor telepon harus diisi!')
|
||||
return
|
||||
}
|
||||
|
||||
if (!alamat.value.trim()) {
|
||||
showSimpleToast('error', 'Alamat harus diisi!')
|
||||
return
|
||||
}
|
||||
|
||||
if (!selectedSales.value) {
|
||||
showSimpleToast('error', 'Sales harus dipilih!')
|
||||
return
|
||||
}
|
||||
|
||||
simpanTransaksi({
|
||||
id_sales: selectedSales.value,
|
||||
nama_pembeli: namaPembeli.value,
|
||||
no_hp: nomorTelepon.value,
|
||||
alamat: alamat.value,
|
||||
ongkos_bikin: ongkosBikin.value || 0, // Pastikan nama field benar
|
||||
total_harga: grandTotal.value,
|
||||
items: props.pesanan
|
||||
})
|
||||
}
|
||||
|
||||
const simpanTransaksi = async (dataTransaksi) => {
|
||||
// console.log('Data transaksi yang akan disimpan:', dataTransaksi);
|
||||
|
||||
try {
|
||||
const response = await axios.post('/api/transaksi', dataTransaksi, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("token")}`,
|
||||
},
|
||||
});
|
||||
|
||||
showSimpleToast('success', 'Transaksi berhasil disimpan!', 2000)
|
||||
|
||||
// Delay untuk memberikan waktu user membaca notifikasi
|
||||
setTimeout(() => {
|
||||
emit('close');
|
||||
window.location.reload();
|
||||
}, 2200);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error saving transaksi:', error);
|
||||
const errorMessage = error.response?.data?.message || error.message || 'Terjadi kesalahan saat menyimpan transaksi';
|
||||
showSimpleToast('error', `Error: ${errorMessage}`, 4000);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
if (props.isOpen) {
|
||||
fetchSales()
|
||||
}
|
||||
})
|
||||
|
||||
function formatInput(e) {
|
||||
let value = e.target.value.replace(/\D/g, "");
|
||||
ongkosBikin.value = value ? parseInt(value, 10) : null;
|
||||
ongkosBikinFormatted.value = value
|
||||
? new Intl.NumberFormat("id-ID").format(value)
|
||||
: "";
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@import url('https://fonts.googleapis.com/css2?family=PT+Serif:ital,wght@0,400;0,700;1,400;1,700&display=swap');
|
||||
|
||||
.pt-serif-regular-italic {
|
||||
font-family: "PT Serif", serif;
|
||||
font-weight: 400;
|
||||
}
|
||||
</style>
|
||||
296
resources/js/components/StrukView.vue
Normal file
@ -0,0 +1,296 @@
|
||||
<template>
|
||||
<div v-if="isOpen"
|
||||
class="text-D pt-serif-regular-italic fixed inset-0 bg-black/75 flex items-center justify-center z-[9999]">
|
||||
|
||||
<!-- print-area untuk fokus saat print -->
|
||||
<div class="print-area bg-white w-[1224px] h-[528px] shadow-lg relative overflow-hidden">
|
||||
<div class="bg-yellow-500 h-8 w-full">
|
||||
<div class="bg-D h-6 w-full"></div>
|
||||
</div>
|
||||
|
||||
<div class="p-6 text-sm flex flex-col h-full relative">
|
||||
<div class="relative flex items-center justify-between top-0 pb-1 mb-2">
|
||||
<div class="flex flex-col gap-2 -mt-5">
|
||||
<p class="flex items-center gap-2">
|
||||
<i class="fab fa-instagram text-pink-600 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>
|
||||
<p class=" text-sm">{{ transaksi.kode_transaksi || 'N/A' }}</p>
|
||||
</div>
|
||||
|
||||
<div class="absolute inset-x-0 top-[-48px] flex flex-col items-center">
|
||||
<img :src="logo" alt="Logo" class="h-40" />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-[130px_1fr] gap-y-1 text-xs items-center -mt-5 relative z-10">
|
||||
<div class="text-right font-semibold pr-3">Tanggal :</div>
|
||||
<p class="text-left pl-2">{{ formatDate(transaksi.created_at) }}</p>
|
||||
|
||||
<div class="text-right font-semibold pr-3">Nama :</div>
|
||||
<p class="text-left pl-2">{{ transaksi.nama_pembeli || '-' }}</p>
|
||||
|
||||
<div class="text-right font-semibold pr-3">Alamat :</div>
|
||||
<p class="text-left pl-2">{{ transaksi.alamat || '-' }}</p>
|
||||
|
||||
<div class="text-right font-semibold pr-3">No.Hp :</div>
|
||||
<p class="text-left pl-2">{{ transaksi.no_hp || '-' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex mb-1 gap-179">
|
||||
<div class="flex gap-4">
|
||||
<img :src="logo_bca" alt="Logo_bca" class="h-5" />
|
||||
<img :src="logo_bri" alt="Logo_bri" class="h-5" />
|
||||
<img :src="logo_bni" alt="Logo_bni" class="h-5" />
|
||||
</div>
|
||||
<div class="flex gap-4">
|
||||
<img :src="logo_mastercard" alt="Logo_mastercard" class="h-5" />
|
||||
<img :src="logo_visa" alt="Logo_visa" class="h-5" />
|
||||
<img :src="logo_mandiri" alt="Logo_mandiri" class="h-5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class="w-full border-D text-sm table-fixed border-b">
|
||||
<thead>
|
||||
<tr class="border-b border-t border-D">
|
||||
<th class="w-[40px] border-r text-lg border-D">Jml</th>
|
||||
<th class="w-[425px] py-2 text-lg border-r border-D">Item</th>
|
||||
<th class="w-[70px] border-r text-lg border-D">Posisi</th>
|
||||
<th class="w-[40px] border-r text-lg border-D">Berat</th>
|
||||
<th class="w-[40px] border-r text-lg border-D">Kadar</th>
|
||||
<th class="w-[175px] text-lg">Harga</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
<tr v-for="(item, index) in itemsWithMinimal" :key="index" class="text-center" :style="getRowStyle()">
|
||||
<td class="border-r border-D">
|
||||
<span v-if="item.harga_deal && item.harga_deal > 0">1</span>
|
||||
</td>
|
||||
<td class="flex items-center gap-2 p-2 border-r border-D" :style="getRowStyle()">
|
||||
<template v-if="item.produk?.foto?.[0]?.url">
|
||||
<img :src="item.produk.foto[0].url" :class="getImageClass()" class="object-cover rounded" />
|
||||
</template>
|
||||
<template v-else-if="item.produk?.nama">
|
||||
<div :class="getImageClass() + ' bg-gray-200 rounded flex items-center justify-center'">
|
||||
<span class="text-xs text-gray-500">IMG</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div :class="getImageClass()"></div>
|
||||
</template>
|
||||
<span :class="getTextClass()">{{ item.produk?.nama || '' }}</span>
|
||||
</td>
|
||||
<td class="border-r border-D">
|
||||
<span v-if="item.produk?.nama">{{ item.posisi_asal || 'Brankas' }}</span>
|
||||
</td>
|
||||
<td class="border-r border-D">
|
||||
<span v-if="item.produk?.berat">{{ formatNumber(item.produk.berat) }}g</span>
|
||||
</td>
|
||||
<td class="border-r border-D">
|
||||
<span v-if="item.produk?.kadar">{{ item.produk.kadar }}k</span>
|
||||
</td>
|
||||
<td>
|
||||
<span v-if="item.harga_deal && item.harga_deal > 0">
|
||||
Rp{{ formatNumber(item.harga_deal) }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
<div class="flex text-sm mt-2">
|
||||
|
||||
<div class="w-[40%] p-2 text-left">
|
||||
<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 dipotong ongkos bikin, barang rusak lain harga.</li>
|
||||
<li>Barang yang sudah dibeli berarti sudah diperiksa dan disetujui.</li>
|
||||
<li class="text-red-500">Surat ini harap dibawa pada saat menjual kembali.</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="w-[20%] p-2 flex flex-col items-center justify-center">
|
||||
<p><strong>Hormat Kami</strong></p>
|
||||
<div class="mt-16 text-sm text-center">
|
||||
<p class="font-semibold">{{ transaksi.nama_sales || '-' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="ml-auto w-[25%] p-2 flex flex-col justify-between">
|
||||
<div class="space-y-4">
|
||||
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex flex-col">
|
||||
<p class="font-semibold">Ongkos bikin</p>
|
||||
<p class="text-red-500 text-xs">diluar harga jual</p>
|
||||
</div>
|
||||
<div class="flex p-1 items-center w-40 bg-B rounded-sm">
|
||||
<p>Rp</p>
|
||||
<p class="px-2 pl-0 text-left text-sm w-full">
|
||||
{{ (transaksi.ongkos_bikin || 0).toLocaleString() }},-
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Total -->
|
||||
<div class="flex items-center justify-between -mt-4">
|
||||
<p class="font-semibold">Total Harga</p>
|
||||
<div class="flex items-center w-40">
|
||||
<p>Rp</p>
|
||||
<p class="px-3 pl-0 py-1 text-left text-sm w-full">
|
||||
{{ (transaksi.total_harga || 0).toLocaleString() }},-
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tombol -->
|
||||
<div class="flex justify-end gap-2 mt-4 no-print">
|
||||
<button @click="$emit('close')" class="bg-gray-400 text-white px-6 py-2 rounded">
|
||||
Tutup
|
||||
</button>
|
||||
<button @click="handlePrint" class="bg-C text-white px-6 py-2 rounded">
|
||||
Print
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="absolute p-8 bottom-0 left-0 w-full text-left text-xs bg-D text-white py-1">
|
||||
Terima kasih sudah berbelanja dengan kami
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import logo from '@/../images/logo.png'
|
||||
import logo_bca from '@/../images/logo_bca.png'
|
||||
import logo_bri from '@/../images/logo_bri.png'
|
||||
import logo_bni from '@/../images/logo_bni.png'
|
||||
import logo_mastercard from '@/../images/logo_mastercard.png'
|
||||
import logo_visa from '@/../images/logo_visa.png'
|
||||
import logo_mandiri from '@/../images/logo_mandiri.png'
|
||||
|
||||
const props = defineProps({
|
||||
isOpen: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
transaksi: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return '-'
|
||||
const days = ['Minggu','Senin','Selasa','Rabu','Kamis','Jumat','Sabtu']
|
||||
const months = ['01','02','03','04','05','06','07','08','09','10','11','12']
|
||||
const date = new Date(dateString)
|
||||
const dayName = days[date.getDay()]
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
const month = months[date.getMonth()]
|
||||
const year = date.getFullYear()
|
||||
return `${dayName}/${day}-${month}-${year}`
|
||||
}
|
||||
|
||||
const itemsWithMinimal = computed(() => {
|
||||
const items = props.transaksi.itemTransaksi ||
|
||||
props.transaksi.items ||
|
||||
props.transaksi.item_transaksi ||
|
||||
[]
|
||||
const arr = [...items]
|
||||
|
||||
if (arr.length === 0) arr.push({ produk: {}, harga_deal: 0, posisi_asal: '' })
|
||||
return arr
|
||||
})
|
||||
|
||||
|
||||
const getRowStyle = () => {
|
||||
if (itemsWithMinimal.value.length === 1) {
|
||||
return { height: '126px' }
|
||||
}
|
||||
return { height: '63px' }
|
||||
}
|
||||
const getImageClass = () => {
|
||||
if (itemsWithMinimal.value.length === 1) {
|
||||
return 'w-25 h-25'
|
||||
}
|
||||
return 'w-12 h-12'
|
||||
}
|
||||
const getTextClass = () => {
|
||||
if (itemsWithMinimal.value.length === 1) {
|
||||
return 'text-lg font-medium'
|
||||
}
|
||||
return 'text-sm'
|
||||
}
|
||||
|
||||
const handlePrint = () => {
|
||||
window.print()
|
||||
}
|
||||
|
||||
const formatNumber = (number) => {
|
||||
if (!number) return 0
|
||||
return parseFloat(number).toLocaleString('id-ID')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@import url('https://fonts.googleapis.com/css2?family=PT+Serif:ital,wght@0,400;0,700;1,400;1,700&display=swap');
|
||||
|
||||
.pt-serif-regular-italic {
|
||||
font-family: "PT Serif", serif;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
|
||||
@media print {
|
||||
@page {
|
||||
size: A4; /* atau '80mm 200mm' kalau thermal */
|
||||
margin: Minimum;
|
||||
}
|
||||
/* Sembunyikan semua elemen di luar print-area */
|
||||
body * {
|
||||
visibility: hidden !important;
|
||||
}
|
||||
.print-area * {
|
||||
visibility: visible !important;
|
||||
-webkit-print-color-adjust: exact !important;
|
||||
print-color-adjust: exact !important;
|
||||
|
||||
}
|
||||
|
||||
.print-area {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 1224px;
|
||||
height: 528px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
transform: scale(0.6673);
|
||||
transform-origin: top left;
|
||||
}
|
||||
|
||||
/* Hilangkan tombol tutup & print */
|
||||
.no-print {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,118 +1,355 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Loading -->
|
||||
<div v-if="loading" class="text-center py-6">Loading...</div>
|
||||
<!-- Tampilkan berat rata-rata -->
|
||||
<div class="bg-A border border-C rounded-xl p-4 mx-6 mb-6">
|
||||
<div class="flex flex-row sm:items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 bg-A rounded-lg">
|
||||
<i class="fas fa-weight text-D"></i>
|
||||
</div>
|
||||
<div class="flex flex-col sm:flex-row sm:gap-6 text-sm text-gray-600">
|
||||
<span>Total: {{ totalTrays }}</span>
|
||||
<span>Berisi: {{ nonEmptyTrays }}</span>
|
||||
<span>Kosong: {{ emptyTrays }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-2xl font-bold text-D">{{ averageWeight }}g</div>
|
||||
<div class="text-sm text-gray-500">Rata-rata</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<!-- 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-for="tray in filteredTrays"
|
||||
:key="tray.id"
|
||||
class="border rounded-lg p-4 shadow-sm hover:shadow-md transition"
|
||||
>
|
||||
<!-- Header Nampan -->
|
||||
<!-- Grid Card -->
|
||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 items-stretch px-6">
|
||||
<div v-for="tray in filteredTrays" :key="tray.id"
|
||||
class="border border-C rounded-xl p-4 shadow-sm hover:shadow-md transition flex flex-col h-full">
|
||||
<!-- Header Card -->
|
||||
<div class="flex justify-between items-center mb-3">
|
||||
<h2 class="font-bold text-lg">{{ 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>
|
||||
<h2 class="font-bold text-lg text-[#102C57]">{{ tray.nama }}</h2>
|
||||
<div class="flex gap-2" v-if="isAdmin">
|
||||
<button class="p-1 rounded" @click="emit('edit', tray)">
|
||||
<i class="fa fa-pen fa-sm text-yellow-500 hover:text-yellow-600"></i>
|
||||
</button>
|
||||
<button class="p-1 rounded" @click="emit('delete', tray)">
|
||||
<i class="fa fa-trash fa-sm text-red-500 hover:text-red-600"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Isi Nampan -->
|
||||
<div v-if="tray.items && tray.items.length > 0" class="space-y-2">
|
||||
<div
|
||||
v-for="item in tray.items"
|
||||
:key="item.id"
|
||||
class="flex justify-between items-center border rounded-lg p-2"
|
||||
>
|
||||
<!-- Gambar + Info -->
|
||||
<!-- Isi Card (Max tinggi 3 item + scroll kalau lebih) -->
|
||||
<div v-if="tray.items && tray.items.length" class="space-y-2 flex-1 overflow-y-auto max-h-[168px] pr-1">
|
||||
<div v-for="item in tray.items" :key="item.id"
|
||||
class="flex justify-between items-center border border-C rounded-lg p-2 cursor-pointer hover:bg-gray-50"
|
||||
@click="openMovePopup(item)">
|
||||
<div class="flex items-center gap-3">
|
||||
<img
|
||||
:src="item.image"
|
||||
alt="Product Image"
|
||||
class="w-12 h-12 object-contain"
|
||||
/>
|
||||
<div>
|
||||
<p class="font-semibold">{{ item.produk.nama }}</p>
|
||||
<p class="text-sm text-gray-500">{{ item.produk.id }}</p>
|
||||
<img 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 class="text-D">
|
||||
<p class="text-sm">{{ item.produk?.nama }}</p>
|
||||
<p class="text-sm font-medium">{{ item.kode_item }}</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">
|
||||
<!-- Kalau kosong -->
|
||||
<div v-else class="text-gray-400 text-center py-4 flex-1">
|
||||
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">
|
||||
Berat Total: {{ totalWeight(tray) }}g
|
||||
<!-- Footer Card -->
|
||||
<div class="border-t border-C mt-3 pt-2 text-right font-semibold">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-sm text-gray-500">{{ tray.items?.length || 0 }} item</span>
|
||||
<span class="text-lg">Berat Total: {{ totalWeight(tray) }}g</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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-2">
|
||||
<div class="p-2 border rounded-lg">
|
||||
<img :src="qrCodeUrl" alt="QR Code" class="size-36" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center text-D font-bold text-lg">
|
||||
{{ selectedItem.kode_item }}
|
||||
</div>
|
||||
<div class="text-center text-gray-700 font-medium mb-3">
|
||||
{{ selectedItem.produk.nama }}
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center mb-4">
|
||||
<button @click="printQR" class="bg-D text-A px-4 py-2 rounded hover:bg-D/80 transition">
|
||||
<i class="fas fa-print mr-2"></i>Cetak
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Dropdown -->
|
||||
<div class="mb-4">
|
||||
<label for="tray-select" class="block text-sm font-medium mb-1">Nama Nampan</label>
|
||||
<InputSelect v-if="isAdmin" v-model="selectedTrayId"
|
||||
:options="trays.map(tray => ({ label: tray.nama, value: tray.id }))" placeholder="Pilih Nampan"
|
||||
class="mt-2" />
|
||||
<div class="bg-A px-3 py-2 rounded text-D font-medium" v-else>
|
||||
{{trays.find(tray => tray.id === selectedTrayId)?.nama}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<button @click="closePopup" class="px-4 py-2 rounded bg-gray-400 hover:bg-gray-500 text-white transition">
|
||||
{{ isAdmin ? 'Batal' : 'Tutup' }}
|
||||
</button>
|
||||
|
||||
<!-- Tombol Hapus hanya muncul kalau Admin -->
|
||||
<button v-if="isAdmin" @click="showDeleteConfirm = true"
|
||||
class="px-4 py-2 rounded bg-red-500 text-white hover:bg-red-600 transition flex items-center">
|
||||
<i class="fas fa-trash mr-2"></i>Hapus
|
||||
</button>
|
||||
|
||||
<button v-if="isAdmin" @click="saveMove" :disabled="!selectedTrayId" class="px-4 py-2 rounded transition"
|
||||
:class="selectedTrayId ? 'bg-C hover:bg-C/80 text-D' : 'bg-gray-400 cursor-not-allowed'">
|
||||
Simpan
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal Konfirmasi Hapus -->
|
||||
<ConfirmDeleteModal :isOpen="showDeleteConfirm" title="Konfirmasi Hapus Item"
|
||||
message="Apakah kamu yakin ingin menghapus item ini?" confirmText="Ya, Hapus" cancelText="Batal"
|
||||
@confirm="confirmDelete" @cancel="cancelDelete" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from "vue";
|
||||
import axios from "axios";
|
||||
import InputSelect from "./InputSelect.vue";
|
||||
import ConfirmDeleteModal from './ConfirmDeleteModal.vue';
|
||||
|
||||
const isAdmin = localStorage.getItem("role") === "owner";
|
||||
|
||||
// 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 Pop-up ---
|
||||
const isPopupVisible = ref(false);
|
||||
const selectedItem = ref(null);
|
||||
const selectedTrayId = ref("");
|
||||
const showDeleteConfirm = ref(false);
|
||||
|
||||
// QR Code generator
|
||||
const qrCodeUrl = computed(() => {
|
||||
if (selectedItem.value) {
|
||||
const data = selectedItem.value.kode_item;
|
||||
return `https://api.qrserver.com/v1/create-qr-code/?size=150x150&data=${encodeURIComponent(data)}`;
|
||||
}
|
||||
return "";
|
||||
});
|
||||
|
||||
const printQR = () => {
|
||||
if (qrCodeUrl.value) {
|
||||
const printWindow = window.open('', '_blank');
|
||||
printWindow.document.write(`
|
||||
<html>
|
||||
<head>
|
||||
<title>Print QR Code - ${selectedItem.value.kode_item}</title>
|
||||
<style>
|
||||
@page {
|
||||
size: 60mm 50mm;
|
||||
margin: 1mm;
|
||||
}
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
}
|
||||
.qr-container {
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
||||
.qr-img {
|
||||
width: 40mm;
|
||||
height: 40mm;
|
||||
margin-bottom: 2mm;
|
||||
}
|
||||
.kode-item {
|
||||
font-weight: bold;
|
||||
font-size: 14pt;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="qr-container">
|
||||
<img id="qr-img" class="qr-img" src="${qrCodeUrl.value}" alt="QR Code" />
|
||||
<div class="kode-item">${selectedItem.value.kode_item}</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
|
||||
printWindow.document.close();
|
||||
|
||||
const img = printWindow.document.getElementById("qr-img");
|
||||
img.onload = () => {
|
||||
printWindow.focus();
|
||||
printWindow.print();
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// ambil data dari backend
|
||||
onMounted(async () => {
|
||||
const confirmDelete = async () => {
|
||||
if (!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.delete(`/api/item/${selectedItem.value.id}`, {
|
||||
headers: { Authorization: `Bearer ${localStorage.getItem("token")}` },
|
||||
});
|
||||
|
||||
await refreshData();
|
||||
showDeleteConfirm.value = false;
|
||||
closePopup();
|
||||
} catch (err) {
|
||||
console.error("Gagal menghapus item:", err.response?.data || err);
|
||||
error.value = err.response?.data?.message || "Gagal menghapus item. Silakan coba lagi.";
|
||||
}
|
||||
};
|
||||
|
||||
const cancelDelete = () => {
|
||||
showDeleteConfirm.value = false;
|
||||
};
|
||||
|
||||
// --- Fungsi Pop-up ---
|
||||
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);
|
||||
error.value = err.response?.data?.message || "Gagal memindahkan item. Silakan coba lagi.";
|
||||
}
|
||||
};
|
||||
|
||||
// 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);
|
||||
};
|
||||
|
||||
// Computed untuk statistik berat rata-rata
|
||||
const averageWeight = computed(() => {
|
||||
const nonEmptyTraysData = trays.value.filter(tray => {
|
||||
const weight = parseFloat(totalWeight(tray));
|
||||
return weight > 0;
|
||||
});
|
||||
|
||||
if (nonEmptyTraysData.length === 0) return "0.00";
|
||||
|
||||
const totalWeightSum = nonEmptyTraysData.reduce((sum, tray) => {
|
||||
return sum + parseFloat(totalWeight(tray));
|
||||
}, 0);
|
||||
|
||||
const average = totalWeightSum / nonEmptyTraysData.length;
|
||||
return average.toFixed(2);
|
||||
});
|
||||
|
||||
// Computed untuk statistik tambahan
|
||||
const totalTrays = computed(() => trays.value.length);
|
||||
|
||||
const nonEmptyTrays = computed(() => {
|
||||
return trays.value.filter(tray => parseFloat(totalWeight(tray)) > 0).length;
|
||||
});
|
||||
|
||||
const emptyTrays = computed(() => {
|
||||
return trays.value.filter(tray => parseFloat(totalWeight(tray)) === 0).length;
|
||||
});
|
||||
|
||||
// Ambil data nampan + item
|
||||
const refreshData = async () => {
|
||||
try {
|
||||
const nampanRes = await axios.get("/api/nampan", {
|
||||
headers: { Authorization: `Bearer ${localStorage.getItem("token")}` },
|
||||
});
|
||||
trays.value = nampanRes.data;
|
||||
} catch (err) {
|
||||
error.value = err.message || "Gagal mengambil data";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// filter berdasarkan nama nampan
|
||||
// Filter nampan
|
||||
const filteredTrays = computed(() => {
|
||||
if (!props.search) return trays.value;
|
||||
return trays.value.filter((tray) =>
|
||||
tray.nama.toLowerCase().includes(props.search.toLowerCase())
|
||||
);
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
refreshData();
|
||||
});
|
||||
|
||||
// Expose refreshData to parent
|
||||
defineExpose({ refreshData });
|
||||
</script>
|
||||
@ -1,12 +1,11 @@
|
||||
<template>
|
||||
<div class="flex justify-end mb-4">
|
||||
<input
|
||||
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"
|
||||
@input="$emit('update:search', searchText)"
|
||||
/>
|
||||
<div class="border border-C bg-A rounded-md w-full relative items-center">
|
||||
<input v-model="searchText" type="text" placeholder="Cari ..."
|
||||
class="focus:outline-none focus:ring-2 focus:ring-blue-400 rounded-md w-full px-3 py-2 "
|
||||
@input="$emit('update:search', searchText)" />
|
||||
<div class="absolute right-3 top-1/2 -translate-y-1/2 text-C">
|
||||
<i class="fas fa-search"></i>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
|
||||
@ -1,10 +1,19 @@
|
||||
<template>
|
||||
<Header />
|
||||
<div class="mx-2 md:mx-4 lg:mx-6 xl:mx-7 my-6">
|
||||
<div class="min-h-screen flex flex-col">
|
||||
<!-- Navbar -->
|
||||
<NavigationComponent />
|
||||
|
||||
<!-- Konten utama -->
|
||||
<div class="flex-1 mx-2 md:mx-4 lg:mx-6 xl:mx-7 my-6">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<!-- Footer selalu di bawah -->
|
||||
<Footer class="w-full" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Header from '../components/Header.vue'
|
||||
import Footer from '../components/Footer.vue'
|
||||
import NavigationComponent from '../components/NavigationComponent.vue'
|
||||
</script>
|
||||