89 Commits

Author SHA1 Message Date
arifal
8e681f6455 fix + 2025-09-19 23:13:47 +07:00
arifal
88bed2a3ef fix + datatable 2025-09-19 23:07:56 +07:00
arifal
c1f8e2986b fix datatable responsive 2025-09-19 23:01:34 +07:00
arifal
e94dd1ff81 add button datatable 2025-09-19 22:45:29 +07:00
arifal
f4234ee573 fix role 2025-09-19 22:38:01 +07:00
arifal
ac1183ac5e fix access docker 2025-09-19 22:20:10 +07:00
arifal
3092ecf34b fix composer install 2025-09-19 22:12:28 +07:00
arifal
ed920e8e7b fix ignore docker 2025-09-19 22:03:03 +07:00
arifal
c01d95a61b add build and storage link 2025-09-19 21:59:22 +07:00
arifal
9f500a5da2 add build prod 2025-09-19 21:45:43 +07:00
arifal
45f79e7027 add claim tab to all users 2025-09-19 21:04:43 +07:00
arifal
db4c586535 fix form create update postcheck and precheck 2025-09-19 20:44:28 +07:00
arifal
cab0d2e9a8 add bind mount .env production 2025-07-14 18:36:49 +07:00
arifal
e2a49530b7 add env production 2025-07-14 18:25:47 +07:00
arifal
193f8c36af add env production 2025-07-14 18:20:24 +07:00
arifal
9a39cabee3 remove not used sh and md 2025-07-14 16:36:42 +07:00
arifal
f123e082f9 fix readme and preview precheck 2025-07-14 16:28:22 +07:00
arifal
833d5abbb5 add vendor to webmix for first build 2025-07-14 16:19:48 +07:00
arifal
4b9be55d32 optimize dockerfile and copy js library used 2025-07-14 16:10:52 +07:00
arifal
5b14523f84 fix remove cdn language 2025-07-14 14:55:51 +07:00
arifal
b97a5f4740 fix style transaction page 2025-07-14 14:53:55 +07:00
arifal
dff0f7ceba fix styling and try optimize dockerfile 2025-07-14 14:33:39 +07:00
arifal
96a9729a35 up file upload limit to 20mb and fix switch camera 2025-07-14 13:00:59 +07:00
arifal
a59f685d41 fix icon target and handle using back or front camera precheck and postcheck 2025-07-14 11:46:51 +07:00
arifal
68e7eb3087 fix double calculation when mechanic created and auto claim work 2025-07-14 11:06:29 +07:00
arifal
720e314bbd fix transaction not get data deleted_at 2025-07-11 17:25:46 +07:00
arifal
0b1589d173 fix share text to app and handle copy to clipboard daily report mechanic 2025-07-11 16:31:37 +07:00
arifal
e3956ae0e4 fix handle upload file on page precheck and postcheck 2025-07-11 14:55:11 +07:00
arifal
748ac8a77e fix upload using file upload storage php not base64 2025-07-11 14:21:37 +07:00
arifal
e52c4d1d27 fix filtering dealer and data with base on user login, partial update precheck and postcheck schema and view 2025-07-10 18:04:38 +07:00
arifal
cec11d6385 fix report filter data base on user login role dealer 2025-07-10 13:25:02 +07:00
arifal
b632996052 fix load data dealer base on user with pivot or not 2025-07-10 12:24:11 +07:00
arifal
e59841fd23 fix login auto detect menu link, and partial update tchnician role dealer 2025-07-09 18:32:49 +07:00
arifal
e468672bbe fix styling filter dealer report technician 2025-07-09 11:15:09 +07:00
arifal
685c6df82e partial update report technician 2025-07-08 19:44:07 +07:00
arifal
cfef3775d7 fix feature report stock product 2025-07-08 14:24:01 +07:00
arifal
956df5cfe6 create feature sa create list claim and price to work per dealer 2025-07-07 19:11:04 +07:00
arifal
fa554446ca partial update create kpi and progress bar 2025-07-04 18:27:32 +07:00
arifal
0ef03fe7cb fix opname default value, show different opname and hide system stock opname 2025-07-03 13:55:49 +07:00
arifal
9b3889ef1f partial update create nginx proxy https 2025-06-26 17:19:21 +07:00
arifal
fc98479362 fix remove filter base on user dealer id 2025-06-25 18:11:26 +07:00
arifal
38def0dc9c fix styling select2 dropdown on mutations and opnames 2025-06-25 17:23:45 +07:00
arifal
e5daafc8f0 add more seeder product and product category and fix daterangepicker 2025-06-25 16:29:34 +07:00
arifal
e96ca0a83c partial update close modal on all page and disable create transaction with no stock 2025-06-25 14:01:21 +07:00
arifal
c3233ea6b2 partial update transaction work with stock product 2025-06-24 19:42:19 +07:00
arifal
33502e905d fix shadow border on datatable 2025-06-20 15:48:53 +07:00
arifal
41ae7da60e fix icon and condition header show when children have access 2025-06-20 15:42:04 +07:00
arifal
334b9acd87 fix styling opnames and mutations same with stock audit 2025-06-20 15:29:34 +07:00
arifal
0de5bec84a fix style section filter 2025-06-20 13:16:16 +07:00
arifal
82f9d7f466 create print opname and mutations 2025-06-19 18:02:20 +07:00
arifal
e478dc81bb create export product stock dealers 2025-06-19 17:35:35 +07:00
arifal
22477b6dab add filter date and dealer on mutations and opnames 2025-06-19 16:45:41 +07:00
arifal
b803068d0e fix orderable datatable on mutations and products index 2025-06-16 19:01:11 +07:00
arifal
aa233eb793 create new menu histori stock audit 2025-06-16 17:27:59 +07:00
arifal
567e4aa5fc localize library cdn, remove approve button from transaction page, fix all fitur running datatable as well, fixing sortable datatable using new cdn, fix modal approve, add note to receiver mutations 2025-06-16 15:01:08 +07:00
arifal
9cfb566aee remove status pending and complete 2025-06-15 02:29:26 +07:00
arifal
3fb598ae4d fix permission on local using root 2025-06-13 18:29:16 +07:00
arifal
e9566d4c8a fix cdn use and nginx restrict cdn 2025-06-13 16:52:37 +07:00
arifal
4517f7efcb fix cdn library 2025-06-13 16:33:36 +07:00
arifal
ec8224760e fix handle using asset for access css 2025-06-13 16:10:03 +07:00
arifal
ac55ed1b67 fix redirect url to port 2025-06-13 15:30:19 +07:00
arifal
6625baf7bd npm run production 2025-06-13 15:17:49 +07:00
arifal
2f5eff9e63 fix handle error and add note for shippings receive approve and reject mutations 2025-06-13 14:19:12 +07:00
arifal
b2bfd666a7 fix nginx config for server demo 2025-06-13 00:13:43 +07:00
arifal
680eb2045a disable worker autorun 2025-06-12 23:43:02 +07:00
arifal
ca7a0b941e update docker demo server 2025-06-12 23:32:43 +07:00
arifal
e64cf43390 fix nginx proxy for server 2025-06-12 23:19:50 +07:00
arifal
bba37c1720 fix port already use on server 2025-06-12 22:54:55 +07:00
arifal
520c0e9885 fix mysql version for production deocker image 2025-06-12 22:37:12 +07:00
arifal
2fa60c583a fix redirect active tab after submit opname mutations and receive mutations 2025-06-12 18:19:26 +07:00
arifal
b04b8f88cb add backup file and autobackup code, partial update mutations receive on transation page 2025-06-12 18:09:13 +07:00
arifal
58578532cc fix edit products using new workflow mutations 2025-06-12 17:15:06 +07:00
arifal
1a01efb1b5 partial update create mutations workflow 2025-06-12 15:10:51 +07:00
arifal
a5e1348436 partial update create mutations 2025-06-12 00:33:59 +07:00
arifal
0b211915f1 add update nginx config to domain and create production setup docker 2025-06-11 19:02:02 +07:00
root
647aa51187 partial update stock opname feature 2025-06-11 18:29:32 +07:00
root
9b25a772a6 fix permission and trouble on mysql docker 2025-06-11 13:43:24 +07:00
arifal
f92655e3e2 add docker for local and production 2025-06-10 22:29:30 +07:00
arifal
84fb7ffb52 add status in opname datatable with order by created at desc 2025-06-10 19:12:21 +07:00
arifal
51079aa567 create stock and stock logs 2025-06-10 18:38:06 +07:00
arifal
1a2ddb59d4 update backupdb local 2025-06-05 19:07:20 +07:00
arifal
d294bb7876 partial update detail opnames page 2025-06-05 15:18:20 +07:00
arifal
ce0a4718e0 partial update create modal list dealers 2025-06-05 12:05:20 +07:00
arifal
ff498cd98f partial update opnames and detail table 2025-06-04 18:29:05 +07:00
arifal
8305e44c42 partial update products 2025-06-04 16:58:50 +07:00
arifal
215792ce63 partial update create page opnames 2025-06-03 12:56:33 +07:00
arifal
a881779c4f partial update create toggle active product and mutations 2025-06-02 18:51:04 +07:00
arifal
6bf8bc4965 fix structure product categories table and crud product 2025-06-02 16:21:33 +07:00
arifal
59e23ae535 create crud product categories and partial update crud products with dealers stock 2025-05-28 18:24:44 +07:00
1765 changed files with 66596 additions and 10148 deletions

71
.dockerignore Executable file
View File

@@ -0,0 +1,71 @@
# Dependencies
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Laravel
/vendor/
.env
.env.*
!.env.example
# Storage and logs
/storage/*.key
/storage/logs/*
/storage/framework/cache/*
/storage/framework/sessions/*
/storage/framework/views/*
/bootstrap/cache/*
# IDE and editor files
.vscode/
.idea/
*.swp
*.swo
*~
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Git
.git/
.gitignore
# Documentation
README.md
*.md
# Docker
Dockerfile*
docker-compose*
.dockerignore
# Testing
/tests/
phpunit.xml
.phpunit.result.cache
# Build tools
yarn.lock
# Temporary files
*.tmp
*.temp
# Keep vendor assets in public folder
!public/js/vendor/
!public/css/vendor/
!public/js/locales/
# Keep built assets
!public/js/app.js
!public/js/vendor.js
!public/css/app.css
!public/mix-manifest.json

0
.editorconfig Normal file → Executable file
View File

0
.env.example Normal file → Executable file
View File

0
.gitattributes vendored Normal file → Executable file
View File

0
.gitignore vendored Normal file → Executable file
View File

0
.styleci.yml Normal file → Executable file
View File

84
Dockerfile Executable file
View File

@@ -0,0 +1,84 @@
FROM php:8.1-fpm
# Set working directory
WORKDIR /var/www/html
# Install system dependencies
RUN apt-get update && apt-get install -y \
git \
curl \
libcurl4-openssl-dev \
pkg-config \
libonig-dev \
libxml2-dev \
libzip-dev \
zip \
unzip \
libfreetype6-dev \
libjpeg62-turbo-dev \
libpng-dev \
libxpm-dev \
libvpx-dev \
supervisor \
nginx \
nodejs \
npm \
&& rm -rf /var/lib/apt/lists/*
# Install PHP extensions
RUN docker-php-ext-configure gd --with-freetype --with-jpeg --with-xpm \
&& docker-php-ext-install -j$(nproc) \
curl \
pdo_mysql \
mbstring \
exif \
pcntl \
bcmath \
gd \
zip \
dom \
xml
# Install Redis extension
RUN pecl install redis \
&& docker-php-ext-enable redis
# Install Composer
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
# Copy only composer files first for caching
COPY composer.json composer.lock ./
# Install PHP dependencies (cached if lock file unchanged)
RUN composer install --optimize-autoloader --no-dev --no-interaction --no-scripts
# Now copy the full Laravel application code
COPY . .
# Run composer scripts and install Node dependencies
RUN composer run-script post-autoload-dump && \
npm install && \
npm run production && \
php artisan storage:link
# Set proper permissions (for production only do this once)
RUN mkdir -p storage/logs \
&& mkdir -p storage/framework/{cache,sessions,views} \
&& mkdir -p storage/app/public \
&& mkdir -p bootstrap/cache \
&& chown -R www-data:www-data /var/www/html \
&& chmod -R 775 storage \
&& chmod -R 775 bootstrap/cache \
&& chmod -R 755 public \
&& chmod -R 777 storage/app/public
# Nginx config
COPY ./docker/nginx.conf /etc/nginx/sites-available/default
# Supervisor config
COPY ./docker/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
# Expose web port
EXPOSE 80
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]

76
Dockerfile.dev Executable file
View File

@@ -0,0 +1,76 @@
FROM php:8.1-fpm
# Set working directory
WORKDIR /var/www/html
# Install system dependencies
RUN apt-get update && apt-get install -y \
git \
curl \
libcurl4-openssl-dev \
pkg-config \
libpng-dev \
libonig-dev \
libxml2-dev \
libzip-dev \
zip \
unzip \
libfreetype6-dev \
libjpeg62-turbo-dev \
libxpm-dev \
libvpx-dev \
supervisor \
nginx \
vim \
nano \
htop \
&& rm -rf /var/lib/apt/lists/*
# Install PHP extensions
RUN docker-php-ext-configure gd --with-freetype --with-jpeg --with-xpm \
&& docker-php-ext-install -j$(nproc) \
curl \
pdo_mysql \
mbstring \
exif \
pcntl \
bcmath \
gd \
zip \
dom \
xml
# Install Redis and Xdebug
RUN pecl install redis xdebug \
&& docker-php-ext-enable redis xdebug
# Install Composer
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
# Copy dependency files first for better caching
COPY composer.json composer.lock ./
# Now copy the entire application code (after composer install)
COPY . .
# Install PHP dependencies (with dev)
RUN composer install --no-interaction
# Set ownership and permissions
RUN mkdir -p storage/logs \
&& mkdir -p storage/framework/{cache,sessions,views} \
&& mkdir -p storage/app \
&& mkdir -p bootstrap/cache \
&& chown -R www-data:www-data /var/www/html \
&& chmod -R 775 storage bootstrap/cache \
&& chmod -R 755 public
# Copy configs
COPY ./docker/nginx.dev.conf /etc/nginx/sites-available/default
COPY ./docker/supervisord.dev.conf /etc/supervisor/conf.d/supervisord.conf
COPY ./docker/xdebug.ini /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini
# Expose web port
EXPOSE 80
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]

261
README.md Normal file → Executable file
View File

@@ -1,64 +1,237 @@
<p align="center"><a href="https://laravel.com" target="_blank"><img src="https://raw.githubusercontent.com/laravel/art/master/logo-lockup/5%20SVG/2%20CMYK/1%20Full%20Color/laravel-logolockup-cmyk-red.svg" width="400"></a></p> # CKB - Bengkel Management System
<p align="center"> Sistem manajemen bengkel yang dibangun dengan Laravel 8 dan menggunakan JavaScript inline untuk performa optimal.
<a href="https://travis-ci.org/laravel/framework"><img src="https://travis-ci.org/laravel/framework.svg" alt="Build Status"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/dt/laravel/framework" alt="Total Downloads"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/v/laravel/framework" alt="Latest Stable Version"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/l/laravel/framework" alt="License"></a>
</p>
## About Laravel ## 🚀 Overview
Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as: Aplikasi ini menggunakan pendekatan JavaScript inline untuk menghindari kebutuhan build process di production server. Semua vendor assets sudah disalin ke folder `public` dan siap untuk deployment.
- [Simple, fast routing engine](https://laravel.com/docs/routing). ## 📦 Prerequisites
- [Powerful dependency injection container](https://laravel.com/docs/container).
- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage.
- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent).
- Database agnostic [schema migrations](https://laravel.com/docs/migrations).
- [Robust background job processing](https://laravel.com/docs/queues).
- [Real-time event broadcasting](https://laravel.com/docs/broadcasting).
Laravel is accessible, powerful, and provides tools required for large, robust applications. - PHP 8.1+
- Composer
- MySQL/MariaDB
- Redis (optional)
- Docker (optional)
## Learning Laravel ## 🛠️ Installation
Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework. ### Local Development
If you don't feel like reading, [Laracasts](https://laracasts.com) can help. Laracasts contains over 1500 video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library. 1. **Clone repository**
## Laravel Sponsors ```bash
git clone <repository-url>
cd ckb
```
We would like to extend our thanks to the following sponsors for funding Laravel development. If you are interested in becoming a sponsor, please visit the Laravel [Patreon page](https://patreon.com/taylorotwell). 2. **Install PHP dependencies**
### Premium Partners ```bash
composer install
```
- **[Vehikl](https://vehikl.com/)** 3. **Copy environment file**
- **[Tighten Co.](https://tighten.co)**
- **[Kirschbaum Development Group](https://kirschbaumdevelopment.com)**
- **[64 Robots](https://64robots.com)**
- **[Cubet Techno Labs](https://cubettech.com)**
- **[Cyber-Duck](https://cyber-duck.co.uk)**
- **[Many](https://www.many.co.uk)**
- **[Webdock, Fast VPS Hosting](https://www.webdock.io/en)**
- **[DevSquad](https://devsquad.com)**
- **[Curotec](https://www.curotec.com/services/technologies/laravel/)**
- **[OP.GG](https://op.gg)**
- **[WebReinvent](https://webreinvent.com/?utm_source=laravel&utm_medium=github&utm_campaign=patreon-sponsors)**
- **[Lendio](https://lendio.com)**
## Contributing ```bash
cp .env.example .env
php artisan key:generate
```
Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions). 4. **Configure database**
## Code of Conduct ```bash
# Edit .env file with your database credentials
php artisan migrate
php artisan db:seed
```
In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct). 5. **Start development server**
```bash
php artisan serve
```
## Security Vulnerabilities ### Docker Development
If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed. ```bash
# Build development image
docker build -f Dockerfile.dev -t ckb-dev .
## License # Run container
docker run -p 8080:80 ckb-dev
```
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT). ### Docker Production
```bash
# Build production image
docker build -f Dockerfile -t ckb-prod .
# Run container
docker run -p 8080:80 ckb-prod
```
## 🐳 Docker Optimization
### ⚡ Optimizations Made
1. **Removed Node.js Dependencies**
- ❌ `nodejs` dan `npm` tidak lagi diinstall di container
- ✅ Mengurangi ukuran image sekitar 200-300MB
- ✅ Build time lebih cepat
2. **No JavaScript Compilation**
- ❌ Tidak ada `npm install` atau `npm run production`
- ✅ Vendor assets sudah ada di `public/js/vendor/` dan `public/css/vendor/`
- ✅ Library diakses langsung dari file yang sudah di-minify
3. **Optimized .dockerignore**
- ❌ Exclude `node_modules/`, `package.json`, `webpack.mix.js`
- ✅ Keep vendor assets di `public/` folder
- ✅ Mengurangi build context size
4. **Better Layer Caching**
- ✅ Copy `composer.json` terlebih dahulu
- ✅ Install PHP dependencies sebelum copy source code
- ✅ Cache layer untuk composer dependencies
### 📊 Performance Improvements
| Metric | Before | After | Improvement |
| ------------ | ------------- | -------- | ----------- |
| Image Size | ~800MB | ~500MB | -37.5% |
| Build Time | ~5-8 min | ~2-3 min | -60% |
| Dependencies | Node.js + PHP | PHP only | -50% |
## 📁 Project Structure
```
ckb/
├── app/
│ ├── Http/
│ │ ├── Controllers/
│ │ └── Requests/
│ ├── Models/
│ └── Services/
├── resources/
│ ├── views/
│ │ ├── layouts/
│ │ │ ├── frontapp.blade.php
│ │ │ └── backapp.blade.php
│ │ └── transaction/
│ └── sass/
├── public/
│ ├── js/
│ │ ├── vendor/
│ │ │ ├── jquery.dataTables.min.js
│ │ │ ├── dataTables.bootstrap4.min.js
│ │ │ ├── sweetalert2.min.js
│ │ │ ├── chart.umd.js
│ │ │ └── ...
│ │ └── bootstrap-datepicker.min.js
│ └── css/
│ ├── vendor/
│ │ ├── dataTables.bootstrap4.min.css
│ │ ├── sweetalert2.min.css
│ │ └── ...
│ └── bootstrap-datepicker.min.css
└── docker/
├── Dockerfile
├── Dockerfile.dev
└── nginx.conf
```
## 🎯 Key Features
### Frontend (Mobile App)
- **Camera Integration** - Foto precheck dan postcheck dengan kontrol penuh
- **File Upload** - Support hingga 20MB
- **Responsive Design** - Optimized untuk mobile devices
- **Real-time Updates** - WebSocket integration
### Backend (Admin Panel)
- **Transaction Management** - Manajemen transaksi bengkel
- **KPI Tracking** - Sistem KPI dengan perhitungan otomatis
- **DataTables** - Tabel data dengan fitur advanced
- **Chart.js** - Visualisasi data dan laporan
- **SweetAlert2** - Notifikasi yang user-friendly
### Warehouse Management
- **Stock Audit** - Audit stok dengan filter advanced
- **Mutations** - Mutasi antar dealer
- **Opnames** - Penghitungan stok
- **Product Management** - Manajemen produk dan kategori
## 🔧 Technology Stack
### Backend
- **Laravel 8** - PHP Framework
- **MySQL/MariaDB** - Database
- **Redis** - Cache & Session
- **PHP 8.1** - Runtime
### Frontend
- **Bootstrap 4** - CSS Framework
- **jQuery** - JavaScript Library
- **DataTables** - Table Enhancement
- **Chart.js** - Chart Library
- **SweetAlert2** - Alert Library
- **Bootstrap Datepicker** - Date Picker
### DevOps
- **Docker** - Containerization
- **Nginx** - Web Server
- **Supervisor** - Process Management
## 🚨 Important Notes
1. **Vendor assets sudah ada di folder `public/`** dan akan di-push ke git
2. **Tidak perlu npm install di production server**
3. **Semua JavaScript sudah inline** di Blade templates
4. **CSS masih perlu dikompilasi** jika ada perubahan di `resources/sass/`
## 🔧 Troubleshooting
### Docker Build Issues
```bash
# Gunakan Docker BuildKit untuk build lebih cepat
export DOCKER_BUILDKIT=1
docker build -f Dockerfile -t ckb-prod .
```
### Vendor Assets Missing
Pastikan folder `public/js/vendor/` dan `public/css/vendor/` sudah ada dan berisi file-file yang diperlukan.
### Database Issues
```bash
# Clear cache
php artisan cache:clear
php artisan config:clear
# Recreate database
php artisan migrate:fresh --seed
```
## 📝 License
This project is licensed under the MIT License.
## 🤝 Contributing
1. Fork the repository
2. Create your feature branch (`git checkout -b feature/amazing-feature`)
3. Commit your changes (`git commit -m 'Add some amazing feature'`)
4. Push to the branch (`git push origin feature/amazing-feature`)
5. Open a Pull Request

0
api_bengkel2/git.zip Normal file → Executable file
View File

View File

@@ -0,0 +1,97 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class CleanMutationsData extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'mutations:clean {--force : Force cleanup without confirmation}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Clean mutations data to allow migration rollback';
/**
* Create a new command instance.
*
* @return void
*/
public function __construct()
{
parent::__construct();
}
/**
* Execute the console command.
*/
public function handle()
{
if (!$this->option('force')) {
if (!$this->confirm('This will delete ALL mutations data. Are you sure?')) {
$this->info('Operation cancelled.');
return 0;
}
}
try {
DB::beginTransaction();
// Delete mutations data in proper order (foreign key constraints)
$this->info('Cleaning mutations data...');
// 1. Delete stock logs related to mutations
if (Schema::hasTable('stock_logs')) {
$deleted = DB::table('stock_logs')
->where('source_type', 'App\\Models\\Mutation')
->delete();
$this->info("Deleted {$deleted} stock logs related to mutations");
}
// 2. Delete mutation details
if (Schema::hasTable('mutation_details')) {
$deleted = DB::table('mutation_details')->delete();
$this->info("Deleted {$deleted} mutation details");
}
// 3. Delete mutations
if (Schema::hasTable('mutations')) {
$deleted = DB::table('mutations')->delete();
$this->info("Deleted {$deleted} mutations");
}
// 4. Reset auto increment
if (Schema::hasTable('mutations')) {
DB::statement('ALTER TABLE mutations AUTO_INCREMENT = 1');
$this->info('Reset mutations auto increment');
}
if (Schema::hasTable('mutation_details')) {
DB::statement('ALTER TABLE mutation_details AUTO_INCREMENT = 1');
$this->info('Reset mutation_details auto increment');
}
DB::commit();
$this->info('✅ Mutations data cleaned successfully!');
$this->info('You can now rollback and re-run migrations.');
return 0;
} catch (\Exception $e) {
DB::rollback();
$this->error('❌ Error cleaning mutations data: ' . $e->getMessage());
return 1;
}
}
}

View File

@@ -0,0 +1,112 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use App\Models\Mutation;
use App\Models\MutationDetail;
class ClearMutationsCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'mutations:clear {--force : Force the operation without confirmation}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Clear all mutations and mutation details, then reset auto increment IDs';
/**
* Create a new command instance.
*
* @return void
*/
public function __construct()
{
parent::__construct();
}
/**
* Execute the console command.
*/
public function handle()
{
// Show warning
$this->warn('⚠️ WARNING: This will permanently delete ALL mutations and mutation details!');
$this->warn('⚠️ This action cannot be undone!');
// Check for force flag
if (!$this->option('force')) {
if (!$this->confirm('Are you sure you want to continue?')) {
$this->info('Operation cancelled.');
return 0;
}
}
// Show current counts
$mutationCount = Mutation::count();
$detailCount = MutationDetail::count();
$trashedMutationCount = Mutation::onlyTrashed()->count();
$this->info("Current data:");
$this->info("- Mutations: {$mutationCount}");
$this->info("- Mutation Details: {$detailCount}");
if ($trashedMutationCount > 0) {
$this->info("- Soft Deleted Mutations: {$trashedMutationCount}");
}
if ($mutationCount === 0 && $detailCount === 0 && $trashedMutationCount === 0) {
$this->info('No data to clear.');
return 0;
}
$this->info('Starting cleanup process...');
try {
// Delete data within transaction
DB::beginTransaction();
// Delete mutation details first (foreign key constraint)
$this->info('🗑️ Deleting mutation details...');
MutationDetail::query()->delete();
// Delete mutations (including soft deleted ones)
$this->info('🗑️ Deleting mutations...');
Mutation::query()->delete();
// Force delete soft deleted mutations
$this->info('🗑️ Force deleting soft deleted mutations...');
Mutation::onlyTrashed()->forceDelete();
DB::commit();
// Reset auto increment outside transaction (DDL operations auto-commit)
$this->info('🔄 Resetting mutation_details auto increment...');
DB::statement('ALTER TABLE mutation_details AUTO_INCREMENT = 1');
$this->info('🔄 Resetting mutations auto increment...');
DB::statement('ALTER TABLE mutations AUTO_INCREMENT = 1');
$this->info('✅ Successfully cleared all mutations and reset auto increment!');
$this->info('📊 Final counts:');
$this->info('- Mutations: ' . Mutation::count());
$this->info('- Mutation Details: ' . MutationDetail::count());
return 0;
} catch (\Exception $e) {
if (DB::transactionLevel() > 0) {
DB::rollback();
}
$this->error('❌ Failed to clear mutations: ' . $e->getMessage());
return 1;
}
}
}

View File

@@ -0,0 +1,93 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema;
use App\Models\Opname;
use App\Models\OpnameDetail;
use App\Models\Stock;
use App\Models\StockLog;
class ClearOpnameData extends Command
{
protected $signature = 'opname:clear {--force : Force clear without confirmation}';
protected $description = 'Clear all opname-related data including opnames, details, stocks, logs, and reset all IDs to 1';
public function handle()
{
if (!$this->option('force')) {
if (!$this->confirm('This will delete ALL opname data, stocks, stock logs, and reset ALL IDs to 1. This is irreversible! Are you sure?')) {
$this->info('Operation cancelled.');
return;
}
}
$this->info('Starting complete data cleanup...');
try {
// Disable foreign key checks
DB::statement('SET FOREIGN_KEY_CHECKS=0;');
// 1. Clear and reset stock logs
if (Schema::hasTable('stock_logs')) {
DB::table('stock_logs')->truncate();
DB::statement('ALTER TABLE stock_logs AUTO_INCREMENT = 1;');
$this->info('✓ Cleared and reset stock_logs table');
}
// 2. Clear and reset stocks
if (Schema::hasTable('stocks')) {
DB::table('stocks')->truncate();
DB::statement('ALTER TABLE stocks AUTO_INCREMENT = 1;');
$this->info('✓ Cleared and reset stocks table');
}
// 3. Clear and reset opname details
if (Schema::hasTable('opname_details')) {
DB::table('opname_details')->truncate();
DB::statement('ALTER TABLE opname_details AUTO_INCREMENT = 1;');
$this->info('✓ Cleared and reset opname_details table');
}
// 4. Clear and reset opnames
if (Schema::hasTable('opnames')) {
DB::table('opnames')->truncate();
DB::statement('ALTER TABLE opnames AUTO_INCREMENT = 1;');
$this->info('✓ Cleared and reset opnames table');
}
// Re-enable foreign key checks
DB::statement('SET FOREIGN_KEY_CHECKS=1;');
$this->info('Successfully cleared all data and reset IDs to 1!');
$this->info('Cleared tables:');
$this->info('- stock_logs');
$this->info('- stocks');
$this->info('- opname_details');
$this->info('- opnames');
Log::info('Complete data cleared and IDs reset by command', [
'user' => auth()->user() ? auth()->user()->id : 'system',
'timestamp' => now(),
'tables_cleared' => ['stock_logs', 'stocks', 'opname_details', 'opnames']
]);
} catch (\Exception $e) {
// Re-enable foreign key checks if they were disabled
DB::statement('SET FOREIGN_KEY_CHECKS=1;');
$this->error('Error clearing data: ' . $e->getMessage());
Log::error('Error in ClearOpnameData command: ' . $e->getMessage(), [
'exception' => $e,
'trace' => $e->getTraceAsString()
]);
return 1; // Return error code
}
return 0; // Return success code
}
}

View File

@@ -0,0 +1,83 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\Models\Menu;
use App\Models\Role;
use App\Models\Privilege;
class SetupStockAuditMenu extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'setup:stock-audit-menu';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Setup Stock Audit menu and privileges';
/**
* Execute the console command.
*
* @return int
*/
public function handle()
{
$this->info('Setting up Stock Audit menu...');
// Check if menu already exists
$existingMenu = Menu::where('link', 'stock-audit.index')->first();
if ($existingMenu) {
$this->warn('Stock Audit menu already exists!');
return 0;
}
// Create Stock Audit menu
$menu = Menu::create([
'name' => 'Audit Histori Stock',
'link' => 'stock-audit.index',
'created_at' => now(),
'updated_at' => now()
]);
$this->info('Stock Audit menu created with ID: ' . $menu->id);
// Give all roles access to this menu
$roles = Role::all();
$privilegeCount = 0;
foreach($roles as $role) {
// Check if privilege already exists
$existingPrivilege = Privilege::where('role_id', $role->id)
->where('menu_id', $menu->id)
->first();
if (!$existingPrivilege) {
Privilege::create([
'role_id' => $role->id,
'menu_id' => $menu->id,
'create' => 0, // Stock audit is view-only
'update' => 0, // Stock audit is view-only
'delete' => 0, // Stock audit is view-only
'view' => 1, // Allow viewing
'created_at' => now(),
'updated_at' => now()
]);
$privilegeCount++;
}
}
$this->info("Created {$privilegeCount} privileges for Stock Audit menu.");
$this->info('Stock Audit menu setup completed successfully!');
return 0;
}
}

4
app/Console/Kernel.php Normal file → Executable file
View File

@@ -28,5 +28,9 @@ class Kernel extends ConsoleKernel
$this->load(__DIR__.'/Commands'); $this->load(__DIR__.'/Commands');
require base_path('routes/console.php'); require base_path('routes/console.php');
$this->commands = [
Commands\ClearOpnameData::class,
];
} }
} }

59
app/Enums/MutationStatus.php Executable file
View File

@@ -0,0 +1,59 @@
<?php
namespace App\Enums;
enum MutationStatus: string
{
case SENT = 'sent';
case RECEIVED = 'received';
case APPROVED = 'approved';
case REJECTED = 'rejected';
case CANCELLED = 'cancelled';
public function label(): string
{
return match($this) {
self::SENT => 'Terkirim ke Dealer',
self::RECEIVED => 'Diterima Dealer',
self::APPROVED => 'Disetujui & Stock Dipindahkan',
self::REJECTED => 'Ditolak',
self::CANCELLED => 'Dibatalkan',
};
}
public function color(): string
{
return match($this) {
self::SENT => 'primary',
self::RECEIVED => 'info',
self::APPROVED => 'brand',
self::REJECTED => 'danger',
self::CANCELLED => 'secondary',
};
}
public function textColorClass(): string
{
return match($this->color()) {
'success' => 'text-success',
'warning' => 'text-warning',
'danger' => 'text-danger',
'info' => 'text-info',
'primary' => 'text-primary',
'brand' => 'text-primary',
'secondary' => 'text-muted',
default => 'text-dark'
};
}
public static function getOptions(): array
{
return [
self::SENT->value => self::SENT->label(),
self::RECEIVED->value => self::RECEIVED->label(),
self::APPROVED->value => self::APPROVED->label(),
self::REJECTED->value => self::REJECTED->label(),
self::CANCELLED->value => self::CANCELLED->label(),
];
}
}

55
app/Enums/OpnameStatus.php Executable file
View File

@@ -0,0 +1,55 @@
<?php
namespace App\Enums;
enum OpnameStatus: string
{
case DRAFT = 'draft';
case PENDING = 'pending';
case APPROVED = 'approved';
case REJECTED = 'rejected';
public function label(): string
{
return match($this) {
self::DRAFT => 'Draft',
self::PENDING => 'Menunggu Persetujuan',
self::APPROVED => 'Disetujui',
self::REJECTED => 'Ditolak',
};
}
public function color(): string
{
return match($this) {
self::DRAFT => 'warning',
self::PENDING => 'info',
self::APPROVED => 'success',
self::REJECTED => 'danger',
};
}
public function textColorClass(): string
{
return match($this->color()) {
'success' => 'text-success',
'warning' => 'text-warning',
'danger' => 'text-danger',
'info' => 'text-info',
'primary' => 'text-primary',
'brand' => 'text-primary',
'secondary' => 'text-muted',
default => 'text-dark'
};
}
public static function getOptions(): array
{
return [
self::DRAFT->value => self::DRAFT->label(),
self::PENDING->value => self::PENDING->label(),
self::APPROVED->value => self::APPROVED->label(),
self::REJECTED->value => self::REJECTED->label(),
];
}
}

21
app/Enums/StockChangeType.php Executable file
View File

@@ -0,0 +1,21 @@
<?php
namespace App\Enums;
enum StockChangeType: string
{
case INCREASE = 'increase';
case DECREASE = 'decrease';
case ADJUSTMENT = 'adjustment'; // Untuk kasus dimana quantity sama tapi perlu dicatat
case NO_CHANGE = 'no_change'; // Untuk kasus dimana quantity sama dan tidak perlu dicatat
public function label(): string
{
return match($this) {
self::INCREASE => 'Penambahan',
self::DECREASE => 'Pengurangan',
self::ADJUSTMENT => 'Penyesuaian',
self::NO_CHANGE => 'Tidak Ada Perubahan'
};
}
}

0
app/Exceptions/Handler.php Normal file → Executable file
View File

View File

@@ -0,0 +1,201 @@
<?php
namespace App\Exports;
use App\Models\Dealer;
use App\Models\Product;
use Maatwebsite\Excel\Concerns\FromCollection;
use Maatwebsite\Excel\Concerns\WithMultipleSheets;
use Maatwebsite\Excel\Concerns\WithTitle;
use Maatwebsite\Excel\Concerns\WithHeadings;
use Maatwebsite\Excel\Concerns\WithStyles;
use Maatwebsite\Excel\Concerns\WithColumnWidths;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
use PhpOffice\PhpSpreadsheet\Style\Alignment;
use PhpOffice\PhpSpreadsheet\Style\Border;
use PhpOffice\PhpSpreadsheet\Style\Fill;
class ProductStockDealers implements WithMultipleSheets
{
public function sheets(): array
{
$sheets = [];
$usedNames = [];
// Get all dealers with their stock data
$dealers = Dealer::with(['stocks.product.category'])->get();
/** @var Dealer $dealer */
foreach ($dealers as $dealer) {
$dealerSheet = new DealerStockSheet($dealer);
$sheetTitle = $dealerSheet->title();
// Handle duplicate sheet names
$originalTitle = $sheetTitle;
$counter = 1;
while (in_array($sheetTitle, $usedNames)) {
$sheetTitle = substr($originalTitle, 0, 28) . '_' . $counter;
$counter++;
}
$usedNames[] = $sheetTitle;
// Set the unique title
$dealerSheet->setUniqueTitle($sheetTitle);
$sheets[] = $dealerSheet;
}
return $sheets;
}
}
class DealerStockSheet implements FromCollection, WithTitle, WithHeadings, WithStyles, WithColumnWidths
{
protected $dealer;
protected $uniqueTitle;
public function __construct(Dealer $dealer)
{
$this->dealer = $dealer;
}
public function collection()
{
// Get all products with stock for this dealer
$stocks = $this->dealer->stocks()
->with(['product.category'])
->whereHas('product', function($query) {
$query->where('active', true);
})
->get();
$data = collect();
$no = 1;
foreach ($stocks as $stock) {
$product = $stock->product;
$data->push([
'no' => $no++,
'kode_produk' => $product->code,
'nama_produk' => $product->name,
'kategori' => $product->category ? $product->category->name : '-',
'satuan' => $product->unit ?? '-',
'stok' => number_format($stock->quantity, 2)
]);
}
// If no stock, add empty row
if ($data->isEmpty()) {
$data->push([
'no' => '-',
'kode_produk' => '-',
'nama_produk' => 'Tidak ada stok produk',
'kategori' => '-',
'satuan' => '-',
'stok' => '0'
]);
}
return $data;
}
public function setUniqueTitle(string $title): void
{
$this->uniqueTitle = $title;
}
public function title(): string
{
if (isset($this->uniqueTitle)) {
return $this->uniqueTitle;
}
// Clean dealer name for sheet title (remove invalid characters and handle edge cases)
$cleanName = $this->dealer->name;
// Remove parentheses and their contents
$cleanName = preg_replace('/\([^)]*\)/', '', $cleanName);
// Remove dots, commas, and other special characters
$cleanName = preg_replace('/[^A-Za-z0-9\-_ ]/', '', $cleanName);
// Clean up multiple spaces and trim
$cleanName = preg_replace('/\s+/', ' ', trim($cleanName));
// If name is empty after cleaning, use dealer ID
if (empty($cleanName)) {
$cleanName = 'Dealer_' . $this->dealer->id;
}
// Limit to 31 characters and ensure no leading/trailing spaces
$cleanName = trim(substr($cleanName, 0, 31));
// Ensure it doesn't end with a space (which can cause Excel issues)
return rtrim($cleanName);
}
public function headings(): array
{
return [
'No',
'Kode Produk',
'Nama Produk',
'Kategori',
'Satuan',
'Stok'
];
}
public function styles(Worksheet $sheet)
{
// Add dealer info at the top first
$sheet->insertNewRowBefore(1, 2);
$sheet->setCellValue('A1', 'STOK PRODUK DEALER: ' . strtoupper($this->dealer->name));
$sheet->setCellValue('A2', 'Tanggal Export: ' . now()->format('d/m/Y H:i:s'));
// Merge cells for dealer info
$sheet->mergeCells('A1:F1');
$sheet->mergeCells('A2:F2');
$lastRow = $sheet->getHighestRow();
// Style dealer info
$sheet->getStyle('A1:A2')->applyFromArray([
'font' => ['bold' => true, 'size' => 12],
'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER]
]);
// Style headers (row 3 after inserting 2 rows)
$sheet->getStyle('A3:F3')->applyFromArray([
'font' => ['bold' => true, 'color' => ['rgb' => 'FFFFFF']],
'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => '4472C4']],
'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER],
'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN]]
]);
// Style data rows if they exist
if ($lastRow > 3) {
$sheet->getStyle('A4:F' . $lastRow)->applyFromArray([
'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN, 'color' => ['rgb' => 'CCCCCC']]],
'alignment' => ['vertical' => Alignment::VERTICAL_CENTER]
]);
// Center align specific columns
$sheet->getStyle('A4:A' . $lastRow)->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
$sheet->getStyle('E4:F' . $lastRow)->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
}
return $sheet;
}
public function columnWidths(): array
{
return [
'A' => 8, // No
'B' => 15, // Kode Produk
'C' => 30, // Nama Produk
'D' => 20, // Kategori
'E' => 12, // Satuan
'F' => 12 // Stok
];
}
}

View File

@@ -0,0 +1,316 @@
<?php
namespace App\Exports;
use Maatwebsite\Excel\Concerns\FromCollection;
use Maatwebsite\Excel\Concerns\WithHeadings;
use Maatwebsite\Excel\Concerns\WithStyles;
use Maatwebsite\Excel\Concerns\WithColumnWidths;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
use PhpOffice\PhpSpreadsheet\Style\Alignment;
use PhpOffice\PhpSpreadsheet\Style\Border;
use PhpOffice\PhpSpreadsheet\Style\Fill;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Collection;
class StockProductsExport implements FromCollection, WithHeadings, WithStyles, WithColumnWidths
{
protected $reportData;
public function __construct($reportData)
{
// Validate and sanitize report data
if (!is_array($reportData)) {
throw new \InvalidArgumentException('Report data must be an array');
}
if (!isset($reportData['data']) || !isset($reportData['dealers'])) {
throw new \InvalidArgumentException('Report data must contain "data" and "dealers" keys');
}
// Ensure dealers is a collection
if (!($reportData['dealers'] instanceof Collection)) {
$reportData['dealers'] = collect($reportData['dealers']);
}
// Ensure data is an array
if (!is_array($reportData['data'])) {
$reportData['data'] = [];
}
$this->reportData = $reportData;
// Debug: Log the structure of report data
Log::info('StockProductsExport constructor', [
'has_data' => isset($reportData['data']),
'has_dealers' => isset($reportData['dealers']),
'data_count' => isset($reportData['data']) ? count($reportData['data']) : 0,
'dealers_count' => isset($reportData['dealers']) ? count($reportData['dealers']) : 0,
'dealers' => isset($reportData['dealers']) ? $reportData['dealers']->pluck('name')->toArray() : []
]);
}
public function collection()
{
try {
$data = collect();
$no = 1;
foreach ($this->reportData['data'] as $row) {
$exportRow = [
'no' => $no++,
'kode_produk' => $row['product_code'] ?? '',
'nama_produk' => $row['product_name'] ?? '',
'kategori' => $row['category_name'] ?? '',
'satuan' => $row['unit'] ?? ''
];
// Add dealer columns
foreach ($this->reportData['dealers'] as $dealer) {
try {
$dealerKey = "dealer_{$dealer->id}";
// Clean dealer name for array key to avoid special characters
$cleanDealerName = $this->cleanDealerName($dealer->name);
$exportRow[$cleanDealerName] = $row[$dealerKey] ?? 0;
Log::info('Processing dealer column', [
'original_name' => $dealer->name,
'clean_name' => $cleanDealerName,
'dealer_key' => $dealerKey,
'value' => $row[$dealerKey] ?? 0
]);
} catch (\Exception $e) {
Log::error('Error processing dealer column', [
'dealer_id' => $dealer->id,
'dealer_name' => $dealer->name,
'error' => $e->getMessage()
]);
// Use a safe fallback name
$exportRow['Dealer_' . $dealer->id] = $row["dealer_{$dealer->id}"] ?? 0;
}
}
// Add total stock
$exportRow['total_stok'] = $row['total_stock'] ?? 0;
$data->push($exportRow);
}
return $data;
} catch (\Exception $e) {
Log::error('Error in collection method', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString()
]);
// Return empty collection as fallback
return collect();
}
}
public function headings(): array
{
try {
$headings = [
'No',
'Kode Produk',
'Nama Produk',
'Kategori',
'Satuan'
];
// Add dealer headings
foreach ($this->reportData['dealers'] as $dealer) {
try {
$cleanName = $this->cleanDealerName($dealer->name);
$headings[] = $cleanName;
Log::info('Processing dealer heading', [
'original_name' => $dealer->name,
'clean_name' => $cleanName
]);
} catch (\Exception $e) {
Log::error('Error processing dealer heading', [
'dealer_id' => $dealer->id,
'dealer_name' => $dealer->name,
'error' => $e->getMessage()
]);
// Use a safe fallback name
$headings[] = 'Dealer_' . $dealer->id;
}
}
// Add total heading
$headings[] = 'Total Stok';
return $headings;
} catch (\Exception $e) {
Log::error('Error in headings method', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString()
]);
// Return basic headings as fallback
return ['No', 'Kode Produk', 'Nama Produk', 'Kategori', 'Satuan', 'Total Stok'];
}
}
public function styles(Worksheet $sheet)
{
try {
$lastColumn = $sheet->getHighestColumn();
$lastRow = $sheet->getHighestRow();
// Validate column and row values
if (!$lastColumn || !$lastRow || $lastRow < 1) {
Log::warning('Invalid sheet dimensions', ['lastColumn' => $lastColumn, 'lastRow' => $lastRow]);
return $sheet;
}
// Style header row
$sheet->getStyle('A1:' . $lastColumn . '1')->applyFromArray([
'font' => [
'bold' => true,
'color' => ['rgb' => 'FFFFFF'],
],
'fill' => [
'fillType' => Fill::FILL_SOLID,
'startColor' => ['rgb' => '4472C4'],
],
'alignment' => [
'horizontal' => Alignment::HORIZONTAL_CENTER,
'vertical' => Alignment::VERTICAL_CENTER,
],
]);
// Style all cells
$sheet->getStyle('A1:' . $lastColumn . $lastRow)->applyFromArray([
'borders' => [
'allBorders' => [
'borderStyle' => Border::BORDER_THIN,
'color' => ['rgb' => '000000'],
],
],
'alignment' => [
'vertical' => Alignment::VERTICAL_CENTER,
],
]);
// Center align specific columns
$sheet->getStyle('A:A')->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
$sheet->getStyle('D:D')->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
// Right align numeric columns (dealer columns and total)
$dealerStartCol = 'E';
$dealerCount = count($this->reportData['dealers']);
if ($dealerCount > 0) {
$dealerEndCol = chr(ord('E') + $dealerCount - 1);
$totalCol = chr(ord($dealerStartCol) + $dealerCount);
// Validate column letters
if (ord($dealerEndCol) <= ord('Z') && ord($totalCol) <= ord('Z')) {
$sheet->getStyle($dealerStartCol . ':' . $dealerEndCol)->getAlignment()->setHorizontal(Alignment::HORIZONTAL_RIGHT);
$sheet->getStyle($totalCol . ':' . $totalCol)->getAlignment()->setHorizontal(Alignment::HORIZONTAL_RIGHT);
// Bold total column
$sheet->getStyle($totalCol . '1:' . $totalCol . $lastRow)->getFont()->setBold(true);
}
}
// Auto-size columns safely
foreach (range('A', $lastColumn) as $column) {
try {
$sheet->getColumnDimension($column)->setAutoSize(true);
} catch (\Exception $e) {
Log::warning('Failed to auto-size column', ['column' => $column, 'error' => $e->getMessage()]);
}
}
return $sheet;
} catch (\Exception $e) {
Log::error('Error in styles method', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString()
]);
return $sheet;
}
}
public function columnWidths(): array
{
try {
$widths = [
'A' => 8, // No
'B' => 15, // Kode Produk
'C' => 30, // Nama Produk
'D' => 15, // Kategori
'E' => 10, // Satuan
];
// Add dealer column widths safely
$currentCol = 'F';
$dealerCount = count($this->reportData['dealers']);
for ($i = 0; $i < $dealerCount; $i++) {
// Validate column letter
if (ord($currentCol) <= ord('Z')) {
$widths[$currentCol] = 15;
$currentCol = chr(ord($currentCol) + 1);
} else {
Log::warning('Too many dealer columns, stopping at column Z');
break;
}
}
// Add total column width if we haven't exceeded Z
if (ord($currentCol) <= ord('Z')) {
$widths[$currentCol] = 15;
}
return $widths;
} catch (\Exception $e) {
Log::error('Error in columnWidths method', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString()
]);
// Return basic widths as fallback
return [
'A' => 8, // No
'B' => 15, // Kode Produk
'C' => 30, // Nama Produk
'D' => 15, // Kategori
'E' => 10, // Satuan
'F' => 15 // Total Stok
];
}
}
/**
* Clean dealer name to make it safe for array keys and Excel headers
*/
private function cleanDealerName($dealerName)
{
// Remove or replace special characters that can cause issues with Excel
$cleanName = preg_replace('/[^a-zA-Z0-9\s\-_]/', '', $dealerName);
$cleanName = trim($cleanName);
// If name becomes empty, use a default
if (empty($cleanName)) {
$cleanName = 'Dealer';
}
// Limit length to avoid Excel issues
if (strlen($cleanName) > 31) {
$cleanName = substr($cleanName, 0, 31);
}
return $cleanName;
}
}

View File

@@ -0,0 +1,435 @@
<?php
namespace App\Exports;
use App\Services\TechnicianReportService;
use Maatwebsite\Excel\Concerns\FromCollection;
use Maatwebsite\Excel\Concerns\WithHeadings;
use Maatwebsite\Excel\Concerns\WithStyles;
use Maatwebsite\Excel\Concerns\WithColumnWidths;
use Maatwebsite\Excel\Concerns\WithEvents;
use Maatwebsite\Excel\Events\AfterSheet;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
use PhpOffice\PhpSpreadsheet\Style\Alignment;
use PhpOffice\PhpSpreadsheet\Style\Fill;
use PhpOffice\PhpSpreadsheet\Style\Border;
use Illuminate\Support\Facades\Log;
use Carbon\Carbon;
class TechnicianReportExport implements FromCollection, WithHeadings, WithStyles, WithColumnWidths, WithEvents
{
protected $dealerId;
protected $startDate;
protected $endDate;
protected $technicianReportService;
protected $mechanics;
protected $headings;
protected $filterInfo;
public function __construct($dealerId = null, $startDate = null, $endDate = null)
{
$this->dealerId = $dealerId;
$this->startDate = $startDate;
$this->endDate = $endDate;
$this->technicianReportService = new TechnicianReportService();
// Get mechanics and prepare headings
$this->prepareHeadings();
$this->prepareFilterInfo();
}
private function prepareHeadings()
{
try {
$reportData = $this->technicianReportService->getTechnicianReportData(
$this->dealerId,
$this->startDate,
$this->endDate
);
$this->mechanics = $reportData['mechanics'];
// Build headings - simplified structure
$this->headings = [
'No',
'Nama Pekerjaan',
'Kode Pekerjaan',
'Kategori'
];
// Add mechanic columns (only total, no completed/pending)
foreach ($this->mechanics as $mechanic) {
$mechanicName = $this->cleanName($mechanic->name);
$this->headings[] = $mechanicName;
}
// Add total column at the end
$this->headings[] = 'Total';
} catch (\Exception $e) {
Log::error('Error preparing headings: ' . $e->getMessage());
$this->headings = ['Error preparing data'];
$this->mechanics = collect();
}
}
private function prepareFilterInfo()
{
$this->filterInfo = [];
// Dealer filter
if ($this->dealerId) {
$dealer = \App\Models\Dealer::find($this->dealerId);
$dealerName = $dealer ? $dealer->name : 'Unknown Dealer';
$this->filterInfo[] = "Dealer: {$dealerName}";
} else {
// Check user access for "Semua Dealer"
$user = auth()->user();
if ($user && $user->role_id) {
$role = \App\Models\Role::with('dealers')->find($user->role_id);
if ($role) {
$technicianReportService = new \App\Services\TechnicianReportService();
if ($technicianReportService->isAdminRole($role)) {
$this->filterInfo[] = "Dealer: Semua Dealer (Admin)";
} else if ($role->dealers->count() > 0) {
$dealerNames = $role->dealers->pluck('name')->implode(', ');
$this->filterInfo[] = "Dealer: Semua Dealer (Pivot: {$dealerNames})";
} else {
$this->filterInfo[] = "Dealer: Semua Dealer";
}
} else {
$this->filterInfo[] = "Dealer: Semua Dealer";
}
} else {
$this->filterInfo[] = "Dealer: Semua Dealer";
}
}
// Date range filter
if ($this->startDate && $this->endDate) {
$startDateFormatted = Carbon::parse($this->startDate)->format('d/m/Y');
$endDateFormatted = Carbon::parse($this->endDate)->format('d/m/Y');
$this->filterInfo[] = "Periode: {$startDateFormatted} - {$endDateFormatted}";
} elseif ($this->startDate) {
$startDateFormatted = Carbon::parse($this->startDate)->format('d/m/Y');
$this->filterInfo[] = "Tanggal Mulai: {$startDateFormatted}";
} elseif ($this->endDate) {
$endDateFormatted = Carbon::parse($this->endDate)->format('d/m/Y');
$this->filterInfo[] = "Tanggal Akhir: {$endDateFormatted}";
} else {
$this->filterInfo[] = "Periode: Semua Periode";
}
// Export date
$exportDate = Carbon::now()->format('d/m/Y H:i:s');
$this->filterInfo[] = "Tanggal Export: {$exportDate}";
}
/**
* Clean name for Excel compatibility
*/
private function cleanName($name)
{
// Remove special characters and limit length
$cleaned = preg_replace('/[^a-zA-Z0-9\s]/', '', $name);
$cleaned = trim($cleaned);
// Limit to 31 characters (Excel sheet name limit)
if (strlen($cleaned) > 31) {
$cleaned = substr($cleaned, 0, 31);
}
return $cleaned ?: 'Unknown';
}
public function collection()
{
try {
$reportData = $this->technicianReportService->getTechnicianReportData(
$this->dealerId,
$this->startDate,
$this->endDate
);
$data = [];
$no = 1;
$columnTotals = [];
foreach ($this->mechanics as $mechanic) {
$columnTotals["mechanic_{$mechanic->id}_total"] = 0;
}
$columnTotals['row_total'] = 0;
foreach ($reportData['data'] as $row) {
$rowTotal = 0;
$exportRow = [
$no++,
$row['work_name'],
$row['work_code'],
$row['category_name']
];
foreach ($this->mechanics as $mechanic) {
$mechanicTotal = $row["mechanic_{$mechanic->id}_total"] ?? 0;
$exportRow[] = $mechanicTotal;
$rowTotal += $mechanicTotal;
$columnTotals["mechanic_{$mechanic->id}_total"] += $mechanicTotal;
}
$exportRow[] = $rowTotal;
$columnTotals['row_total'] += $rowTotal;
$data[] = $exportRow;
}
// Add total row
$totalRow = ['', 'TOTAL', '', ''];
foreach ($this->mechanics as $mechanic) {
$totalRow[] = $columnTotals["mechanic_{$mechanic->id}_total"];
}
$totalRow[] = $columnTotals['row_total'];
$data[] = $totalRow;
return collect($data);
} catch (\Exception $e) {
Log::error('Error in collection: ' . $e->getMessage());
return collect([['Error loading data']]);
}
}
public function headings(): array
{
return $this->headings;
}
public function styles(Worksheet $sheet)
{
try {
$lastColumn = $sheet->getHighestColumn();
$lastRow = $sheet->getHighestRow();
// Calculate positions
$titleRow = 1;
$headerRow = 1; // Headers are now in row 2
$dataStartRow = 2; // Data starts in row 3
// Calculate total row position (after data)
$dataRows = count($this->technicianReportService->getTechnicianReportData($this->dealerId, $this->startDate, $this->endDate)['data']);
$totalRow = $dataStartRow + $dataRows;
$filterStartRow = $totalRow + 2; // After total row + empty row
// Style the title row (row 1)
$sheet->getStyle('A' . $titleRow . ':' . $lastColumn . $titleRow)->applyFromArray([
'font' => [
'bold' => true,
'size' => 16,
],
'alignment' => [
'horizontal' => Alignment::HORIZONTAL_CENTER,
'vertical' => Alignment::VERTICAL_CENTER,
],
]);
// Header styling (row 2)
$sheet->getStyle('A' . $headerRow . ':' . $lastColumn . $headerRow)->applyFromArray([
'font' => [
'bold' => true,
'color' => ['rgb' => 'FFFFFF'],
'size' => 10,
],
'fill' => [
'fillType' => Fill::FILL_SOLID,
'startColor' => ['rgb' => '2E5BBA'],
],
'alignment' => [
'horizontal' => Alignment::HORIZONTAL_CENTER,
'vertical' => Alignment::VERTICAL_CENTER,
],
'borders' => [
'allBorders' => [
'borderStyle' => Border::BORDER_THIN,
'color' => ['rgb' => '000000'],
],
],
]);
// Data styling (starting from row 3)
if ($lastRow > $headerRow) {
$dataEndRow = $totalRow;
$sheet->getStyle('A' . $dataStartRow . ':' . $lastColumn . $dataEndRow)->applyFromArray([
'borders' => [
'allBorders' => [
'borderStyle' => Border::BORDER_THIN,
'color' => ['rgb' => '000000'],
],
],
'alignment' => [
'horizontal' => Alignment::HORIZONTAL_CENTER,
'vertical' => Alignment::VERTICAL_CENTER,
],
]);
// Left align text columns
$sheet->getStyle('B' . $dataStartRow . ':D' . $dataEndRow)->getAlignment()->setHorizontal(Alignment::HORIZONTAL_LEFT);
// Style the total row
$sheet->getStyle('A' . $totalRow . ':' . $lastColumn . $totalRow)->applyFromArray([
'font' => [
'bold' => true,
'size' => 11,
],
'fill' => [
'fillType' => Fill::FILL_SOLID,
'startColor' => ['rgb' => 'F2F2F2'],
],
'borders' => [
'allBorders' => [
'borderStyle' => Border::BORDER_THIN,
'color' => ['rgb' => '000000'],
],
],
]);
}
// Style the export information section
if ($filterStartRow <= $lastRow) {
$exportInfoRow = $totalRow + 2; // After total row + empty row
$filterEndRow = $lastRow;
// Style the "INFORMASI EXPORT" title
$sheet->getStyle('A' . $exportInfoRow . ':' . $lastColumn . $exportInfoRow)->applyFromArray([
'font' => [
'bold' => true,
'size' => 12,
],
'fill' => [
'fillType' => Fill::FILL_SOLID,
'startColor' => ['rgb' => 'E6E6E6'],
],
'alignment' => [
'horizontal' => Alignment::HORIZONTAL_LEFT,
'vertical' => Alignment::VERTICAL_CENTER,
],
]);
// Style the filter info rows
$filterInfoStartRow = $exportInfoRow + 3; // After title + empty + "Filter yang Digunakan:"
$sheet->getStyle('A' . $filterInfoStartRow . ':' . $lastColumn . $filterEndRow)->applyFromArray([
'font' => [
'size' => 10,
],
'alignment' => [
'horizontal' => Alignment::HORIZONTAL_LEFT,
'vertical' => Alignment::VERTICAL_TOP,
],
]);
}
// Auto-size columns
foreach (range('A', $lastColumn) as $column) {
if ($column === 'A') {
// Set specific width for column A (No) - don't auto-size
$sheet->getColumnDimension($column)->setWidth(5);
} else {
$sheet->getColumnDimension($column)->setAutoSize(true);
}
}
} catch (\Exception $e) {
Log::error('Error applying styles: ' . $e->getMessage());
}
}
public function columnWidths(): array
{
$widths = [
'A' => 5, // No - reduced from 8 to 5
'B' => 30, // Nama Pekerjaan
'C' => 15, // Kode Pekerjaan
'D' => 20, // Kategori
];
// Add widths for mechanic columns
$currentColumn = 'E';
foreach ($this->mechanics as $mechanic) {
$widths[$currentColumn++] = 15; // Mechanic total
}
// Add width for total column
$widths[$currentColumn] = 15; // Total
return $widths;
}
public function registerEvents(): array
{
return [
AfterSheet::class => function(AfterSheet $event) {
$sheet = $event->sheet->getDelegate();
$highestColumn = $sheet->getHighestColumn();
$highestRow = $sheet->getHighestRow();
// Header styling ONLY for row 1
$sheet->getStyle('A1:' . $highestColumn . '1')->applyFromArray([
'font' => [
'bold' => true,
'color' => ['rgb' => 'FFFFFF'],
'size' => 10,
],
'fill' => [
'fillType' => Fill::FILL_SOLID,
'startColor' => ['rgb' => '2E5BBA'],
],
'alignment' => [
'horizontal' => Alignment::HORIZONTAL_CENTER,
'vertical' => Alignment::VERTICAL_CENTER,
],
'borders' => [
'allBorders' => [
'borderStyle' => Border::BORDER_THIN,
'color' => ['rgb' => '000000'],
],
],
]);
// Total row styling (only last row)
$sheet->getStyle('A' . $highestRow . ':' . $highestColumn . $highestRow)->applyFromArray([
'font' => [
'bold' => true,
'size' => 11,
],
'fill' => [
'fillType' => Fill::FILL_SOLID,
'startColor' => ['rgb' => 'F2F2F2'],
],
'borders' => [
'allBorders' => [
'borderStyle' => Border::BORDER_THIN,
'color' => ['rgb' => '000000'],
],
],
]);
// Export info below table
$infoStartRow = $highestRow + 2;
$sheet->setCellValue('A' . $infoStartRow, 'INFORMASI EXPORT');
$sheet->getStyle('A' . $infoStartRow . ':' . $highestColumn . $infoStartRow)->applyFromArray([
'font' => [
'bold' => true,
'size' => 12,
],
'fill' => [
'fillType' => Fill::FILL_SOLID,
'startColor' => ['rgb' => 'E6E6E6'],
],
'alignment' => [
'horizontal' => Alignment::HORIZONTAL_LEFT,
'vertical' => Alignment::VERTICAL_CENTER,
],
]);
$sheet->setCellValue('A' . ($infoStartRow + 2), 'Filter yang Digunakan:');
$row = $infoStartRow + 3;
foreach ($this->filterInfo as $info) {
$sheet->setCellValue('A' . $row, $info);
$row++;
}
$sheet->getStyle('A' . ($infoStartRow + 2) . ':A' . ($row-1))->applyFromArray([
'font' => [ 'size' => 10 ],
'alignment' => [
'horizontal' => Alignment::HORIZONTAL_LEFT,
'vertical' => Alignment::VERTICAL_TOP,
],
]);
}
];
}
}

0
app/Exports/TransactionDealerExport.php Normal file → Executable file
View File

0
app/Exports/TransactionExport.php Normal file → Executable file
View File

0
app/Exports/TransactionSaExport.php Normal file → Executable file
View File

130
app/Http/Controllers/AdminController.php Normal file → Executable file
View File

@@ -4,10 +4,12 @@ namespace App\Http\Controllers;
use App\Models\Dealer; use App\Models\Dealer;
use App\Models\Menu; use App\Models\Menu;
use App\Models\Role;
use App\Models\Transaction; use App\Models\Transaction;
use App\Models\User; use App\Models\User;
use App\Models\Work; use App\Models\Work;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Gate;
@@ -37,9 +39,22 @@ class AdminController extends Controller
$month = $request->month; $month = $request->month;
$dealer = $request->dealer; $dealer = $request->dealer;
$year = $request->year; $year = $request->year;
$dealer_datas = Dealer::all();
// Get dealers based on user role
$user = Auth::user();
$role = Role::with(['dealers' => function($query) {
$query->whereNull('dealers.deleted_at'); // Only active dealers
}])->find($user->role_id);
if($role && $this->isAdminRole($role) && $role->dealers->count() == 0) {
$dealer_datas = Dealer::all();
} else if($role) {
$dealer_datas = $role->dealers()->whereNull('dealers.deleted_at')->orderBy('name')->get();
} else {
$dealer_datas = collect();
}
$ajax_url = route('dashboard_data').'?month='.$month.'&year='.$year.'&dealer='.$dealer; $ajax_url = route('dashboard_data').'?month='.$month.'&year='.$year.'&dealer='.$dealer;
// dd($ajax_url);
return view('dashboard', compact('month','year', 'ajax_url', 'dealer', 'dealer_datas')); return view('dashboard', compact('month','year', 'ajax_url', 'dealer', 'dealer_datas'));
} }
@@ -72,22 +87,52 @@ class AdminController extends Controller
$dealer_work_trx = DB::statement("SET @sql = NULL"); $dealer_work_trx = DB::statement("SET @sql = NULL");
$sql = "SELECT IF(work_id IS NOT NULL, GROUP_CONCAT(DISTINCT CONCAT('SUM(IF(work_id = \"', work_id,'\", qty,\"\")) AS \"',CONCAT(w.name, '|',w.id),'\"')), 's.work_id') INTO @sql FROM transactions t JOIN works w ON w.id = t.work_id WHERE month(t.date) = '". $month ."' and year(t.date) = '". $year ."' and t.deleted_at is null"; $sql = "SELECT IF(work_id IS NOT NULL, GROUP_CONCAT(DISTINCT CONCAT('SUM(IF(work_id = \"', work_id,'\", qty,\"\")) AS \"',CONCAT(w.name, '|',w.id),'\"')), 's.work_id') INTO @sql FROM transactions t JOIN works w ON w.id = t.work_id WHERE month(t.date) = '". $month ."' and year(t.date) = '". $year ."' and t.deleted_at is null";
if(isset($request->dealer) && $request->dealer != 'all') { $dealer_work_trx = DB::statement($sql);
$sql .= " and t.dealer_id = '". $dealer ."'";
// Get dealers based on user role - only change this part
$user = Auth::user();
$role = Role::with(['dealers' => function($query) {
$query->whereNull('dealers.deleted_at'); // Only active dealers
}])->find($user->role_id);
if($role && $this->isAdminRole($role) && $role->dealers->count() == 0) {
$dealer_datas = Dealer::all();
} else if($role) {
$dealer_datas = $role->dealers()->whereNull('dealers.deleted_at')->orderBy('name')->get();
} else {
$dealer_datas = collect();
} }
$dealer_work_trx = DB::statement($sql); // Validate that the requested dealer is allowed for this user
if(isset($request->dealer) && $request->dealer != 'all') { if(isset($request->dealer) && $request->dealer != 'all') {
$dealer_work_trx = DB::statement("SET @sql = IF(@sql != 's.work_id' ,CONCAT(\"SELECT d.name as DEALER, d.id as dealer_id, \", @sql, \"FROM transactions s JOIN dealers d ON d.id = s.dealer_id WHERE month(s.date) = '". $month ."' and year(s.date) = '". $year ."' and s.deleted_at is null and s.dealer_id = '". $dealer ."' GROUP BY s.dealer_id ORDER BY s.dealer_id ASC\"), CONCAT(\"SELECT d.name as DEALER \", \"FROM transactions s JOIN dealers d ON d.id = s.dealer_id WHERE month(s.date) = '". $month ."' and year(s.date) = '". $year ."' and s.deleted_at is null and s.dealer_id = '". $dealer ."' GROUP BY s.`dealer_id` ORDER BY s.`dealer_id` ASC\"))"); if($dealer_datas->count() > 0) {
}else{ $allowedDealerIds = $dealer_datas->pluck('id')->toArray();
$dealer_work_trx = DB::statement("SET @sql = IF(@sql != 's.work_id' ,CONCAT(\"SELECT d.name as DEALER, d.id as dealer_id, \", @sql, \"FROM transactions s JOIN dealers d ON d.id = s.dealer_id WHERE month(s.date) = '". $month ."' and year(s.date) = '". $year ."' and s.deleted_at is null GROUP BY s.dealer_id ORDER BY s.dealer_id ASC\"), CONCAT(\"SELECT d.name as DEALER, d.id as dealer_id \", \"FROM transactions s JOIN dealers d ON d.id = s.dealer_id WHERE month(s.date) = '". $month ."' and year(s.date) = '". $year ."' and s.deleted_at is null GROUP BY s.`dealer_id` ORDER BY s.`dealer_id` ASC\"))"); if(!in_array($dealer, $allowedDealerIds)) {
// If dealer is not allowed, reset to 'all'
$dealer = 'all';
}
} else {
// If no dealers are allowed, reset to 'all'
$dealer = 'all';
}
}
// Build dealer filter based on user role
$dealerFilter = '';
if($dealer_datas->count() > 0) {
$dealerIds = $dealer_datas->pluck('id')->toArray();
$dealerFilter = " and s.dealer_id IN (" . implode(',', $dealerIds) . ")";
}
if(isset($request->dealer) && $request->dealer != 'all') {
$dealer_work_trx = DB::statement("SET @sql = IF(@sql != 's.work_id' ,CONCAT(\"SELECT d.name as DEALER, d.id as dealer_id, \", @sql, \"FROM transactions s JOIN dealers d ON d.id = s.dealer_id WHERE month(s.date) = '". $month ."' and year(s.date) = '". $year ."' and s.deleted_at is null and s.dealer_id = '". $dealer ."'". $dealerFilter ." GROUP BY s.dealer_id ORDER BY s.dealer_id ASC\"), CONCAT(\"SELECT d.name as DEALER \", \"FROM transactions s JOIN dealers d ON d.id = s.dealer_id WHERE month(s.date) = '". $month ."' and year(s.date) = '". $year ."' and s.deleted_at is null and s.dealer_id = '". $dealer ."'". $dealerFilter ." GROUP BY s.`dealer_id` ORDER BY s.`dealer_id` ASC\"))");
} else {
$dealer_work_trx = DB::statement("SET @sql = IF(@sql != 's.work_id' ,CONCAT(\"SELECT d.name as DEALER, d.id as dealer_id, \", @sql, \"FROM transactions s JOIN dealers d ON d.id = s.dealer_id WHERE month(s.date) = '". $month ."' and year(s.date) = '". $year ."' and s.deleted_at is null". $dealerFilter ." GROUP BY s.dealer_id ORDER BY s.dealer_id ASC\"), CONCAT(\"SELECT d.name as DEALER, d.id as dealer_id \", \"FROM transactions s JOIN dealers d ON d.id = s.dealer_id WHERE month(s.date) = '". $month ."' and year(s.date) = '". $year ."' and s.deleted_at is null". $dealerFilter ." GROUP BY s.`dealer_id` ORDER BY s.`dealer_id` ASC\"))");
} }
$dealer_work_trx = DB::statement("PREPARE stmt FROM @sql"); $dealer_work_trx = DB::statement("PREPARE stmt FROM @sql");
$dealer_work_trx = DB::select(DB::raw("EXECUTE stmt")); $dealer_work_trx = DB::select(DB::raw("EXECUTE stmt"));
DB::statement('DEALLOCATE PREPARE stmt'); DB::statement('DEALLOCATE PREPARE stmt');
// DD($dealer_work_trx);
$theads = ['DEALER']; $theads = ['DEALER'];
$dealer_names = []; $dealer_names = [];
$dealer_trx = []; $dealer_trx = [];
@@ -118,7 +163,6 @@ class AdminController extends Controller
$dealer_names[] = $dealer_work->DEALER; $dealer_names[] = $dealer_work->DEALER;
} }
// dd($dealer_trx);
$dealer_trx = array_values($dealer_trx); $dealer_trx = array_values($dealer_trx);
$dealer = $request->dealer; $dealer = $request->dealer;
$month = $request->month; $month = $request->month;
@@ -128,10 +172,12 @@ class AdminController extends Controller
$prev_mth_start = date('Y-m-d', strtotime(date($year.'-'. $request->month .'-1')." -1 month")); $prev_mth_start = date('Y-m-d', strtotime(date($year.'-'. $request->month .'-1')." -1 month"));
$prev_mth = explode('-', $prev_mth_start); $prev_mth = explode('-', $prev_mth_start);
if($request->month == date('m')) { if($request->month == date('m') && $year == date('Y')) {
$prev_mth_end = $prev_mth[0].'-'.$prev_mth[1].'-'.date('d'); // Jika bulan sekarang, ambil total bulan sebelumnya yang lengkap
$prev_mth_end = $prev_mth[0].'-'.$prev_mth[1].'-'.date('t', strtotime($prev_mth_start));
}else{ }else{
$prev_mth_end = $prev_mth[0].'-'.$prev_mth[1].'-'.date('t'); // Jika bulan lain, ambil total bulan sebelumnya yang lengkap
$prev_mth_end = $prev_mth[0].'-'.$prev_mth[1].'-'.date('t', strtotime($prev_mth_start));
} }
$prev_month_trx = []; $prev_month_trx = [];
@@ -143,6 +189,11 @@ class AdminController extends Controller
if(isset($request->dealer) && $request->dealer != 'all') { if(isset($request->dealer) && $request->dealer != 'all') {
$prev_month = $prev_month->where('dealer_id', $request->dealer); $prev_month = $prev_month->where('dealer_id', $request->dealer);
$now_month = $now_month->where('dealer_id', $request->dealer); $now_month = $now_month->where('dealer_id', $request->dealer);
} else if($dealer_datas->count() > 0) {
// Filter by allowed dealers based on user role
$dealerIds = $dealer_datas->pluck('id')->toArray();
$prev_month = $prev_month->whereIn('dealer_id', $dealerIds);
$now_month = $now_month->whereIn('dealer_id', $dealerIds);
} }
$prev_month_trx[] = $prev_month->sum('qty'); $prev_month_trx[] = $prev_month->sum('qty');
@@ -160,6 +211,36 @@ class AdminController extends Controller
return view('dashboard_data', compact('theads', 'work_trx', 'month', 'year', 'dealer_names', 'dealer_trx', 'dealer', 'totals')); return view('dashboard_data', compact('theads', 'work_trx', 'month', 'year', 'dealer_names', 'dealer_trx', 'dealer', 'totals'));
} }
/**
* Check if role is admin type
*/
private function isAdminRole($role)
{
if (!$role) {
return false;
}
// Define admin role names that should have access to all dealers
$adminRoleNames = [
'admin'
];
// Check if role name contains admin keywords (but not "area")
$roleName = strtolower(trim($role->name));
foreach ($adminRoleNames as $adminName) {
if (strpos($roleName, $adminName) !== false && strpos($roleName, 'area') === false) {
return true;
}
}
// Role with "area" in name should use pivot dealers, not all dealers
if (strpos($roleName, 'area') !== false) {
return false;
}
return false;
}
public function dealer_work_trx(Request $request) { public function dealer_work_trx(Request $request) {
$dealer_work_trx = Work::select(DB::raw('works.name AS work_name'), DB::raw("IFNULL(SUM(t.qty), 0) AS qty"), 'works.id AS work_id')->whereHas('transactions', function($q) use($request) { $dealer_work_trx = Work::select(DB::raw('works.name AS work_name'), DB::raw("IFNULL(SUM(t.qty), 0) AS qty"), 'works.id AS work_id')->whereHas('transactions', function($q) use($request) {
if(isset($request->month)) { if(isset($request->month)) {
@@ -227,13 +308,14 @@ class AdminController extends Controller
foreach($works as $work1) { foreach($works as $work1) {
$prev_mth_start = date('Y-m-d', strtotime(date('Y-'. $request->month .'-1')." -1 month")); $prev_mth_start = date('Y-m-d', strtotime(date('Y-'. $request->month .'-1')." -1 month"));
$prev_mth = explode('-', $prev_mth_start); $prev_mth = explode('-', $prev_mth_start);
if($request->month == date('m')) { if($request->month == date('m') && date('Y') == date('Y')) {
$prev_mth_end = $prev_mth[0].'-'.$prev_mth[1].'-'.date('d'); // Jika bulan sekarang, ambil total bulan sebelumnya yang lengkap
$prev_mth_end = $prev_mth[0].'-'.$prev_mth[1].'-'.date('t', strtotime($prev_mth_start));
}else{ }else{
$prev_mth_end = $prev_mth[0].'-'.$prev_mth[1].'-'.date('t'); // Jika bulan lain, ambil total bulan sebelumnya yang lengkap
$prev_mth_end = $prev_mth[0].'-'.$prev_mth[1].'-'.date('t', strtotime($prev_mth_start));
} }
// dd($prev_mth_end);
$yesterday_month_trx = Transaction::where('work_id', $work1->id)->where('dealer_id', $dealer->id)->whereDate('date', '>=', $prev_mth_start)->whereDate('date', '<=', $prev_mth_end)->sum('qty'); $yesterday_month_trx = Transaction::where('work_id', $work1->id)->where('dealer_id', $dealer->id)->whereDate('date', '>=', $prev_mth_start)->whereDate('date', '<=', $prev_mth_end)->sum('qty');
if(array_key_exists($work1->id, $prev_month_trxs_total)) { if(array_key_exists($work1->id, $prev_month_trxs_total)) {
@@ -348,10 +430,12 @@ class AdminController extends Controller
foreach($works as $work1) { foreach($works as $work1) {
$prev_mth_start = date('Y-m-d', strtotime(date($request->year.'-'. $request->month .'-1')." -1 month")); $prev_mth_start = date('Y-m-d', strtotime(date($request->year.'-'. $request->month .'-1')." -1 month"));
$prev_mth = explode('-', $prev_mth_start); $prev_mth = explode('-', $prev_mth_start);
if($request->month == date('m')) { if($request->month == date('m') && $request->year == date('Y')) {
$prev_mth_end = $prev_mth[0].'-'.$prev_mth[1].'-'.date('d'); // Jika bulan sekarang, ambil total bulan sebelumnya yang lengkap
$prev_mth_end = $prev_mth[0].'-'.$prev_mth[1].'-'.date('t', strtotime($prev_mth_start));
}else{ }else{
$prev_mth_end = $prev_mth[0].'-'.$prev_mth[1].'-'.date('t'); // Jika bulan lain, ambil total bulan sebelumnya yang lengkap
$prev_mth_end = $prev_mth[0].'-'.$prev_mth[1].'-'.date('t', strtotime($prev_mth_start));
} }
$yesterday_month_trx = Transaction::where('work_id', $work1->id)->where('dealer_id', $id)->whereDate('date', '>=', $prev_mth_start)->whereDate('date', '<=', $prev_mth_end)->sum('qty'); $yesterday_month_trx = Transaction::where('work_id', $work1->id)->where('dealer_id', $id)->whereDate('date', '>=', $prev_mth_start)->whereDate('date', '<=', $prev_mth_end)->sum('qty');
@@ -440,16 +524,12 @@ class AdminController extends Controller
// $month_trxs_total = array_values($month_trxs_total); // $month_trxs_total = array_values($month_trxs_total);
// $yesterday_month_trxs_total = array_values($yesterday_month_trxs_total); // $yesterday_month_trxs_total = array_values($yesterday_month_trxs_total);
// dd(["month_trxs_total" => $month_trxs_total, "yesterday_month_trxs_total" => $yesterday_month_trxs_total, "works" => $works->toArray()]);
// dd($month_trxs_total);
// dd($yesterday_month_trxs_total);
$final_month_trxs_total = []; $final_month_trxs_total = [];
$final_yesterday_month_trxs_total = []; $final_yesterday_month_trxs_total = [];
foreach($works as $work1) { foreach($works as $work1) {
$final_month_trxs_total[$work1->id] = array_key_exists($work1->id, $month_trxs_total) ? $month_trxs_total[$work1->id] : 0; $final_month_trxs_total[$work1->id] = array_key_exists($work1->id, $month_trxs_total) ? $month_trxs_total[$work1->id] : 0;
$final_yesterday_month_trxs_total[$work1->id] = $yesterday_month_trxs_total[$work1->id]; $final_yesterday_month_trxs_total[$work1->id] = $yesterday_month_trxs_total[$work1->id];
} }
// dd([$final_month_trxs_total, $final_yesterday_month_trxs_total]);
$month_trxs_total = array_values($final_month_trxs_total); $month_trxs_total = array_values($final_month_trxs_total);
$yesterday_month_trxs_total = array_values($final_yesterday_month_trxs_total); $yesterday_month_trxs_total = array_values($final_yesterday_month_trxs_total);
$totals = []; $totals = [];

8
app/Http/Controllers/ApiController.php Normal file → Executable file
View File

@@ -93,7 +93,6 @@ class ApiController extends Controller
$prev_mth_end = $prev_mth[0].'-'.$prev_mth[1].'-'.date('t'); $prev_mth_end = $prev_mth[0].'-'.$prev_mth[1].'-'.date('t');
} }
// dd($prev_mth_end);
$yesterday_month_trx = Transaction::where('work_id', $work1->id)->where('dealer_id', $id)->whereDate('date', '>=', $prev_mth_start)->whereDate('date', '<=', $prev_mth_end)->sum('qty'); $yesterday_month_trx = Transaction::where('work_id', $work1->id)->where('dealer_id', $id)->whereDate('date', '>=', $prev_mth_start)->whereDate('date', '<=', $prev_mth_end)->sum('qty');
if(array_key_exists($work1->id, $yesterday_month_trxs_total)) { if(array_key_exists($work1->id, $yesterday_month_trxs_total)) {
@@ -153,7 +152,6 @@ class ApiController extends Controller
$final_month_trxs_total[$work1->id] = $month_trxs_total[$work1->id]; $final_month_trxs_total[$work1->id] = $month_trxs_total[$work1->id];
$final_yesterday_month_trxs_total[$work1->id] = $yesterday_month_trxs_total[$work1->id]; $final_yesterday_month_trxs_total[$work1->id] = $yesterday_month_trxs_total[$work1->id];
} }
// dd([$final_month_trxs_total, $final_yesterday_month_trxs_total]);
$month_trxs_total = array_values($final_month_trxs_total); $month_trxs_total = array_values($final_month_trxs_total);
$yesterday_month_trxs_total = array_values($final_yesterday_month_trxs_total); $yesterday_month_trxs_total = array_values($final_yesterday_month_trxs_total);
@@ -287,7 +285,11 @@ class ApiController extends Controller
public function logout() public function logout()
{ {
Auth::user()->tokens()->delete(); /** @var \App\Models\User $user */
$user = auth('sanctum')->user();
if ($user) {
$user->tokens()->delete();
}
return response()->json([ return response()->json([
'message' => 'Logout success', 'message' => 'Logout success',
'status' => true, 'status' => true,

View File

0
app/Http/Controllers/Auth/ForgotPasswordController.php Normal file → Executable file
View File

37
app/Http/Controllers/Auth/LoginController.php Normal file → Executable file
View File

@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Privilege; use App\Models\Privilege;
use App\Models\User;
use App\Providers\RouteServiceProvider; use App\Providers\RouteServiceProvider;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Foundation\Auth\AuthenticatesUsers; use Illuminate\Foundation\Auth\AuthenticatesUsers;
@@ -50,11 +51,39 @@ class LoginController extends Controller
*/ */
protected function authenticated(Request $request, $user) protected function authenticated(Request $request, $user)
{ {
$user = Privilege::where('menu_id', 10)->where('role_id', Auth::user()->role_id)->where('view', 1)->first(); // Get user's role_id
$roleId = Auth::user()->role_id;
if (!$roleId) {
// User has no role, redirect to default
return redirect(RouteServiceProvider::HOME);
}
if ($user != null) { // Check if user has access to adminarea menu
return redirect()->route('dashboard'); if (!User::roleCanAccessMenu($roleId, 'adminarea')) {
}else{ // User doesn't have admin area access, redirect to default home
return redirect(RouteServiceProvider::HOME);
}
// User has admin area access, get first accessible menu (excluding adminarea and mechanicarea)
$firstMenu = Privilege::join('menus', 'privileges.menu_id', '=', 'menus.id')
->where('privileges.role_id', $roleId)
->where('privileges.view', 1)
->whereNotIn('menus.link', ['adminarea', 'mechanicarea'])
->select('menus.*', 'privileges.view', 'privileges.create', 'privileges.update', 'privileges.delete')
->orderBy('menus.id')
->first();
if (!$firstMenu) {
// User has no accessible menus (excluding adminarea/mechanicarea), redirect to default
return redirect(RouteServiceProvider::HOME);
}
try {
// Try to redirect to the first accessible menu
return redirect()->route($firstMenu->link);
} catch (\Exception $e) {
// Route doesn't exist, fallback to default home
return redirect(RouteServiceProvider::HOME); return redirect(RouteServiceProvider::HOME);
} }
} }

0
app/Http/Controllers/Auth/RegisterController.php Normal file → Executable file
View File

0
app/Http/Controllers/Auth/ResetPasswordController.php Normal file → Executable file
View File

0
app/Http/Controllers/Auth/VerificationController.php Normal file → Executable file
View File

12
app/Http/Controllers/CategoryController.php Normal file → Executable file
View File

@@ -25,16 +25,16 @@ class CategoryController extends Controller
$data = Category::all(); $data = Category::all();
return DataTables::of($data)->addIndexColumn() return DataTables::of($data)->addIndexColumn()
->addColumn('action', function($row) use ($menu) { ->addColumn('action', function($row) use ($menu) {
$btn = ''; $btn = '<div class="d-flex">';
if(Auth::user()->can('delete', $menu)) { if(Gate::allows('update', $menu)) {
$btn .= '<button class="btn btn-warning btn-sm btn-bold mr-2" id="editCategory'. $row->id .'" data-url="'. route('category.edit', $row->id) .'" data-action="'. route('category.update', $row->id) .'" onclick="editCategory('. $row->id .')"> Edit </button>';
}
if(Gate::allows('delete', $menu)) {
$btn .= '<button class="btn btn-danger btn-sm btn-bold" data-action="'. route('category.destroy', $row->id) .'" id="destroyCategory'. $row->id .'" onclick="destroyCategory('. $row->id .')"> Hapus </button>'; $btn .= '<button class="btn btn-danger btn-sm btn-bold" data-action="'. route('category.destroy', $row->id) .'" id="destroyCategory'. $row->id .'" onclick="destroyCategory('. $row->id .')"> Hapus </button>';
} }
if(Auth::user()->can('update', $menu)) { $btn .= '</div>';
$btn .= '<button class="btn btn-warning btn-sm btn-bold" id="editCategory'. $row->id .'" data-url="'. route('category.edit', $row->id) .'" data-action="'. route('category.update', $row->id) .'" onclick="editCategory('. $row->id .')"> Edit </button>';
}
return $btn; return $btn;
}) })
->rawColumns(['action']) ->rawColumns(['action'])

0
app/Http/Controllers/Controller.php Normal file → Executable file
View File

17
app/Http/Controllers/DealerController.php Normal file → Executable file
View File

@@ -27,27 +27,28 @@ class DealerController extends Controller
$data = Dealer::leftJoin('users as u', 'u.id', '=', 'pic')->select('u.name as pic_name', 'dealers.*'); $data = Dealer::leftJoin('users as u', 'u.id', '=', 'pic')->select('u.name as pic_name', 'dealers.*');
return Datatables::of($data)->addIndexColumn() return Datatables::of($data)->addIndexColumn()
->addColumn('action', function($row) use ($menu) { ->addColumn('action', function($row) use ($menu) {
$btn = ''; $btn = '<div class="d-flex">';
if($row->pic != null) { if($row->pic != null) {
if(Auth::user()->can('delete', $menu)) { if(Gate::allows('delete', $menu)) {
$btn .= '<button class="btn btn-danger btn-sm btn-bold" data-action="'. route('dealer.destroy', $row->id) .'" id="destroyDealer'. $row->id .'" onclick="destroyDealer('. $row->id .')"> Hapus </button>'; $btn .= '<button class="btn btn-danger btn-sm btn-bold mr-2" data-action="'. route('dealer.destroy', $row->id) .'" id="destroyDealer'. $row->id .'" onclick="destroyDealer('. $row->id .')"> Hapus </button>';
} }
if(Auth::user()->can('update', $menu)) { if(Gate::allows('update', $menu)) {
$btn .= '<button class="btn btn-warning btn-sm btn-bold" id="editDealer'. $row->id .'" data-url="'. route('dealer.edit', $row->id) .'" data-action="'. route('dealer.update', $row->id) .'" onclick="editDealer('. $row->id .')"> Edit </button>'; $btn .= '<button class="btn btn-warning btn-sm btn-bold" id="editDealer'. $row->id .'" data-url="'. route('dealer.edit', $row->id) .'" data-action="'. route('dealer.update', $row->id) .'" onclick="editDealer('. $row->id .')"> Edit </button>';
} }
}else{ }else{
if(Auth::user()->can('delete', $menu)) { if(Gate::allows('delete', $menu)) {
$btn .= '<button class="btn btn-danger btn-sm btn-bold" data-action="'. route('dealer.destroy', $row->id) .'" id="destroyDealer'. $row->id .'" onclick="destroyDealer('. $row->id .')"> Hapus </button>'; $btn .= '<button class="btn btn-danger btn-sm btn-bold mr-2" data-action="'. route('dealer.destroy', $row->id) .'" id="destroyDealer'. $row->id .'" onclick="destroyDealer('. $row->id .')"> Hapus </button>';
} }
if(Auth::user()->can('update', $menu)) { if(Gate::allows('update', $menu)) {
$btn .= '<button class="btn btn-warning btn-sm btn-bold" id="editDealer'. $row->id .'" data-url="'. route('dealer.edit', $row->id) .'" data-action="'. route('dealer.update', $row->id) .'" onclick="editDealer('. $row->id .')"> Edit </button> $btn .= '<button class="btn btn-warning btn-sm btn-bold mr-2" id="editDealer'. $row->id .'" data-url="'. route('dealer.edit', $row->id) .'" data-action="'. route('dealer.update', $row->id) .'" onclick="editDealer('. $row->id .')"> Edit </button>
<button class="btn btn-success btn-sm btn-bold" data-action="'. route('dealer.picstore', $row->id) .'" id="addPic'. $row->id .'" data-url="'. route('dealer.edit', $row->id) .'" onclick="addPic('. $row->id .')"> Tambahkan PIC </button>'; <button class="btn btn-success btn-sm btn-bold" data-action="'. route('dealer.picstore', $row->id) .'" id="addPic'. $row->id .'" data-url="'. route('dealer.edit', $row->id) .'" onclick="addPic('. $row->id .')"> Tambahkan PIC </button>';
} }
} }
$btn .= '</div>';
return $btn; return $btn;
}) })
->rawColumns(['action']) ->rawColumns(['action'])

0
app/Http/Controllers/HomeController.php Normal file → Executable file
View File

View File

@@ -0,0 +1,237 @@
<?php
namespace App\Http\Controllers\KPI;
use App\Http\Controllers\Controller;
use App\Http\Requests\KPI\StoreKpiTargetRequest;
use App\Http\Requests\KPI\UpdateKpiTargetRequest;
use App\Models\KpiTarget;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Log;
class TargetsController extends Controller
{
/**
* Display a listing of KPI targets
*/
public function index()
{
$targets = KpiTarget::with(['user', 'user.role'])
->orderBy('created_at', 'desc')
->paginate(15);
// Get mechanics using role_id 3 (mechanic) with dealer relationship
$mechanics = User::with('dealer')
->where('role_id', 3)
->orderBy('name', 'asc')
->limit(50)
->get();
// If no mechanics found, get all users as fallback
if ($mechanics->isEmpty()) {
$mechanics = User::with('dealer')
->orderBy('name', 'asc')
->limit(50)
->get();
}
return view('kpi.targets.index', compact('targets', 'mechanics'));
}
/**
* Show the form for creating a new KPI target
*/
public function create()
{
// Get mechanics using role_id 3 (mechanic) with dealer relationship
$mechanics = User::with('dealer')
->where('role_id', 3)
->orderBy('name', 'asc')
->limit(50)
->get();
// Debug: Log the mechanics found
Log::info('Mechanics found for KPI target creation:', [
'count' => $mechanics->count(),
'mechanics' => $mechanics->pluck('name', 'id')->toArray()
]);
// If no mechanics found, get all users as fallback
if ($mechanics->isEmpty()) {
$mechanics = User::with('dealer')
->orderBy('name', 'asc')
->limit(50)
->get();
Log::warning('No mechanics found, using all users as fallback', [
'count' => $mechanics->count()
]);
}
return view('kpi.targets.create', compact('mechanics'));
}
/**
* Store a newly created KPI target
*/
public function store(StoreKpiTargetRequest $request)
{
try {
// Log the validated data
Log::info('Creating KPI target with data:', $request->validated());
// Check if user already has an active target and deactivate it
$existingTarget = KpiTarget::where('user_id', $request->user_id)
->where('is_active', true)
->first();
if ($existingTarget) {
Log::info('Deactivating existing active KPI target', [
'user_id' => $request->user_id,
'existing_target_id' => $existingTarget->id
]);
// Deactivate the existing target
$existingTarget->update(['is_active' => false]);
}
$target = KpiTarget::create($request->validated());
Log::info('KPI target created successfully', [
'target_id' => $target->id,
'user_id' => $target->user_id
]);
return redirect()->route('kpi.targets.index')
->with('success', 'Target KPI berhasil ditambahkan');
} catch (\Exception $e) {
Log::error('Failed to create KPI target', [
'error' => $e->getMessage(),
'data' => $request->validated()
]);
return redirect()->back()
->withInput()
->with('error', 'Gagal menambahkan target KPI: ' . $e->getMessage());
}
}
/**
* Display the specified KPI target
*/
public function show(KpiTarget $target)
{
$target->load(['user.dealer', 'achievements']);
return view('kpi.targets.show', compact('target'));
}
/**
* Show the form for editing the specified KPI target
*/
public function edit(KpiTarget $target)
{
// Debug: Check if target is loaded correctly
if (!$target) {
abort(404, 'Target KPI tidak ditemukan');
}
// Load target with user relationship
$target->load('user');
// Get mechanics using role_id 3 (mechanic) with dealer relationship
$mechanics = User::with('dealer')
->where('role_id', 3)
->orderBy('name', 'asc')
->limit(50)
->get();
// If no mechanics found, get all users as fallback
if ($mechanics->isEmpty()) {
$mechanics = User::with('dealer')
->orderBy('name', 'asc')
->limit(50)
->get();
}
// Ensure data types are correct for comparison
$target->user_id = (int)$target->user_id;
return view('kpi.targets.edit', compact('target', 'mechanics'));
}
/**
* Update the specified KPI target
*/
public function update(UpdateKpiTargetRequest $request, KpiTarget $target)
{
try {
$target->update($request->validated());
return redirect()->route('kpi.targets.index')
->with('success', 'Target KPI berhasil diperbarui');
} catch (\Exception $e) {
return redirect()->back()
->withInput()
->with('error', 'Gagal memperbarui target KPI: ' . $e->getMessage());
}
}
/**
* Remove the specified KPI target
*/
public function destroy(KpiTarget $target)
{
try {
$target->delete();
return redirect()->route('kpi.targets.index')
->with('success', 'Target KPI berhasil dihapus');
} catch (\Exception $e) {
return redirect()->back()
->with('error', 'Gagal menghapus target KPI: ' . $e->getMessage());
}
}
/**
* Toggle active status of KPI target
*/
public function toggleStatus(KpiTarget $target)
{
try {
$target->update(['is_active' => !$target->is_active]);
$status = $target->is_active ? 'diaktifkan' : 'dinonaktifkan';
return response()->json([
'success' => true,
'message' => "Target KPI berhasil {$status}",
'is_active' => $target->is_active
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => 'Gagal mengubah status target KPI'
], 500);
}
}
/**
* Get KPI targets for specific user
*/
public function getUserTargets(User $user)
{
$targets = $user->kpiTargets()
->with('achievements')
->orderBy('year', 'desc')
->orderBy('month', 'desc')
->get();
return response()->json([
'success' => true,
'data' => $targets
]);
}
}

498
app/Http/Controllers/ReportController.php Normal file → Executable file
View File

@@ -16,6 +16,7 @@ use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Gate;
use Yajra\DataTables\Facades\DataTables; use Yajra\DataTables\Facades\DataTables;
use Maatwebsite\Excel\Facades\Excel; use Maatwebsite\Excel\Facades\Excel;
use App\Models\Role;
class ReportController extends Controller class ReportController extends Controller
{ {
@@ -36,13 +37,41 @@ class ReportController extends Controller
$request['sa'] = 'all'; $request['sa'] = 'all';
} }
$works = Work::select('id', 'name')->whereHas('transactions', function($q) use($request) { // Get dealers based on user role
$user = Auth::user();
$role = Role::with(['dealers' => function($query) {
$query->whereNull('dealers.deleted_at'); // Only active dealers
}])->find($user->role_id);
if($role && $this->isAdminRole($role) && $role->dealers->count() == 0) {
$allowedDealers = Dealer::all();
} else if($role) {
$allowedDealers = $role->dealers()->whereNull('dealers.deleted_at')->orderBy('name')->get();
} else {
$allowedDealers = collect();
}
$works = Work::select('id', 'name')->whereHas('transactions', function($q) use($request, $allowedDealers) {
if(isset($request->month)) { if(isset($request->month)) {
$q = $q->whereMonth('date', '=', $request->month)->whereYear('date', date('Y')); $q = $q->whereMonth('date', '=', $request->month)->whereYear('date', date('Y'));
} }
// Filter by allowed dealers based on user role
if($allowedDealers->count() > 0) {
$dealerIds = $allowedDealers->pluck('id')->toArray();
$q = $q->whereIn('dealer_id', $dealerIds);
}
if(isset($request->dealer) && $request->dealer != 'all') { if(isset($request->dealer) && $request->dealer != 'all') {
$q = $q->where('dealer_id', '=', $request->dealer); // Validate that the requested dealer is allowed for this user
if($allowedDealers->count() > 0) {
$allowedDealerIds = $allowedDealers->pluck('id')->toArray();
if(in_array($request->dealer, $allowedDealerIds)) {
$q = $q->where('dealer_id', '=', $request->dealer);
}
} else {
$q = $q->where('dealer_id', '=', $request->dealer);
}
} }
if(isset($request->sa) && $request->sa != 'all') { if(isset($request->sa) && $request->sa != 'all') {
@@ -52,8 +81,27 @@ class ReportController extends Controller
return $q; return $q;
})->orderBy('id', 'ASC')->get(); })->orderBy('id', 'ASC')->get();
$dealer_datas = Dealer::orderBy('id', 'ASC')->get(); // Get dealers based on user role
$sa_datas = User::select('id', 'name')->where('role_id', 4)->get(); $user = Auth::user();
$role = Role::with(['dealers' => function($query) {
$query->whereNull('dealers.deleted_at'); // Only active dealers
}])->find($user->role_id);
if($role && $this->isAdminRole($role) && $role->dealers->count() == 0) {
$dealer_datas = Dealer::orderBy('id', 'ASC')->get();
} else if($role) {
$dealer_datas = $role->dealers()->whereNull('dealers.deleted_at')->orderBy('name')->get();
} else {
$dealer_datas = collect();
}
// Get SA users based on dealer access
if($dealer_datas->count() > 0) {
$dealerIds = $dealer_datas->pluck('id')->toArray();
$sa_datas = User::select('id', 'name')->where('role_id', 4)->whereIn('dealer_id', $dealerIds)->get();
} else {
$sa_datas = User::select('id', 'name')->where('role_id', 4)->get();
}
$sa = $request->sa; $sa = $request->sa;
$dealer = $request->dealer; $dealer = $request->dealer;
$month = $request->month; $month = $request->month;
@@ -82,8 +130,27 @@ class ReportController extends Controller
$request['sa'] = 'all'; $request['sa'] = 'all';
} }
$dealer_datas = Dealer::orderBy('id', 'ASC')->get(); // Get dealers based on user role
$sa_datas = User::select('id', 'name')->where('role_id', 4)->get(); $user = Auth::user();
$role = Role::with(['dealers' => function($query) {
$query->whereNull('dealers.deleted_at'); // Only active dealers
}])->find($user->role_id);
if($role && $this->isAdminRole($role) && $role->dealers->count() == 0) {
$dealer_datas = Dealer::orderBy('id', 'ASC')->get();
} else if($role) {
$dealer_datas = $role->dealers()->whereNull('dealers.deleted_at')->orderBy('name')->get();
} else {
$dealer_datas = collect();
}
// Get SA users based on dealer access
if($dealer_datas->count() > 0) {
$dealerIds = $dealer_datas->pluck('id')->toArray();
$sa_datas = User::select('id', 'name')->where('role_id', 4)->whereIn('dealer_id', $dealerIds)->get();
} else {
$sa_datas = User::select('id', 'name')->where('role_id', 4)->get();
}
$sa = $request->sa; $sa = $request->sa;
$dealer = $request->dealer; $dealer = $request->dealer;
@@ -126,11 +193,40 @@ class ReportController extends Controller
$sa = $request->sa; $sa = $request->sa;
$year = $request->year; $year = $request->year;
// Get dealers based on user role
$user = Auth::user();
$role = Role::with(['dealers' => function($query) {
$query->whereNull('dealers.deleted_at'); // Only active dealers
}])->find($user->role_id);
if($role && $this->isAdminRole($role) && $role->dealers->count() == 0) {
$allowedDealers = Dealer::all();
} else if($role) {
$allowedDealers = $role->dealers()->whereNull('dealers.deleted_at')->orderBy('name')->get();
} else {
$allowedDealers = collect();
}
$dealer_work_trx = DB::statement("SET @sql = NULL"); $dealer_work_trx = DB::statement("SET @sql = NULL");
$sql = "SELECT IF(work_id IS NOT NULL, GROUP_CONCAT(DISTINCT CONCAT('SUM(IF(work_id = \"', work_id,'\", qty,\"\")) AS \"',CONCAT(w.name, '|',w.id),'\"')), 's.work_id') INTO @sql FROM transactions t JOIN works w ON w.id = t.work_id WHERE month(t.date) = '". $month ."' and year(t.date) = '". $year ."' and t.deleted_at is null"; $sql = "SELECT IF(work_id IS NOT NULL, GROUP_CONCAT(DISTINCT CONCAT('SUM(IF(work_id = \"', work_id,'\", qty,\"\")) AS \"',CONCAT(w.name, '|',w.id),'\"')), 's.work_id') INTO @sql FROM transactions t JOIN works w ON w.id = t.work_id WHERE month(t.date) = '". $month ."' and year(t.date) = '". $year ."' and t.deleted_at is null";
// Filter by allowed dealers based on user role
if($allowedDealers->count() > 0) {
$dealerIds = $allowedDealers->pluck('id')->toArray();
$dealerIdsStr = implode(',', $dealerIds);
$sql .= " and t.dealer_id IN (". $dealerIdsStr .")";
}
if(isset($request->dealer) && $request->dealer != 'all') { if(isset($request->dealer) && $request->dealer != 'all') {
$sql .= " and t.dealer_id = '". $dealer ."'"; // Validate that the requested dealer is allowed for this user
if($allowedDealers->count() > 0) {
$allowedDealerIds = $allowedDealers->pluck('id')->toArray();
if(in_array($request->dealer, $allowedDealerIds)) {
$sql .= " and t.dealer_id = '". $dealer ."'";
}
} else {
$sql .= " and t.dealer_id = '". $dealer ."'";
}
} }
if(isset($request->sa) && $request->sa != 'all') { if(isset($request->sa) && $request->sa != 'all') {
@@ -139,17 +235,35 @@ class ReportController extends Controller
$sa_work_trx = DB::statement($sql); $sa_work_trx = DB::statement($sql);
// Validate dealer access before building the main query
$dealerFilter = "";
if(isset($request->dealer) && $request->dealer != 'all') {
if($allowedDealers->count() > 0) {
$allowedDealerIds = $allowedDealers->pluck('id')->toArray();
if(in_array($request->dealer, $allowedDealerIds)) {
$dealerFilter = " and s.dealer_id = '". $dealer ."'";
}
} else {
$dealerFilter = " and s.dealer_id = '". $dealer ."'";
}
} else if($allowedDealers->count() > 0) {
// If no specific dealer requested, filter by allowed dealers
$dealerIds = $allowedDealers->pluck('id')->toArray();
$dealerIdsStr = implode(',', $dealerIds);
$dealerFilter = " and s.dealer_id IN (". $dealerIdsStr .")";
}
if(isset($request->dealer) && $request->dealer != 'all') { if(isset($request->dealer) && $request->dealer != 'all') {
if(isset($request->sa) && $request->sa != 'all') { if(isset($request->sa) && $request->sa != 'all') {
$sa_work_trx = DB::statement("SET @sql = IF(@sql != 's.work_id' ,CONCAT(\"SELECT sa.name as SA, sa.id as sa_id, \", @sql, \"FROM transactions s JOIN users sa ON sa.id = s.user_sa_id WHERE month(s.date) = '". $month ."' and year(s.date) = '". $year ."' and s.deleted_at is null and s.dealer_id = '". $dealer ."' and s.user_sa_id = '". $sa ."' GROUP BY s.user_sa_id ORDER BY s.user_sa_id ASC\"), CONCAT(\"SELECT sa.name as SA, sa.id as sa_id \", \"FROM transactions s JOIN users sa ON sa.id = s.user_sa_id WHERE month(s.date) = '". $month ."' and year(s.date) = '". $year ."' and s.deleted_at is null and s.dealer_id = '". $dealer ."' and s.user_sa_id = '". $sa ."' GROUP BY s.`user_sa_id` ORDER BY s.`user_sa_id` ASC\"))"); $sa_work_trx = DB::statement("SET @sql = IF(@sql != 's.work_id' ,CONCAT(\"SELECT sa.name as SA, sa.id as sa_id, \", @sql, \"FROM transactions s JOIN users sa ON sa.id = s.user_sa_id WHERE month(s.date) = '". $month ."' and year(s.date) = '". $year ."' and s.deleted_at is null". $dealerFilter ." and s.user_sa_id = '". $sa ."' GROUP BY s.user_sa_id ORDER BY s.user_sa_id ASC\"), CONCAT(\"SELECT sa.name as SA, sa.id as sa_id \", \"FROM transactions s JOIN users sa ON sa.id = s.user_sa_id WHERE month(s.date) = '". $month ."' and year(s.date) = '". $year ."' and s.deleted_at is null". $dealerFilter ." and s.user_sa_id = '". $sa ."' GROUP BY s.`user_sa_id` ORDER BY s.`user_sa_id` ASC\"))");
}else{ }else{
$sa_work_trx = DB::statement("SET @sql = IF(@sql != 's.work_id' ,CONCAT(\"SELECT sa.name as SA, sa.id as sa_id, \", @sql, \"FROM transactions s JOIN users sa ON sa.id = s.user_sa_id WHERE month(s.date) = '". $month ."' and year(s.date) = '". $year ."' and s.deleted_at is null and s.dealer_id = '". $dealer ."' GROUP BY s.user_sa_id ORDER BY s.user_sa_id ASC\"), CONCAT(\"SELECT sa.name as SA, sa.id as sa_id \", \"FROM transactions s JOIN users sa ON sa.id = s.user_sa_id WHERE month(s.date) = '". $month ."' and year(s.date) = '". $year ."' and s.deleted_at is null and s.dealer_id = '". $dealer ."' GROUP BY s.`user_sa_id` ORDER BY s.`user_sa_id` ASC\"))"); $sa_work_trx = DB::statement("SET @sql = IF(@sql != 's.work_id' ,CONCAT(\"SELECT sa.name as SA, sa.id as sa_id, \", @sql, \"FROM transactions s JOIN users sa ON sa.id = s.user_sa_id WHERE month(s.date) = '". $month ."' and year(s.date) = '". $year ."' and s.deleted_at is null". $dealerFilter ." GROUP BY s.user_sa_id ORDER BY s.user_sa_id ASC\"), CONCAT(\"SELECT sa.name as SA, sa.id as sa_id \", \"FROM transactions s JOIN users sa ON sa.id = s.user_sa_id WHERE month(s.date) = '". $month ."' and year(s.date) = '". $year ."' and s.deleted_at is null". $dealerFilter ." GROUP BY s.`user_sa_id` ORDER BY s.`user_sa_id` ASC\"))");
} }
}else{ }else{
if(isset($request->sa) && $request->sa != 'all') { if(isset($request->sa) && $request->sa != 'all') {
$sa_work_trx = DB::statement("SET @sql = IF(@sql != 's.work_id' ,CONCAT(\"SELECT sa.name as SA, sa.id as sa_id, \", @sql, \"FROM transactions s JOIN users sa ON sa.id = s.user_sa_id WHERE month(s.date) = '". $month ."' and year(s.date) = '". $year ."' and s.deleted_at is null and s.user_sa_id = '". $sa ."' GROUP BY s.user_sa_id ORDER BY s.user_sa_id ASC\"), CONCAT(\"SELECT sa.name as SA, sa.id as user_sa_id \", \"FROM transactions s JOIN dealers d ON d.id = s.user_sa_id WHERE month(s.date) = '". $month ."' and year(s.date) = '". $year ."' and s.deleted_at is null and s.user_sa_id = '". $sa ."' GROUP BY s.`user_sa_id` ORDER BY s.`user_sa_id` ASC\"))"); $sa_work_trx = DB::statement("SET @sql = IF(@sql != 's.work_id' ,CONCAT(\"SELECT sa.name as SA, sa.id as sa_id, \", @sql, \"FROM transactions s JOIN users sa ON sa.id = s.user_sa_id WHERE month(s.date) = '". $month ."' and year(s.date) = '". $year ."' and s.deleted_at is null". $dealerFilter ." and s.user_sa_id = '". $sa ."' GROUP BY s.user_sa_id ORDER BY s.user_sa_id ASC\"), CONCAT(\"SELECT sa.name as SA, sa.id as user_sa_id \", \"FROM transactions s JOIN dealers d ON d.id = s.user_sa_id WHERE month(s.date) = '". $month ."' and year(s.date) = '". $year ."' and s.deleted_at is null". $dealerFilter ." and s.user_sa_id = '". $sa ."' GROUP BY s.`user_sa_id` ORDER BY s.`user_sa_id` ASC\"))");
}else{ }else{
$sa_work_trx = DB::statement("SET @sql = IF(@sql != 's.work_id' ,CONCAT(\"SELECT sa.name as SA, sa.id as sa_id, \", @sql, \"FROM transactions s JOIN users sa ON sa.id = s.user_sa_id WHERE month(s.date) = '". $month ."' and year(s.date) = '". $year ."' and s.deleted_at is null GROUP BY s.user_sa_id ORDER BY s.user_sa_id ASC\"), CONCAT(\"SELECT sa.name as SA, sa.id as user_sa_id \", \"FROM transactions s JOIN dealers d ON d.id = s.user_sa_id WHERE month(s.date) = '". $month ."' and year(s.date) = '". $year ."' and s.deleted_at is null GROUP BY s.`user_sa_id` ORDER BY s.`user_sa_id` ASC\"))"); $sa_work_trx = DB::statement("SET @sql = IF(@sql != 's.work_id' ,CONCAT(\"SELECT sa.name as SA, sa.id as sa_id, \", @sql, \"FROM transactions s JOIN users sa ON sa.id = s.user_sa_id WHERE month(s.date) = '". $month ."' and year(s.date) = '". $year ."' and s.deleted_at is null". $dealerFilter ." GROUP BY s.user_sa_id ORDER BY s.user_sa_id ASC\"), CONCAT(\"SELECT sa.name as SA, sa.id as user_sa_id \", \"FROM transactions s JOIN dealers d ON d.id = s.user_sa_id WHERE month(s.date) = '". $month ."' and year(s.date) = '". $year ."' and s.deleted_at is null". $dealerFilter ." GROUP BY s.`user_sa_id` ORDER BY s.`user_sa_id` ASC\"))");
} }
} }
@@ -218,13 +332,41 @@ class ReportController extends Controller
$request['month'] = date('m'); $request['month'] = date('m');
} }
$works = Work::select('id', 'name')->whereHas('transactions', function($q) use($request) { // Get dealers based on user role
$user = Auth::user();
$role = Role::with(['dealers' => function($query) {
$query->whereNull('dealers.deleted_at'); // Only active dealers
}])->find($user->role_id);
if($role && $this->isAdminRole($role) && $role->dealers->count() == 0) {
$allowedDealers = Dealer::all();
} else if($role) {
$allowedDealers = $role->dealers()->whereNull('dealers.deleted_at')->orderBy('name')->get();
} else {
$allowedDealers = collect();
}
$works = Work::select('id', 'name')->whereHas('transactions', function($q) use($request, $allowedDealers) {
if(isset($request->month)) { if(isset($request->month)) {
$q->whereMonth('date', '=', $request->month); $q->whereMonth('date', '=', $request->month);
} }
// Filter by allowed dealers based on user role
if($allowedDealers->count() > 0) {
$dealerIds = $allowedDealers->pluck('id')->toArray();
$q->whereIn('dealer_id', $dealerIds);
}
if(isset($request->dealer) && $request->dealer != 'all') { if(isset($request->dealer) && $request->dealer != 'all') {
$q->where('dealer_id', '=', $request->dealer); // Validate that the requested dealer is allowed for this user
if($allowedDealers->count() > 0) {
$allowedDealerIds = $allowedDealers->pluck('id')->toArray();
if(in_array($request->dealer, $allowedDealerIds)) {
$q->where('dealer_id', '=', $request->dealer);
}
} else {
$q->where('dealer_id', '=', $request->dealer);
}
} }
if(isset($request->sa) && $request->sa != 'all') { if(isset($request->sa) && $request->sa != 'all') {
@@ -232,7 +374,27 @@ class ReportController extends Controller
} }
})->get(); })->get();
$sas = User::select('id', 'name')->where('role_id', 4)->get(); // Get dealers based on user role
$user = Auth::user();
$role = Role::with(['dealers' => function($query) {
$query->whereNull('dealers.deleted_at'); // Only active dealers
}])->find($user->role_id);
if($role && $this->isAdminRole($role) && $role->dealers->count() == 0) {
$dealer_datas = Dealer::all();
} else if($role) {
$dealer_datas = $role->dealers()->whereNull('dealers.deleted_at')->orderBy('name')->get();
} else {
$dealer_datas = collect();
}
// Get SA users based on dealer access
if($dealer_datas->count() > 0) {
$dealerIds = $dealer_datas->pluck('id')->toArray();
$sas = User::select('id', 'name')->where('role_id', 4)->whereIn('dealer_id', $dealerIds)->get();
} else {
$sas = User::select('id', 'name')->where('role_id', 4)->get();
}
$trxs = []; $trxs = [];
foreach($sas as $key => $sa) { foreach($sas as $key => $sa) {
@@ -243,9 +405,23 @@ class ReportController extends Controller
if(isset($request->month)) { if(isset($request->month)) {
$d = $d->whereMonth('date', '=', $request->month); $d = $d->whereMonth('date', '=', $request->month);
} }
// Filter by allowed dealers based on user role
if($allowedDealers->count() > 0) {
$dealerIds = $allowedDealers->pluck('id')->toArray();
$d = $d->whereIn('dealer_id', $dealerIds);
}
if(isset($request->dealer) && $request->dealer != 'all') { if(isset($request->dealer) && $request->dealer != 'all') {
$d = $d->where('dealer_id', '=', $request->dealer); // Validate that the requested dealer is allowed for this user
if($allowedDealers->count() > 0) {
$allowedDealerIds = $allowedDealers->pluck('id')->toArray();
if(in_array($request->dealer, $allowedDealerIds)) {
$d = $d->where('dealer_id', '=', $request->dealer);
}
} else {
$d = $d->where('dealer_id', '=', $request->dealer);
}
} }
if(isset($request->sa) && $request->sa != 'all') { if(isset($request->sa) && $request->sa != 'all') {
@@ -296,40 +472,80 @@ class ReportController extends Controller
$sa_names = json_encode($sa_names); $sa_names = json_encode($sa_names);
$trx_data = json_encode(array_values($trx_data)); $trx_data = json_encode(array_values($trx_data));
// dd($trx_data);
$work_count = count($works); $work_count = count($works);
$month = $request->month; $month = $request->month;
$dealer_id = $request->dealer; $dealer_id = $request->dealer;
$sa_id = $request->sa; $sa_id = $request->sa;
$dealers = Dealer::all();
$sas = User::where('role_id', 4)->get();
return view('back.report.transaction_sa', compact('sas', 'dealers', 'dealer_id', 'sa_id', 'month', 'trxs', 'works', 'work_count', 'sa_names', 'trx_data')); return view('back.report.transaction_sa', compact('sas', 'dealer_datas', 'dealer_id', 'sa_id', 'month', 'trxs', 'works', 'work_count', 'sa_names', 'trx_data'));
} }
public function sa_work_trx(Request $request) { public function sa_work_trx(Request $request) {
$sa_work_trx = Work::select(DB::raw('works.name AS work_name'), DB::raw("IFNULL(SUM(t.qty), 0) AS qty"), 'works.id AS work_id')->whereHas('transactions', function($q) use($request) { // Get dealers based on user role
$user = Auth::user();
$role = Role::with(['dealers' => function($query) {
$query->whereNull('dealers.deleted_at'); // Only active dealers
}])->find($user->role_id);
if($role && $this->isAdminRole($role) && $role->dealers->count() == 0) {
$allowedDealers = Dealer::all();
} else if($role) {
$allowedDealers = $role->dealers()->whereNull('dealers.deleted_at')->orderBy('name')->get();
} else {
$allowedDealers = collect();
}
$sa_work_trx = Work::select(DB::raw('works.name AS work_name'), DB::raw("IFNULL(SUM(t.qty), 0) AS qty"), 'works.id AS work_id')->whereHas('transactions', function($q) use($request, $allowedDealers) {
if(isset($request->month)) { if(isset($request->month)) {
$q = $q->whereMonth('date', '=', $request->month)->whereYear('date', date('Y')); $q = $q->whereMonth('date', '=', $request->month)->whereYear('date', date('Y'));
} }
// Filter by allowed dealers based on user role
if($allowedDealers->count() > 0) {
$dealerIds = $allowedDealers->pluck('id')->toArray();
$q->whereIn('dealer_id', $dealerIds);
}
if(isset($request->dealer) && $request->dealer != 'all') { if(isset($request->dealer) && $request->dealer != 'all') {
$q = $q->where('dealer_id', '=', $request->dealer); // Validate that the requested dealer is allowed for this user
if($allowedDealers->count() > 0) {
$allowedDealerIds = $allowedDealers->pluck('id')->toArray();
if(in_array($request->dealer, $allowedDealerIds)) {
$q->where('dealer_id', '=', $request->dealer);
}
} else {
$q->where('dealer_id', '=', $request->dealer);
}
} }
if(isset($request->sa_filter) && $request->sa_filter != 'all') { if(isset($request->sa_filter) && $request->sa_filter != 'all') {
$q = $q->where('user_sa_id', '=', $request->sa_filter); $q->where('user_sa_id', '=', $request->sa_filter);
} }
return $q; return $q;
})->leftJoin('transactions AS t', function($q) use($request) { })->leftJoin('transactions AS t', function($q) use($request, $allowedDealers) {
$q->on('t.work_id', '=', 'works.id'); $q->on('t.work_id', '=', 'works.id');
$q->on(DB::raw('MONTH(t.date)'), '=', DB::raw($request->month)); $q->on(DB::raw('MONTH(t.date)'), '=', DB::raw($request->month));
$q->on(DB::raw('YEAR(t.date)'), '=', DB::raw(date('Y'))); $q->on(DB::raw('YEAR(t.date)'), '=', DB::raw(date('Y')));
$q->on('t.user_sa_id', '=', DB::raw($request->sa)); $q->on('t.user_sa_id', '=', DB::raw($request->sa));
// Filter by allowed dealers based on user role
if($allowedDealers->count() > 0) {
$dealerIds = $allowedDealers->pluck('id')->toArray();
$q->whereIn('t.dealer_id', $dealerIds);
}
if(isset($request->dealer) && $request->dealer != 'all') { if(isset($request->dealer) && $request->dealer != 'all') {
$q->on('t.dealer_id', '=', DB::raw($request->dealer)); // Validate that the requested dealer is allowed for this user
if($allowedDealers->count() > 0) {
$allowedDealerIds = $allowedDealers->pluck('id')->toArray();
if(in_array($request->dealer, $allowedDealerIds)) {
$q->on('t.dealer_id', '=', DB::raw($request->dealer));
}
} else {
$q->on('t.dealer_id', '=', DB::raw($request->dealer));
}
} }
if(isset($request->sa_filter) && $request->sa_filter != 'all') { if(isset($request->sa_filter) && $request->sa_filter != 'all') {
$q->on('t.user_sa_id', '=', DB::raw($request->sa_filter)); $q->on('t.user_sa_id', '=', DB::raw($request->sa_filter));
@@ -351,13 +567,41 @@ class ReportController extends Controller
$request['sa'] = 'all'; $request['sa'] = 'all';
} }
$sas = User::where('role_id', 4)->whereHas('sa_transactions', function($q) use($request) { // Get dealers based on user role
$user = Auth::user();
$role = Role::with(['dealers' => function($query) {
$query->whereNull('dealers.deleted_at'); // Only active dealers
}])->find($user->role_id);
if($role && $this->isAdminRole($role) && $role->dealers->count() == 0) {
$allowedDealers = Dealer::all();
} else if($role) {
$allowedDealers = $role->dealers()->whereNull('dealers.deleted_at')->orderBy('name')->get();
} else {
$allowedDealers = collect();
}
$sas = User::where('role_id', 4)->whereHas('sa_transactions', function($q) use($request, $allowedDealers) {
if(isset($request->month)) { if(isset($request->month)) {
$q = $q->whereMonth('date', '=', $request->month)->whereYear('date', date('Y')); $q = $q->whereMonth('date', '=', $request->month)->whereYear('date', date('Y'));
} }
// Filter by allowed dealers based on user role
if($allowedDealers->count() > 0) {
$dealerIds = $allowedDealers->pluck('id')->toArray();
$q->whereIn('dealer_id', $dealerIds);
}
if(isset($request->dealer) && $request->dealer != 'all') { if(isset($request->dealer) && $request->dealer != 'all') {
$q->where('dealer_id', '=', $request->dealer); // Validate that the requested dealer is allowed for this user
if($allowedDealers->count() > 0) {
$allowedDealerIds = $allowedDealers->pluck('id')->toArray();
if(in_array($request->dealer, $allowedDealerIds)) {
$q->where('dealer_id', '=', $request->dealer);
}
} else {
$q->where('dealer_id', '=', $request->dealer);
}
} }
}); });
@@ -383,10 +627,22 @@ class ReportController extends Controller
$request['year'] = date('Y'); $request['year'] = date('Y');
} }
$user = Auth::user();
$role = Role::with(['dealers' => function($query) {
$query->whereNull('dealers.deleted_at'); // Only active dealers
}])->find($user->role_id);
if($role && $this->isAdminRole($role) && $role->dealers->count() == 0) {
$dealer_datas = Dealer::all();
} else if($role) {
$dealer_datas = $role->dealers()->whereNull('dealers.deleted_at')->orderBy('name')->get();
} else {
$dealer_datas = collect();
}
$year = $request->year; $year = $request->year;
$month = $request->month; $month = $request->month;
$dealer = $request->dealer; $dealer = $request->dealer;
$dealer_datas = Dealer::all();
$ajax_url = route('dashboard_data').'?month='.$month.'&year='.$year.'&dealer='.$dealer; $ajax_url = route('dashboard_data').'?month='.$month.'&year='.$year.'&dealer='.$dealer;
return view('dashboard', compact('month', 'ajax_url', 'dealer', 'dealer_datas', 'year')); return view('dashboard', compact('month', 'ajax_url', 'dealer', 'dealer_datas', 'year'));
} }
@@ -396,9 +652,30 @@ class ReportController extends Controller
$menu = Menu::where('link', 'report.transaction')->first(); $menu = Menu::where('link', 'report.transaction')->first();
abort_if(Gate::denies('view', $menu), 403, 'Unauthorized User'); abort_if(Gate::denies('view', $menu), 403, 'Unauthorized User');
$sas = User::where('role_id', 4)->get(); $current_user = Auth::user();
$mechanics = User::where('role_id', 3)->get(); $current_role = Role::with(['dealers' => function($query) {
$dealers = Dealer::all(); $query->whereNull('dealers.deleted_at'); // Only active dealers
}])->find($current_user->role_id);
// Get dealers based on user role
if($current_role && $this->isAdminRole($current_role) && $current_role->dealers->count() == 0) {
$dealers = Dealer::all();
} else if($current_role) {
$dealers = $current_role->dealers()->whereNull('dealers.deleted_at')->orderBy('name')->get();
} else {
$dealers = collect();
}
// Get SA users based on dealer access
if($dealers->count() > 0) {
$dealerIds = $dealers->pluck('id')->toArray();
$sas = User::where('role_id', 4)->whereIn('dealer_id', $dealerIds)->get();
$mechanics = User::where('role_id', 3)->whereIn('dealer_id', $dealerIds)->get();
} else {
$sas = User::where('role_id', 4)->get();
$mechanics = User::where('role_id', 3)->get();
}
$works = Work::all(); $works = Work::all();
return view('back.report.transaction', compact('sas', 'mechanics', 'dealers', 'works')); return view('back.report.transaction', compact('sas', 'mechanics', 'dealers', 'works'));
@@ -410,12 +687,50 @@ class ReportController extends Controller
abort_if(Gate::denies('view', $menu), 403, 'Unauthorized User'); abort_if(Gate::denies('view', $menu), 403, 'Unauthorized User');
if ($request->ajax()) { if ($request->ajax()) {
// Get dealers based on user role
$current_user = Auth::user();
$current_role = Role::with(['dealers' => function($query) {
$query->whereNull('dealers.deleted_at'); // Only active dealers
}])->find($current_user->role_id);
if($current_role && $this->isAdminRole($current_role) && $current_role->dealers->count() == 0) {
$allowedDealers = Dealer::all();
} else if($current_role) {
$allowedDealers = $current_role->dealers()->whereNull('dealers.deleted_at')->orderBy('name')->get();
} else {
$allowedDealers = collect();
}
$data = Transaction::leftJoin('users', 'users.id', '=', 'transactions.user_id') $data = Transaction::leftJoin('users', 'users.id', '=', 'transactions.user_id')
->leftJoin('users as sa', 'sa.id', '=', 'transactions.user_sa_id') ->leftJoin('users as sa', 'sa.id', '=', 'transactions.user_sa_id')
->leftJoin('works as w', 'w.id', '=', 'transactions.work_id') ->leftJoin('works as w', 'w.id', '=', 'transactions.work_id')
->leftJoin('categories as cat', 'cat.id', '=', 'w.category_id') ->leftJoin('categories as cat', 'cat.id', '=', 'w.category_id')
->leftJoin('dealers as d', 'd.id', '=', 'transactions.dealer_id') ->leftJoin('dealers as d', 'd.id', '=', 'transactions.dealer_id')
->select('transactions.id', 'transactions.status', 'transactions.user_id as user_id', 'transactions.user_sa_id as user_sa_id', 'users.name as username', 'sa.name as sa_name', 'cat.name as category_name', 'w.name as workname', 'transactions.qty as qty', 'transactions.date as date', 'transactions.police_number as police_number', 'transactions.warranty as warranty', 'transactions.spk as spk', 'transactions.dealer_id', 'd.name as dealer_name'); ->leftJoin('prechecks as pre', 'pre.transaction_id', '=', 'transactions.id')
->leftJoin('postchecks as post', 'post.transaction_id', '=', 'transactions.id')
->select(
'transactions.id',
'transactions.status',
'users.name as username',
'sa.name as sa_name',
'cat.name as category_name',
'w.name as workname',
'transactions.qty as qty',
'transactions.date as date',
'transactions.police_number as police_number',
'transactions.warranty as warranty',
'transactions.spk as spk',
'd.name as dealer_name',
DB::raw('pre.id as precheck_id'),
DB::raw('post.id as postcheck_id')
);
// Filter by allowed dealers based on user role
if($allowedDealers->count() > 0) {
$dealerIds = $allowedDealers->pluck('id')->toArray();
$data->whereIn('transactions.dealer_id', $dealerIds);
}
if(isset($request->date_start)) { if(isset($request->date_start)) {
$data->where('transactions.date', '>=', $request->date_start); $data->where('transactions.date', '>=', $request->date_start);
@@ -434,29 +749,86 @@ class ReportController extends Controller
} }
if(isset($request->dealer)) { if(isset($request->dealer)) {
$data->where('transactions.dealer_id', $request->dealer); // Validate that the requested dealer is allowed for this user
if($allowedDealers->count() > 0) {
$allowedDealerIds = $allowedDealers->pluck('id')->toArray();
if(in_array($request->dealer, $allowedDealerIds)) {
$data->where('transactions.dealer_id', $request->dealer);
}
} else {
$data->where('transactions.dealer_id', $request->dealer);
}
} }
$data->orderBy('date', 'DESC'); $data->orderBy('date', 'DESC');
return DataTables::of($data)->addIndexColumn() return DataTables::of($data)->addIndexColumn()
->addColumn('action', function($row) use ($menu) { ->addColumn('action', function($row) use ($menu) {
$btn = ''; $btn = '<div class="d-flex justify-content-center align-items-center flex-wrap">';
if($row->status == 1) {
if(Auth::user()->can('delete', $menu)) { // Jika status closed
$btn .= ' <button class="btn btn-danger btn-sm btn-bold" data-action="'. route('report.transaction.destroy', $row->id) .'" id="destroyTransaction'. $row->id .'" onclick="destroyTransaction('. $row->id .')"> Hapus </button>'; if ($row->status == 1) {
if (Gate::allows('delete', $menu)) {
$btn .= '<button class="btn btn-danger btn-sm font-weight-bold mr-2 mt-2"
data-action="'. route('report.transaction.destroy', $row->id) .'"
id="destroyTransaction'. $row->id .'"
onclick="destroyTransaction('. $row->id .')">
Hapus
</button>';
} }
$btn .= '<span class="badge badge-success">Closed</span>';
}else{ // Badge Closed rapi
if(Auth::user()->can('delete', $menu)) { $btn .= '<span class="btn btn-success btn-sm font-weight-bold px-3 py-2 mr-2 mt-2 disabled"
$btn .= '<button class="btn btn-danger btn-sm btn-bold" data-action="'. route('report.transaction.destroy', $row->id) .'" id="destroyTransaction'. $row->id .'" onclick="destroyTransaction('. $row->id .')"> Hapus </button>'; style="pointer-events: none; cursor: default;">
Closed
</span>';
} else {
if (Gate::allows('delete', $menu)) {
$btn .= '<button class="btn btn-danger btn-sm font-weight-bold mr-2 mt-2"
data-action="'. route('report.transaction.destroy', $row->id) .'"
id="destroyTransaction'. $row->id .'"
onclick="destroyTransaction('. $row->id .')">
Hapus
</button>';
} }
if(Auth::user()->can('update', $menu)) { if (Gate::allows('update', $menu)) {
$btn .= '<button class="btn btn-info btn-sm btn-bold" data-url="'. route('report.transaction.edit', $row->id) .'" data-action="'. route('report.transaction.update', $row->id) .'" onclick="editTransaction('. $row->id .')" id="editTransaction'. $row->id .'"> Edit </button> $btn .= '<button class="btn btn-info btn-sm font-weight-bold mr-2 mt-2"
<button class="btn btn-warning btn-sm btn-bold" id="closeTransaction'. $row->id .'" data-url="'. route('report.transaction.close', $row->id) .'" onclick="closeTransaction('. $row->id .')"> Close </button>'; data-url="'. route('report.transaction.edit', $row->id) .'"
data-action="'. route('report.transaction.update', $row->id) .'"
onclick="editTransaction('. $row->id .')"
id="editTransaction'. $row->id .'">
Edit
</button>';
$btn .= '<button class="btn btn-warning btn-sm font-weight-bold mr-2 mt-2"
id="closeTransaction'. $row->id .'"
data-url="'. route('report.transaction.close', $row->id) .'"
onclick="closeTransaction('. $row->id .')">
Close
</button>';
} }
} }
if ($row->precheck_id) {
$btn .= '<button class="btn btn-primary btn-sm font-weight-bold action-print mr-2 mt-2"
data-type="precheck"
data-id="'. $row->id .'"
data-url="'. route('report.transaction.precheck.print', $row->id) .'">
Pre Check
</button>';
}
if ($row->postcheck_id) {
$btn .= '<button class="btn btn-success btn-sm font-weight-bold action-print mr-2 mt-2"
data-type="postcheck"
data-id="'. $row->id .'"
data-url="'. route('report.transaction.postcheck.print', $row->id) .'">
Post Check
</button>';
}
$btn .= '</div>';
return $btn; return $btn;
}) })
->rawColumns(['action']) ->rawColumns(['action'])
@@ -562,4 +934,34 @@ class ReportController extends Controller
return response()->json($response); return response()->json($response);
} }
/**
* Check if role is admin type
*/
private function isAdminRole($role)
{
if (!$role) {
return false;
}
// Define admin role names that should have access to all dealers
$adminRoleNames = [
'admin'
];
// Check if role name contains admin keywords (but not "area")
$roleName = strtolower(trim($role->name));
foreach ($adminRoleNames as $adminName) {
if (strpos($roleName, $adminName) !== false && strpos($roleName, 'area') === false) {
return true;
}
}
// Role with "area" in name should use pivot dealers, not all dealers
if (strpos($roleName, 'area') !== false) {
return false;
}
return false;
}
} }

View File

@@ -0,0 +1,103 @@
<?php
namespace App\Http\Controllers\Reports;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Models\Menu;
use App\Models\Product;
use App\Models\Dealer;
use App\Models\Stock;
use App\Models\StockLog;
use App\Services\StockReportService;
use App\Exports\StockProductsExport;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Log;
use Yajra\DataTables\DataTables;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
use Maatwebsite\Excel\Facades\Excel;
class ReportStockProductsController extends Controller
{
public function index(Request $request)
{
$menu = Menu::where('link','reports.stock-product.index')->first();
abort_if(!Gate::allows('view', $menu), 403);
return view('reports.stock-products');
}
public function getData(Request $request)
{
$menu = Menu::where('link','reports.stock-product.index')->first();
abort_if(!Gate::allows('view', $menu), 403);
if ($request->ajax()) {
$filterDate = $request->get('filter_date');
$stockService = new StockReportService();
$reportData = $stockService->getOptimizedStockReportData($filterDate);
return DataTables::of($reportData['data'])
->addIndexColumn()
->addColumn('product_info', function($row) {
return "<strong>{$row['product_name']}</strong><br><small class='text-muted'>{$row['product_code']}</small>";
})
->addColumn('total_stock', function($row) {
return number_format($row['total_stock'], 2);
})
->rawColumns(['product_info'])
->make(true);
}
return response()->json(['error' => 'Invalid request'], 400);
}
public function getDealers()
{
$stockService = new StockReportService();
$dealers = $stockService->getDealersBasedOnUserRole();
return response()->json($dealers);
}
public function export(Request $request)
{
try {
$menu = Menu::where('link','reports.stock-product.index')->first();
abort_if(!Gate::allows('view', $menu), 403);
$filterDate = $request->get('filter_date');
$stockService = new StockReportService();
$reportData = $stockService->getOptimizedStockReportData($filterDate);
// Validate report data
if (!isset($reportData['data']) || !isset($reportData['dealers'])) {
throw new \Exception('Invalid report data structure');
}
// Debug: Log dealer names to identify problematic characters
Log::info('Export data validation', [
'data_count' => count($reportData['data']),
'dealers_count' => count($reportData['dealers']),
'dealer_names' => $reportData['dealers']->pluck('name')->toArray(),
'first_data_row' => isset($reportData['data'][0]) ? array_keys($reportData['data'][0]) : []
]);
$fileName = 'laporan_stok_produk_' . ($filterDate ?: date('Y-m-d')) . '.xlsx';
return Excel::download(new StockProductsExport($reportData), $fileName);
} catch (\Exception $e) {
Log::error('Export error: ' . $e->getMessage(), [
'filter_date' => $request->get('filter_date'),
'trace' => $e->getTraceAsString()
]);
return back()->with('error', 'Gagal mengexport data: ' . $e->getMessage());
}
}
}

View File

@@ -0,0 +1,329 @@
<?php
namespace App\Http\Controllers\Reports;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Models\Menu;
use App\Models\Role;
use App\Services\TechnicianReportService;
use App\Exports\TechnicianReportExport;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Log;
use Maatwebsite\Excel\Facades\Excel;
use Illuminate\Support\Facades\DB; // Added DB facade
use App\Models\Dealer; // Added Dealer model
class ReportTechniciansController extends Controller
{
protected $technicianReportService;
public function __construct(TechnicianReportService $technicianReportService)
{
$this->technicianReportService = $technicianReportService;
}
public function index(Request $request)
{
$menu = Menu::where('link','reports.technician.index')->first();
abort_if(!Gate::allows('view', $menu), 403);
return view('reports.technician');
}
/**
* Get dealers for filter dropdown
*/
public function getDealers()
{
try {
// Get current authenticated user
$user = auth()->user();
if (!$user) {
Log::info('Controller: No authenticated user found');
return response()->json([
'status' => 'error',
'message' => 'User tidak terautentikasi'
], 401);
}
Log::info('Controller: Getting dealers for user:', [
'user_id' => $user->id,
'user_name' => $user->name,
'user_role_id' => $user->role_id,
'user_dealer_id' => $user->dealer_id
]);
$dealers = $this->technicianReportService->getDealers();
$defaultDealer = $this->technicianReportService->getDefaultDealer();
Log::info('Controller: Service returned dealers:', [
'dealers_count' => $dealers->count(),
'dealers' => $dealers->toArray(),
'default_dealer' => $defaultDealer ? $defaultDealer->toArray() : null,
'default_dealer_id' => $defaultDealer ? $defaultDealer->id : null
]);
// Check if default dealer exists in dealers list
if ($defaultDealer && $dealers->count() > 0) {
$defaultDealerExists = $dealers->contains('id', $defaultDealer->id);
Log::info('Controller: Default dealer validation:', [
'default_dealer_id' => $defaultDealer->id,
'default_dealer_exists_in_list' => $defaultDealerExists,
'available_dealer_ids' => $dealers->pluck('id')->toArray()
]);
// If default dealer doesn't exist in list, use first dealer from list
if (!$defaultDealerExists) {
Log::info('Controller: Default dealer not in list, using first dealer from list');
$defaultDealer = $dealers->first();
Log::info('Controller: New default dealer:', $defaultDealer ? $defaultDealer->toArray() : null);
}
} else if ($defaultDealer === null && $dealers->count() > 0) {
// Admin without default dealer - no need to set default
Log::info('Controller: Admin without default dealer, no default will be set');
}
return response()->json([
'status' => 'success',
'data' => $dealers,
'default_dealer' => $defaultDealer ? $defaultDealer->id : null
]);
} catch (\Exception $e) {
Log::error('Controller: Error getting dealers: ' . $e->getMessage(), [
'trace' => $e->getTraceAsString()
]);
return response()->json([
'status' => 'error',
'message' => 'Gagal mengambil data dealer: ' . $e->getMessage()
], 500);
}
}
/**
* Get technician report data for DataTable
*/
public function getData(Request $request)
{
try {
$dealerId = $request->input('dealer_id');
$startDate = $request->input('start_date');
$endDate = $request->input('end_date');
// Get current authenticated user
$user = auth()->user();
if (!$user) {
return response()->json([
'status' => 'error',
'message' => 'User tidak terautentikasi'
], 401);
}
Log::info('Requesting technician report data:', [
'dealer_id' => $dealerId,
'start_date' => $startDate,
'end_date' => $endDate,
'user_id' => $user->id,
'user_role_id' => $user->role_id,
'user_dealer_id' => $user->dealer_id
]);
$reportData = $this->technicianReportService->getTechnicianReportData(
$dealerId,
$startDate,
$endDate
);
Log::info('Technician report data response:', [
'data_count' => count($reportData['data']),
'mechanics_count' => $reportData['mechanics']->count(),
'works_count' => $reportData['works']->count(),
'mechanics' => $reportData['mechanics']->map(function($mechanic) {
return [
'id' => $mechanic->id,
'name' => $mechanic->name,
'role_id' => $mechanic->role_id
];
})
]);
return response()->json([
'status' => 'success',
'data' => $reportData['data'],
'mechanics' => $reportData['mechanics'],
'works' => $reportData['works']
]);
} catch (\Exception $e) {
Log::error('Error getting technician report data: ' . $e->getMessage(), [
'dealer_id' => $request->input('dealer_id'),
'start_date' => $request->input('start_date'),
'end_date' => $request->input('end_date'),
'trace' => $e->getTraceAsString()
]);
return response()->json([
'status' => 'error',
'message' => 'Gagal mengambil data laporan teknisi: ' . $e->getMessage()
], 500);
}
}
/**
* Get technician report data for Yajra DataTable
*/
public function getDataTable(Request $request)
{
try {
$dealerId = $request->input('dealer_id');
$startDate = $request->input('start_date');
$endDate = $request->input('end_date');
// Get current authenticated user
$user = auth()->user();
if (!$user) {
return response()->json([
'error' => 'User tidak terautentikasi'
], 401);
}
Log::info('Requesting technician report data for DataTable:', [
'dealer_id' => $dealerId,
'start_date' => $startDate,
'end_date' => $endDate,
'user_id' => $user->id,
'user_role_id' => $user->role_id,
'user_dealer_id' => $user->dealer_id
]);
$reportData = $this->technicianReportService->getTechnicianReportDataForDataTable(
$dealerId,
$startDate,
$endDate
);
return $reportData;
} catch (\Exception $e) {
Log::error('Error getting technician report data for DataTable: ' . $e->getMessage(), [
'dealer_id' => $request->input('dealer_id'),
'start_date' => $request->input('start_date'),
'end_date' => $request->input('end_date'),
'trace' => $e->getTraceAsString()
]);
return response()->json([
'error' => 'Gagal mengambil data laporan teknisi: ' . $e->getMessage()
], 500);
}
}
/**
* Export technician report to Excel
*/
public function export(Request $request)
{
try {
$dealerId = $request->input('dealer_id');
$startDate = $request->input('start_date');
$endDate = $request->input('end_date');
// Get current authenticated user
$user = auth()->user();
if (!$user) {
return response()->json([
'status' => 'error',
'message' => 'User tidak terautentikasi'
], 401);
}
Log::info('Exporting technician report', [
'dealer_id' => $dealerId,
'start_date' => $startDate,
'end_date' => $endDate,
'user_id' => $user->id,
'user_role_id' => $user->role_id,
'user_dealer_id' => $user->dealer_id
]);
// Validate dealer access for export
if ($dealerId) {
// User is trying to export specific dealer
if ($user->dealer_id) {
// User has specific dealer_id, check if they can access the requested dealer
if ($user->dealer_id != $dealerId) {
if ($user->role_id) {
$role = \App\Models\Role::with('dealers')->find($user->role_id);
if (!$role || !$role->hasDealer($dealerId)) {
return response()->json([
'status' => 'error',
'message' => 'Anda tidak memiliki akses untuk export data dealer ini'
], 403);
}
} else {
return response()->json([
'status' => 'error',
'message' => 'Anda tidak memiliki akses untuk export data dealer ini'
], 403);
}
}
} else if ($user->role_id) {
// User has role, check if they can access the requested dealer
$role = \App\Models\Role::with('dealers')->find($user->role_id);
if (!$role || !$role->hasDealer($dealerId)) {
return response()->json([
'status' => 'error',
'message' => 'Anda tidak memiliki akses untuk export data dealer ini'
], 403);
}
}
} else {
// User is trying to export "Semua Dealer" - check if they have permission
if ($user->role_id) {
$role = \App\Models\Role::with('dealers')->find($user->role_id);
if ($role) {
// Check if role is admin type
$technicianReportService = new \App\Services\TechnicianReportService();
if ($technicianReportService->isAdminRole($role)) {
// Admin can export all dealers
Log::info('Admin user exporting all dealers');
} else {
// Non-admin with pivot dealers - can only export pivot dealers
if ($role->dealers->count() > 0) {
Log::info('User with pivot dealers exporting pivot dealers only');
} else {
return response()->json([
'status' => 'error',
'message' => 'Anda tidak memiliki akses untuk export data semua dealer'
], 403);
}
}
}
} else if ($user->dealer_id) {
// User with specific dealer_id cannot export all dealers
return response()->json([
'status' => 'error',
'message' => 'Anda hanya dapat export data dealer Anda sendiri'
], 403);
}
}
return Excel::download(new TechnicianReportExport($dealerId, $startDate, $endDate), 'laporan_teknisi_' . date('Y-m-d') . '.xlsx');
} catch (\Exception $e) {
Log::error('Error exporting technician report: ' . $e->getMessage(), [
'dealer_id' => $request->input('dealer_id'),
'start_date' => $request->input('start_date'),
'end_date' => $request->input('end_date')
]);
return response()->json([
'status' => 'error',
'message' => 'Gagal export laporan: ' . $e->getMessage()
], 500);
}
}
}

38
app/Http/Controllers/RolePrivilegeController.php Normal file → Executable file
View File

@@ -6,6 +6,7 @@ use App\Models\Menu;
use App\Models\Privilege; use App\Models\Privilege;
use App\Models\Role; use App\Models\Role;
use App\Models\User; use App\Models\User;
use App\Models\Dealer;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Gate;
@@ -14,10 +15,11 @@ class RolePrivilegeController extends Controller
public function index() { public function index() {
$menu = Menu::where('link', 'roleprivileges.index')->first(); $menu = Menu::where('link', 'roleprivileges.index')->first();
abort_if(Gate::denies('view', $menu), 403, 'Unauthorized User'); abort_if(Gate::denies('view', $menu), 403, 'Unauthorized User');
$roles = Role::all(); $roles = Role::with('dealers')->get();
$menus = Menu::all(); $menus = Menu::all();
$users = User::all(); $users = User::all();
return view('back.roleprivileges', compact('roles', 'users', 'menus')); $dealers = Dealer::all();
return view('back.roleprivileges', compact('roles', 'users', 'menus', 'dealers'));
} }
public function store(Request $request) { public function store(Request $request) {
@@ -117,4 +119,36 @@ class RolePrivilegeController extends Controller
User::where('role_id', $id)->update(['role_id' => 0]); User::where('role_id', $id)->update(['role_id' => 0]);
return redirect()->back()->with('success', 'Berhasil Hapus Role'); return redirect()->back()->with('success', 'Berhasil Hapus Role');
} }
public function assignDealer(Request $request, $id) {
$menu = Menu::where('link', 'roleprivileges.index')->first();
abort_if(Gate::denies('create', $menu), 403, 'Unauthorized User');
$request->validate([
'dealers' => 'required|array',
'dealers.*' => 'exists:dealers,id'
]);
$role = Role::findOrFail($id);
// Sync dealers (this will replace existing assignments)
$role->dealers()->sync($request->dealers);
return response()->json([
'success' => true,
'message' => 'Berhasil assign dealer ke role'
]);
}
public function getAssignedDealers($id) {
$menu = Menu::where('link', 'roleprivileges.index')->first();
abort_if(Gate::denies('view', $menu), 403, 'Unauthorized User');
$role = Role::findOrFail($id);
$assignedDealers = $role->dealers()->pluck('dealers.id')->toArray();
return response()->json([
'assignedDealers' => $assignedDealers
]);
}
} }

797
app/Http/Controllers/TransactionController.php Normal file → Executable file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,277 @@
<?php
namespace App\Http\Controllers\Transactions;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Models\Postcheck;
use App\Models\Transaction;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Log;
class PostchecksController extends Controller
{
public function create(Transaction $transaction)
{
$acConditions = Postcheck::getAcConditionOptions();
$blowerConditions = Postcheck::getBlowerConditionOptions();
$evaporatorConditions = Postcheck::getEvaporatorConditionOptions();
$compressorConditions = Postcheck::getCompressorConditionOptions();
return view('transaction.postchecks.create', compact(
'transaction',
'acConditions',
'blowerConditions',
'evaporatorConditions',
'compressorConditions'
));
}
public function store(Request $request, Transaction $transaction)
{
$request->validate([
'kilometer' => 'required|numeric|min:0',
'pressure_high' => 'required|numeric|min:0',
'pressure_low' => 'nullable|numeric|min:0',
'cabin_temperature' => 'nullable|numeric',
'cabin_temperature_image' => 'nullable|image|mimes:jpeg,png,jpg|max:20480',
'ac_condition' => 'nullable|in:' . implode(',', Postcheck::getAcConditionOptions()),
'ac_image' => 'nullable|image|mimes:jpeg,png,jpg|max:20480',
'blower_condition' => 'nullable|in:' . implode(',', Postcheck::getBlowerConditionOptions()),
'blower_image' => 'nullable|image|mimes:jpeg,png,jpg|max:20480',
'evaporator_condition' => 'nullable|in:' . implode(',', Postcheck::getEvaporatorConditionOptions()),
'evaporator_image' => 'nullable|image|mimes:jpeg,png,jpg|max:20480',
'compressor_condition' => 'nullable|in:' . implode(',', Postcheck::getCompressorConditionOptions()),
'postcheck_notes' => 'nullable|string',
'front_image' => 'required|image|mimes:jpeg,png,jpg|max:20480',
]);
$data = [
'transaction_id' => $transaction->id,
'postcheck_by' => auth()->id(),
'postcheck_at' => now(),
'police_number' => $transaction->police_number,
'spk_number' => $transaction->spk,
'kilometer' => $request->kilometer,
'pressure_high' => $request->pressure_high,
'pressure_low' => $request->pressure_low,
'cabin_temperature' => $request->cabin_temperature,
'ac_condition' => $request->ac_condition,
'blower_condition' => $request->blower_condition,
'evaporator_condition' => $request->evaporator_condition,
'compressor_condition' => $request->compressor_condition,
'postcheck_notes' => $request->postcheck_notes,
];
// Handle file uploads securely
$imageFields = [
'front_image', 'cabin_temperature_image', 'ac_image',
'blower_image', 'evaporator_image'
];
foreach ($imageFields as $field) {
$storedPath = $this->processImageUpload($request, $field, $transaction);
if ($storedPath) {
$data[$field] = $storedPath;
}
}
try {
Postcheck::create($data);
return redirect()->route('transaction')->with('success', 'Postcheck berhasil disimpan');
} catch (\Exception $e) {
Log::error('Postcheck creation failed: ' . $e->getMessage());
return back()->withErrors(['error' => 'Gagal menyimpan data postcheck. Silakan coba lagi.']);
}
}
public function edit(Transaction $transaction, Postcheck $postcheck)
{
$acConditions = Postcheck::getAcConditionOptions();
$blowerConditions = Postcheck::getBlowerConditionOptions();
$evaporatorConditions = Postcheck::getEvaporatorConditionOptions();
$compressorConditions = Postcheck::getCompressorConditionOptions();
return view('transaction.postchecks.edit', compact(
'transaction',
'postcheck',
'acConditions',
'blowerConditions',
'evaporatorConditions',
'compressorConditions'
));
}
public function update(Request $request, Transaction $transaction, Postcheck $postcheck)
{
$request->validate([
'kilometer' => 'required|numeric|min:0',
'pressure_high' => 'required|numeric|min:0',
'pressure_low' => 'nullable|numeric|min:0',
'cabin_temperature' => 'nullable|numeric',
'cabin_temperature_image' => 'nullable|image|mimes:jpeg,png,jpg|max:20480',
'ac_condition' => 'nullable|in:' . implode(',', Postcheck::getAcConditionOptions()),
'ac_image' => 'nullable|image|mimes:jpeg,png,jpg|max:20480',
'blower_condition' => 'nullable|in:' . implode(',', Postcheck::getBlowerConditionOptions()),
'blower_image' => 'nullable|image|mimes:jpeg,png,jpg|max:20480',
'evaporator_condition' => 'nullable|in:' . implode(',', Postcheck::getEvaporatorConditionOptions()),
'evaporator_image' => 'nullable|image|mimes:jpeg,png,jpg|max:20480',
'compressor_condition' => 'nullable|in:' . implode(',', Postcheck::getCompressorConditionOptions()),
'postcheck_notes' => 'nullable|string',
'front_image' => 'nullable|image|mimes:jpeg,png,jpg|max:20480',
]);
$updateData = [
'kilometer' => $request->kilometer,
'pressure_high' => $request->pressure_high,
'pressure_low' => $request->pressure_low,
'cabin_temperature' => $request->cabin_temperature,
'ac_condition' => $request->ac_condition,
'blower_condition' => $request->blower_condition,
'evaporator_condition' => $request->evaporator_condition,
'compressor_condition' => $request->compressor_condition,
'postcheck_notes' => $request->postcheck_notes,
];
$imageFields = [
'front_image', 'cabin_temperature_image', 'ac_image',
'blower_image', 'evaporator_image'
];
foreach ($imageFields as $field) {
$newPath = $this->processImageUpload($request, $field, $transaction);
if ($newPath) {
// delete old file if exists
if ($postcheck->{$field}) {
$this->deleteIfExists($postcheck->{$field});
}
$updateData[$field] = $newPath;
}
}
try {
$postcheck->update($updateData);
return redirect()->route('transaction')->with('success', 'Postcheck berhasil diperbarui');
} catch (\Exception $e) {
Log::error('Postcheck update failed: ' . $e->getMessage());
return back()->withErrors(['error' => 'Gagal memperbarui data postcheck. Silakan coba lagi.']);
}
}
public function print($transaction_id)
{
try {
$postcheck = Postcheck::where('transaction_id', $transaction_id)->firstOrFail();
return view('transaction.postchecks.print', compact('postcheck'));
} catch (\Exception $e) {
Log::error('Error printing postcheck: ' . $e->getMessage());
return back()->with('error', 'Gagal membuka halaman print postcheck.');
}
}
/**
* Ensure the base storage directory exists
*/
private function ensureStorageDirectoryExists()
{
$storagePath = storage_path('app/public');
if (!is_dir($storagePath)) {
if (!mkdir($storagePath, 0755, true)) {
Log::error('Failed to create storage directory: ' . $storagePath);
throw new \Exception('Cannot create storage directory: ' . $storagePath . '. Please run: php fix_permissions.php or manually create the directory.');
}
Log::info('Created storage directory: ' . $storagePath);
}
// Check if directory is writable
if (!is_writable($storagePath)) {
Log::error('Storage directory is not writable: ' . $storagePath);
throw new \Exception(
'Storage directory is not writable: ' . $storagePath . '. ' .
'Please run one of these commands from your project root: ' .
'1) php fix_permissions.php ' .
'2) chmod -R 775 storage/ ' .
'3) mkdir -p storage/app/public/transactions/{transaction_id}/postcheck'
);
}
// Check if we can create subdirectories
$testDir = $storagePath . '/test_' . time();
if (!mkdir($testDir, 0755, true)) {
Log::error('Cannot create subdirectories in storage: ' . $storagePath);
throw new \Exception(
'Cannot create subdirectories in storage. ' .
'Please check permissions and run: php fix_permissions.php'
);
}
// Clean up test directory
rmdir($testDir);
Log::info('Storage directory is properly configured: ' . $storagePath);
}
/**
* Securely process image upload to prevent RCE.
* - Only allows jpeg and png
* - Generates safe filename
* - Validates actual image content using getimagesize
*/
private function processImageUpload(Request $request, string $field, Transaction $transaction): ?string
{
if (!($request->hasFile($field) && $request->file($field)->isValid())) {
return null;
}
$file = $request->file($field);
// Double-check mime type from PHP, disallow svg/gif
$allowedMimes = ['image/jpeg' => 'jpg', 'image/png' => 'png'];
$mime = $file->getMimeType();
if (!array_key_exists($mime, $allowedMimes)) {
throw new \RuntimeException('Tipe file tidak diperbolehkan');
}
// Verify it's a real image by reading dimensions
$imageInfo = @getimagesize($file->getRealPath());
if ($imageInfo === false) {
throw new \RuntimeException('File bukan gambar yang valid');
}
// Prepare directory
$directory = 'transactions/' . $transaction->id . '/postcheck';
$this->ensureStorageDirectoryExists();
if (!Storage::disk('public')->exists('transactions')) {
Storage::disk('public')->makeDirectory('transactions', 0755, true);
}
if (!Storage::disk('public')->exists('transactions/' . $transaction->id)) {
Storage::disk('public')->makeDirectory('transactions/' . $transaction->id, 0755, true);
}
if (!Storage::disk('public')->exists($directory)) {
Storage::disk('public')->makeDirectory($directory, 0755, true);
}
// Safe filename
$ext = $allowedMimes[$mime];
$filename = time() . '_' . bin2hex(random_bytes(6)) . '_' . $transaction->id . '_' . $field . '.' . $ext;
// Store
$path = $file->storeAs($directory, $filename, 'public');
Log::info('Secure image stored', ['field' => $field, 'path' => $path]);
return $path;
}
/**
* Delete a file from public storage if it exists
*/
private function deleteIfExists(string $path): void
{
try {
if ($path && Storage::disk('public')->exists($path)) {
Storage::disk('public')->delete($path);
}
} catch (\Throwable $e) {
Log::warning('Failed to delete old image', ['path' => $path, 'error' => $e->getMessage()]);
}
}
}

View File

@@ -0,0 +1,479 @@
<?php
namespace App\Http\Controllers\Transactions;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Models\Precheck;
use App\Models\Transaction;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Log;
class PrechecksController extends Controller
{
public function create(Transaction $transaction)
{
$acConditions = Precheck::getAcConditionOptions();
$blowerConditions = Precheck::getBlowerConditionOptions();
$evaporatorConditions = Precheck::getEvaporatorConditionOptions();
$compressorConditions = Precheck::getCompressorConditionOptions();
return view('transaction.prechecks.create', compact(
'transaction',
'acConditions',
'blowerConditions',
'evaporatorConditions',
'compressorConditions'
));
}
public function store(Request $request, Transaction $transaction)
{
$request->validate([
'kilometer' => 'required|numeric|min:0',
'pressure_high' => 'required|numeric|min:0',
'pressure_low' => 'nullable|numeric|min:0',
'cabin_temperature' => 'nullable|numeric',
'cabin_temperature_image' => 'nullable|image|mimes:jpeg,png,jpg|max:20480',
'ac_condition' => 'nullable|in:' . implode(',', Precheck::getAcConditionOptions()),
'ac_image' => 'nullable|image|mimes:jpeg,png,jpg|max:20480',
'blower_condition' => 'nullable|in:' . implode(',', Precheck::getBlowerConditionOptions()),
'blower_image' => 'nullable|image|mimes:jpeg,png,jpg|max:20480',
'evaporator_condition' => 'nullable|in:' . implode(',', Precheck::getEvaporatorConditionOptions()),
'evaporator_image' => 'nullable|image|mimes:jpeg,png,jpg|max:20480',
'compressor_condition' => 'nullable|in:' . implode(',', Precheck::getCompressorConditionOptions()),
'precheck_notes' => 'nullable|string',
'front_image' => 'required|image|mimes:jpeg,png,jpg|max:20480',
]);
$data = [
'transaction_id' => $transaction->id,
'precheck_by' => auth()->id(),
'precheck_at' => now(),
'police_number' => $transaction->police_number,
'spk_number' => $transaction->spk,
'kilometer' => $request->kilometer,
'pressure_high' => $request->pressure_high,
'pressure_low' => $request->pressure_low,
'cabin_temperature' => $request->cabin_temperature,
'ac_condition' => $request->ac_condition,
'blower_condition' => $request->blower_condition,
'evaporator_condition' => $request->evaporator_condition,
'compressor_condition' => $request->compressor_condition,
'precheck_notes' => $request->precheck_notes,
];
// Handle file uploads
$imageFields = [
'front_image', 'cabin_temperature_image', 'ac_image',
'blower_image', 'evaporator_image'
];
foreach ($imageFields as $field) {
if ($request->hasFile($field) && $request->file($field)->isValid()) {
try {
$file = $request->file($field);
// Enhanced security validation
if (!$this->isValidImageFile($file)) {
return back()->withErrors(['error' => 'File tidak valid atau berbahaya: ' . $field]);
}
// Generate unique filename with transaction ID
$filename = time() . '_' . uniqid() . '_' . $transaction->id . '_' . $field . '.' . $file->getClientOriginalExtension();
// Create directory path: transactions/{transaction_id}/precheck/
$directory = 'transactions/' . $transaction->id . '/precheck';
// Ensure base storage directory exists
$this->ensureStorageDirectoryExists();
// Ensure transactions directory exists
if (!Storage::disk('public')->exists('transactions')) {
Storage::disk('public')->makeDirectory('transactions', 0755, true);
Log::info('Created transactions directory');
}
// Ensure transaction ID directory exists
$transactionDir = 'transactions/' . $transaction->id;
if (!Storage::disk('public')->exists($transactionDir)) {
Storage::disk('public')->makeDirectory($transactionDir, 0755, true);
Log::info('Created transaction directory: ' . $transactionDir);
}
// Ensure precheck directory exists
if (!Storage::disk('public')->exists($directory)) {
Storage::disk('public')->makeDirectory($directory, 0755, true);
Log::info('Created precheck directory: ' . $directory);
}
// Store file in organized directory structure
$path = $file->storeAs($directory, $filename, 'public');
// Store file path
$data[$field] = $path;
// Store metadata
$data[$field . '_metadata'] = [
'original_name' => $file->getClientOriginalName(),
'size' => $file->getSize(),
'mime_type' => $file->getMimeType(),
'uploaded_at' => now()->toISOString(),
'transaction_id' => $transaction->id,
'filename' => $filename,
];
Log::info('File uploaded successfully: ' . $path);
} catch (\Exception $e) {
// Log error for debugging
Log::error('File upload failed: ' . $e->getMessage(), [
'field' => $field,
'file' => $file->getClientOriginalName(),
'transaction_id' => $transaction->id,
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString()
]);
return back()->withErrors(['error' => 'Gagal mengupload file: ' . $field . '. Error: ' . $e->getMessage()]);
}
}
}
try {
Precheck::create($data);
return redirect()->route('transaction')->with('success', 'Precheck berhasil disimpan');
} catch (\Exception $e) {
Log::error('Precheck creation failed: ' . $e->getMessage());
return back()->withErrors(['error' => 'Gagal menyimpan data precheck. Silakan coba lagi.']);
}
}
public function edit(Transaction $transaction, Precheck $precheck)
{
$acConditions = Precheck::getAcConditionOptions();
$blowerConditions = Precheck::getBlowerConditionOptions();
$evaporatorConditions = Precheck::getEvaporatorConditionOptions();
$compressorConditions = Precheck::getCompressorConditionOptions();
return view('transaction.prechecks.edit', compact(
'transaction',
'precheck',
'acConditions',
'blowerConditions',
'evaporatorConditions',
'compressorConditions'
));
}
public function update(Request $request, Transaction $transaction, Precheck $precheck)
{
$request->validate([
'kilometer' => 'required|numeric|min:0',
'pressure_high' => 'required|numeric|min:0',
'pressure_low' => 'nullable|numeric|min:0',
'cabin_temperature' => 'nullable|numeric',
'cabin_temperature_image' => 'nullable|image|mimes:jpeg,png,jpg|max:20480',
'ac_condition' => 'nullable|in:' . implode(',', Precheck::getAcConditionOptions()),
'ac_image' => 'nullable|image|mimes:jpeg,png,jpg|max:20480',
'blower_condition' => 'nullable|in:' . implode(',', Precheck::getBlowerConditionOptions()),
'blower_image' => 'nullable|image|mimes:jpeg,png,jpg|max:20480',
'evaporator_condition' => 'nullable|in:' . implode(',', Precheck::getEvaporatorConditionOptions()),
'evaporator_image' => 'nullable|image|mimes:jpeg,png,jpg|max:20480',
'compressor_condition' => 'nullable|in:' . implode(',', Precheck::getCompressorConditionOptions()),
'precheck_notes' => 'nullable|string',
'front_image' => 'nullable|image|mimes:jpeg,png,jpg|max:20480',
]);
$data = [
'kilometer' => $request->kilometer,
'pressure_high' => $request->pressure_high,
'pressure_low' => $request->pressure_low,
'cabin_temperature' => $request->cabin_temperature,
'ac_condition' => $request->ac_condition,
'blower_condition' => $request->blower_condition,
'evaporator_condition' => $request->evaporator_condition,
'compressor_condition' => $request->compressor_condition,
'precheck_notes' => $request->precheck_notes,
];
// Handle file uploads with security validation
$imageFields = [
'front_image', 'cabin_temperature_image', 'ac_image',
'blower_image', 'evaporator_image'
];
foreach ($imageFields as $field) {
if ($request->hasFile($field) && $request->file($field)->isValid()) {
try {
$file = $request->file($field);
// Enhanced security validation
if (!$this->isValidImageFile($file)) {
return back()->withErrors(['error' => 'File tidak valid atau berbahaya: ' . $field]);
}
// Generate unique filename with transaction ID
$filename = time() . '_' . uniqid() . '_' . $transaction->id . '_' . $field . '.' . $file->getClientOriginalExtension();
// Create directory path: transactions/{transaction_id}/precheck/
$directory = 'transactions/' . $transaction->id . '/precheck';
// Ensure base storage directory exists
$this->ensureStorageDirectoryExists();
// Ensure transactions directory exists
if (!Storage::disk('public')->exists('transactions')) {
Storage::disk('public')->makeDirectory('transactions', 0755, true);
Log::info('Created transactions directory');
}
// Ensure transaction ID directory exists
$transactionDir = 'transactions/' . $transaction->id;
if (!Storage::disk('public')->exists($transactionDir)) {
Storage::disk('public')->makeDirectory($transactionDir, 0755, true);
Log::info('Created transaction directory: ' . $transactionDir);
}
// Ensure precheck directory exists
if (!Storage::disk('public')->exists($directory)) {
Storage::disk('public')->makeDirectory($directory, 0755, true);
Log::info('Created precheck directory: ' . $directory);
}
// Delete old file if exists
if ($precheck->$field && Storage::disk('public')->exists($precheck->$field)) {
Storage::disk('public')->delete($precheck->$field);
}
// Store file in organized directory structure
$path = $file->storeAs($directory, $filename, 'public');
// Store file path
$data[$field] = $path;
// Store metadata
$data[$field . '_metadata'] = [
'original_name' => $file->getClientOriginalName(),
'size' => $file->getSize(),
'mime_type' => $file->getMimeType(),
'uploaded_at' => now()->toISOString(),
'transaction_id' => $transaction->id,
'filename' => $filename,
];
Log::info('File uploaded successfully: ' . $path);
} catch (\Exception $e) {
// Log error for debugging
Log::error('File upload failed: ' . $e->getMessage(), [
'field' => $field,
'file' => $file->getClientOriginalName(),
'transaction_id' => $transaction->id,
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString()
]);
return back()->withErrors(['error' => 'Gagal mengupload file: ' . $field . '. Error: ' . $e->getMessage()]);
}
}
}
try {
$precheck->update($data);
return redirect()->route('transaction')->with('success', 'Precheck berhasil diperbarui');
} catch (\Exception $e) {
Log::error('Precheck update failed: ' . $e->getMessage());
return back()->withErrors(['error' => 'Gagal memperbarui data precheck. Silakan coba lagi.']);
}
}
public function print($transaction_id)
{
try {
$precheck = Precheck::where('transaction_id', $transaction_id)->firstOrFail();
return view('transaction.prechecks.print', compact('precheck'));
} catch (\Exception $e) {
Log::error('Error printing precheck: ' . $e->getMessage());
return back()->with('error', 'Gagal membuka halaman print precheck.');
}
}
/**
* Ensure the base storage directory exists
*/
private function ensureStorageDirectoryExists()
{
$storagePath = storage_path('app/public');
if (!is_dir($storagePath)) {
if (!mkdir($storagePath, 0755, true)) {
Log::error('Failed to create storage directory: ' . $storagePath);
throw new \Exception('Cannot create storage directory: ' . $storagePath . '. Please run: php fix_permissions.php or manually create the directory.');
}
Log::info('Created storage directory: ' . $storagePath);
}
// Check if directory is writable
if (!is_writable($storagePath)) {
Log::error('Storage directory is not writable: ' . $storagePath);
throw new \Exception(
'Storage directory is not writable: ' . $storagePath . '. ' .
'Please run one of these commands from your project root: ' .
'1) php fix_permissions.php ' .
'2) chmod -R 775 storage/ ' .
'3) mkdir -p storage/app/public/transactions/{transaction_id}/precheck'
);
}
// Check if we can create subdirectories
$testDir = $storagePath . '/test_' . time();
if (!mkdir($testDir, 0755, true)) {
Log::error('Cannot create subdirectories in storage: ' . $storagePath);
throw new \Exception(
'Cannot create subdirectories in storage. ' .
'Please check permissions and run: php fix_permissions.php'
);
}
// Clean up test directory
rmdir($testDir);
Log::info('Storage directory is properly configured: ' . $storagePath);
}
/**
* Enhanced security validation for image files to prevent RCE attacks
*
* @param \Illuminate\Http\UploadedFile $file
* @return bool
*/
private function isValidImageFile($file)
{
try {
// 1. Check file extension (whitelist approach)
$allowedExtensions = ['jpg', 'jpeg', 'png'];
$extension = strtolower($file->getClientOriginalExtension());
if (!in_array($extension, $allowedExtensions)) {
Log::warning('Invalid file extension: ' . $extension, [
'filename' => $file->getClientOriginalName(),
'user_id' => auth()->id()
]);
return false;
}
// 2. Check MIME type
$allowedMimeTypes = [
'image/jpeg',
'image/jpg',
'image/png'
];
$mimeType = $file->getMimeType();
if (!in_array($mimeType, $allowedMimeTypes)) {
Log::warning('Invalid MIME type: ' . $mimeType, [
'filename' => $file->getClientOriginalName(),
'user_id' => auth()->id()
]);
return false;
}
// 3. Verify file is actually an image using getimagesize
$imageInfo = @getimagesize($file->getPathname());
if ($imageInfo === false) {
Log::warning('File is not a valid image: ' . $file->getClientOriginalName(), [
'user_id' => auth()->id()
]);
return false;
}
// 4. Check image dimensions (prevent extremely large images)
$maxWidth = 5000;
$maxHeight = 5000;
if ($imageInfo[0] > $maxWidth || $imageInfo[1] > $maxHeight) {
Log::warning('Image dimensions too large: ' . $imageInfo[0] . 'x' . $imageInfo[1], [
'filename' => $file->getClientOriginalName(),
'user_id' => auth()->id()
]);
return false;
}
// 5. Check file size (max 20MB)
$maxSize = 20 * 1024 * 1024; // 20MB
if ($file->getSize() > $maxSize) {
Log::warning('File size too large: ' . $file->getSize(), [
'filename' => $file->getClientOriginalName(),
'user_id' => auth()->id()
]);
return false;
}
// 6. Check for suspicious content in filename
$filename = $file->getClientOriginalName();
$suspiciousPatterns = [
'<?php', '<?=', '<script', 'javascript:', 'data:', 'vbscript:',
'..', '~', '$', '`', '|', '&', ';', '(', ')', '{', '}',
'exec', 'system', 'shell_exec', 'passthru', 'eval'
];
foreach ($suspiciousPatterns as $pattern) {
if (stripos($filename, $pattern) !== false) {
Log::warning('Suspicious filename pattern detected: ' . $pattern, [
'filename' => $filename,
'user_id' => auth()->id()
]);
return false;
}
}
// 7. Additional security: Check file header (magic bytes)
$handle = fopen($file->getPathname(), 'rb');
if ($handle) {
$header = fread($handle, 8);
fclose($handle);
// Check for valid image headers
$validHeaders = [
"\xFF\xD8\xFF", // JPEG
"\x89\x50\x4E\x47\x0D\x0A\x1A\x0A", // PNG
];
$isValidHeader = false;
foreach ($validHeaders as $validHeader) {
if (substr($header, 0, strlen($validHeader)) === $validHeader) {
$isValidHeader = true;
break;
}
}
if (!$isValidHeader) {
Log::warning('Invalid file header detected', [
'filename' => $filename,
'user_id' => auth()->id(),
'header' => bin2hex($header)
]);
return false;
}
}
Log::info('File validation passed', [
'filename' => $filename,
'size' => $file->getSize(),
'mime_type' => $mimeType,
'user_id' => auth()->id()
]);
return true;
} catch (\Exception $e) {
Log::error('File validation error: ' . $e->getMessage(), [
'filename' => $file->getClientOriginalName(),
'user_id' => auth()->id(),
'error' => $e->getMessage()
]);
return false;
}
}
}

12
app/Http/Controllers/UserController.php Normal file → Executable file
View File

@@ -24,16 +24,16 @@ class UserController extends Controller
return DataTables::of($data) return DataTables::of($data)
->addIndexColumn() ->addIndexColumn()
->addColumn('action', function($row) use ($menu) { ->addColumn('action', function($row) use ($menu) {
$btn = ''; $btn = '<div class="d-flex">';
if(Auth::user()->can('delete', $menu)) { if(Gate::allows('update', $menu)) {
$btn .= '<button class="btn btn-warning btn-sm btn-bold mr-2" id="editUser'. $row->id .'" data-url="'. route('user.edit', $row->id) .'" data-action="'. route('user.update', $row->id) .'" onclick="editUser('. $row->id .')"> Edit </button>';
}
if(Gate::allows('delete', $menu)) {
$btn .= '<button class="btn btn-danger btn-sm btn-bold" data-action="'. route('user.destroy', $row->id) .'" id="destroyUser'. $row->id .'" onclick="destroyUser('. $row->id .')"> Hapus </button>'; $btn .= '<button class="btn btn-danger btn-sm btn-bold" data-action="'. route('user.destroy', $row->id) .'" id="destroyUser'. $row->id .'" onclick="destroyUser('. $row->id .')"> Hapus </button>';
} }
if(Auth::user()->can('update', $menu)) { $btn .= '</div>';
$btn .= '<button class="btn btn-warning btn-sm btn-bold" id="editUser'. $row->id .'" data-url="'. route('user.edit', $row->id) .'" data-action="'. route('user.update', $row->id) .'" onclick="editUser('. $row->id .')"> Edit </button>';
}
return $btn; return $btn;
}) })
->rawColumns(['action']) ->rawColumns(['action'])

View File

@@ -0,0 +1,553 @@
<?php
namespace App\Http\Controllers\WarehouseManagement;
use App\Http\Controllers\Controller;
use App\Models\Mutation;
use App\Models\MutationDetail;
use App\Models\Product;
use App\Models\Dealer;
use App\Enums\MutationStatus;
use App\Models\Menu;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Yajra\DataTables\DataTables;
use Illuminate\Support\Facades\Gate;
class MutationsController extends Controller
{
public function index(Request $request)
{
$menu = Menu::where('link','mutations.index')->first();
abort_if(!Gate::allows('view', $menu), 403);
$dealers = Dealer::all();
if ($request->ajax()) {
// Use a more specific query to avoid join conflicts
$data = Mutation::query()
->with(['fromDealer', 'toDealer', 'requestedBy.role', 'approvedBy.role', 'receivedBy.role'])
->select(['mutations.*']);
// Filter berdasarkan dealer jika user bukan admin
if (auth()->user()->dealer_id) {
$data->where(function($query) {
$query->where('from_dealer_id', auth()->user()->dealer_id)
->orWhere('to_dealer_id', auth()->user()->dealer_id);
});
}
// Filter berdasarkan dealer yang dipilih
if ($request->filled('dealer_filter')) {
$data->where(function($query) use ($request) {
$query->where('from_dealer_id', $request->dealer_filter)
->orWhere('to_dealer_id', $request->dealer_filter);
});
}
// Filter berdasarkan tanggal
if ($request->filled('date_from')) {
try {
$dateFrom = \Carbon\Carbon::parse($request->date_from)->format('Y-m-d');
$data->whereDate('mutations.created_at', '>=', $dateFrom);
} catch (\Exception $e) {
// Fallback to original format
$data->whereDate('mutations.created_at', '>=', $request->date_from);
}
}
if ($request->filled('date_to')) {
try {
$dateTo = \Carbon\Carbon::parse($request->date_to)->format('Y-m-d');
$data->whereDate('mutations.created_at', '<=', $dateTo);
} catch (\Exception $e) {
// Fallback to original format
$data->whereDate('mutations.created_at', '<=', $request->date_to);
}
}
return DataTables::of($data)
->addIndexColumn()
->addColumn('mutation_number', function($row) {
return $row->mutation_number;
})
->addColumn('from_dealer', function($row) {
return $row->fromDealer->name ?? '-';
})
->addColumn('to_dealer', function($row) {
return $row->toDealer->name ?? '-';
})
->addColumn('requested_by', function($row) {
return $row->requestedBy->name ?? '-';
})
->addColumn('status', function($row) {
$status = $row->status instanceof MutationStatus ? $row->status : MutationStatus::from($row->status);
$textColorClass = $status->textColorClass();
$label = $status->label();
return "<span class=\"font-weight-bold {$textColorClass}\">{$label}</span>";
})
->addColumn('total_items', function($row) {
return number_format($row->total_items, 0);
})
->addColumn('created_at', function($row) {
return $row->created_at->format('d M Y, H:i');
})
->addColumn('action', function($row) {
return view('warehouse_management.mutations._action', compact('row'))->render();
})
// Enhanced filtering
->filterColumn('mutation_number', function($query, $keyword) {
$query->where('mutations.mutation_number', 'like', "%{$keyword}%");
})
->filterColumn('from_dealer', function($query, $keyword) {
$query->whereHas('fromDealer', function($q) use ($keyword) {
$q->where('name', 'like', "%{$keyword}%");
});
})
->filterColumn('to_dealer', function($query, $keyword) {
$query->whereHas('toDealer', function($q) use ($keyword) {
$q->where('name', 'like', "%{$keyword}%");
});
})
->filterColumn('requested_by', function($query, $keyword) {
$query->whereHas('requestedBy', function($q) use ($keyword) {
$q->where('name', 'like', "%{$keyword}%");
});
})
->filterColumn('status', function($query, $keyword) {
$query->where('mutations.status', 'like', "%{$keyword}%");
})
->filterColumn('created_at', function($query, $keyword) {
$query->whereDate('mutations.created_at', 'like', "%{$keyword}%");
})
// Enhanced ordering - avoid join conflicts by using subqueries
->orderColumn('mutation_number', function($query, $order) {
$query->orderBy('mutations.mutation_number', $order);
})
->orderColumn('from_dealer', function($query, $order) {
$query->orderBy(
DB::raw('(SELECT name FROM dealers WHERE dealers.id = mutations.from_dealer_id)'),
$order
);
})
->orderColumn('to_dealer', function($query, $order) {
$query->orderBy(
DB::raw('(SELECT name FROM dealers WHERE dealers.id = mutations.to_dealer_id)'),
$order
);
})
->orderColumn('requested_by', function($query, $order) {
$query->orderBy(
DB::raw('(SELECT name FROM users WHERE users.id = mutations.requested_by)'),
$order
);
})
->orderColumn('total_items', function($query, $order) {
$query->orderBy(
DB::raw('(SELECT SUM(quantity_requested) FROM mutation_details WHERE mutation_details.mutation_id = mutations.id)'),
$order
);
})
->orderColumn('status', function($query, $order) {
$query->orderBy('mutations.status', $order);
})
->orderColumn('created_at', function($query, $order) {
$query->orderBy('mutations.created_at', $order);
})
->rawColumns(['status', 'action'])
->make(true);
}
return view('warehouse_management.mutations.index', compact('menu', 'dealers'));
}
public function create()
{
$menu = Menu::where('link','mutations.create')->first();
$dealers = Dealer::all();
$products = Product::with('stocks')->get();
return view('warehouse_management.mutations.create', compact('menu', 'dealers', 'products'));
}
public function store(Request $request)
{
$request->validate([
'from_dealer_id' => 'required|exists:dealers,id',
'to_dealer_id' => 'required|exists:dealers,id|different:from_dealer_id',
'shipping_notes' => 'nullable|string',
'products' => 'required|array|min:1',
'products.*.product_id' => 'required|exists:products,id',
'products.*.quantity_requested' => 'required|numeric|min:0.01'
]);
DB::beginTransaction();
try {
// Buat mutation record dengan status SENT (langsung terkirim ke dealer tujuan)
$mutation = Mutation::create([
'from_dealer_id' => $request->from_dealer_id,
'to_dealer_id' => $request->to_dealer_id,
'status' => MutationStatus::SENT,
'requested_by' => auth()->id(),
'shipping_notes' => $request->shipping_notes
]);
// Buat mutation details
foreach ($request->products as $productData) {
MutationDetail::create([
'mutation_id' => $mutation->id,
'product_id' => $productData['product_id'],
'quantity_requested' => $productData['quantity_requested']
]);
}
DB::commit();
// Check if request came from transaction page
if ($request->has('from_transaction_page') || str_contains($request->header('referer', ''), '/transaction')) {
return redirect()->back()
->with('success', 'Mutasi berhasil dibuat dan terkirim ke dealer tujuan');
}
return redirect()->route('mutations.index')
->with('success', 'Mutasi berhasil dibuat dan terkirim ke dealer tujuan');
} catch (\Exception $e) {
DB::rollback();
return back()->withErrors(['error' => 'Gagal membuat mutasi: ' . $e->getMessage()]);
}
}
public function show(Mutation $mutation)
{
$mutation->load([
'fromDealer',
'toDealer',
'requestedBy.role',
'approvedBy.role',
'receivedBy.role',
'rejectedBy.role',
'cancelledBy.role',
'mutationDetails.product'
]);
return view('warehouse_management.mutations.show', compact('mutation'));
}
public function receive(Request $request, Mutation $mutation)
{
$request->validate([
'reception_notes' => 'nullable|string',
'products' => 'required|array',
'products.*.quantity_approved' => 'required|numeric|min:0',
'products.*.notes' => 'nullable|string'
]);
if (!$mutation->canBeReceived()) {
return back()->withErrors(['error' => 'Mutasi tidak dapat diterima dalam status saat ini']);
}
DB::beginTransaction();
try {
// Update product details dengan quantity_approved dan notes
if ($request->products) {
foreach ($request->products as $detailId => $productData) {
$updateData = [];
// Set quantity_approved
if (isset($productData['quantity_approved'])) {
$updateData['quantity_approved'] = $productData['quantity_approved'];
}
// Set notes jika ada
if (isset($productData['notes']) && !empty($productData['notes'])) {
$updateData['notes'] = $productData['notes'];
}
if (!empty($updateData)) {
MutationDetail::where('id', $detailId)
->where('mutation_id', $mutation->id)
->update($updateData);
}
}
}
// Receive mutation with reception notes
$mutation->receive(auth()->id(), $request->reception_notes);
DB::commit();
// Check user role and redirect accordingly
if (!auth()->user()->dealer_id) {
// Users without dealer_id are likely admin, redirect to mutations index
return redirect()->route('mutations.index')
->with('success', 'Mutasi berhasil diterima dan siap untuk disetujui. Stock akan dipindahkan setelah disetujui.');
} else {
// Dealer users redirect back to transaction page
return redirect()->route('transaction')
->with('success', 'Mutasi berhasil diterima. Silakan setujui mutasi ini untuk memindahkan stock.')
->with('active_tab', 'penerimaan');
}
} catch (\Exception $e) {
DB::rollback();
return back()->withErrors(['error' => 'Gagal menerima mutasi: ' . $e->getMessage()]);
}
}
public function approve(Request $request, Mutation $mutation)
{
$request->validate([
'approval_notes' => 'nullable|string'
]);
if (!$mutation->canBeApproved()) {
return back()->withErrors(['error' => 'Mutasi tidak dapat disetujui dalam status saat ini']);
}
try {
// Approve mutation (stock will move automatically)
$mutation->approve(auth()->id(), $request->approval_notes);
// Check user role and redirect accordingly
if (!auth()->user()->dealer_id) {
// Admin users redirect to mutations index
return redirect()->route('mutations.index')
->with('success', 'Mutasi berhasil disetujui dan stock telah dipindahkan');
} else {
// Dealer users
if ($request->has('from_transaction_page') || str_contains($request->header('referer', ''), '/transaction')) {
return redirect()->route('transaction')
->with('success', 'Mutasi berhasil disetujui dan stock telah dipindahkan')
->with('active_tab', 'penerimaan');
} else {
return redirect()->route('mutations.index')
->with('success', 'Mutasi berhasil disetujui dan stock telah dipindahkan');
}
}
} catch (\Exception $e) {
return back()->withErrors(['error' => 'Gagal menyetujui mutasi: ' . $e->getMessage()]);
}
}
public function reject(Request $request, Mutation $mutation)
{
$request->validate([
'rejection_reason' => 'required|string'
]);
if (!$mutation->canBeApproved()) {
return back()->withErrors(['error' => 'Mutasi tidak dapat ditolak dalam status saat ini']);
}
try {
$mutation->reject(auth()->id(), $request->rejection_reason);
// Check user role and redirect accordingly
if (!auth()->user()->dealer_id) {
// Admin users redirect to mutations index
return redirect()->route('mutations.index')
->with('success', 'Mutasi berhasil ditolak');
} else {
// Dealer users
if ($request->has('from_transaction_page') || str_contains($request->header('referer', ''), '/transaction')) {
return redirect()->route('transaction')
->with('success', 'Mutasi berhasil ditolak')
->with('active_tab', 'penerimaan');
} else {
return redirect()->route('mutations.index')
->with('success', 'Mutasi berhasil ditolak');
}
}
} catch (\Exception $e) {
return back()->withErrors(['error' => 'Gagal menolak mutasi: ' . $e->getMessage()]);
}
}
// Complete method removed - Stock moves automatically after approval
public function cancel(Request $request, Mutation $mutation)
{
$request->validate([
'cancellation_reason' => 'nullable|string'
]);
if (!$mutation->canBeCancelled()) {
return back()->withErrors(['error' => 'Mutasi tidak dapat dibatalkan dalam status saat ini']);
}
try {
$mutation->cancel(auth()->id(), $request->cancellation_reason);
return redirect()->route('mutations.index')
->with('success', 'Mutasi berhasil dibatalkan');
} catch (\Exception $e) {
return back()->withErrors(['error' => 'Gagal membatalkan mutasi: ' . $e->getMessage()]);
}
}
// API untuk mendapatkan stock produk di dealer tertentu
public function getProductStock(Request $request)
{
$dealerId = $request->dealer_id;
$productId = $request->product_id;
$product = Product::findOrFail($productId);
$stock = $product->getStockByDealer($dealerId);
return response()->json([
'product_name' => $product->name,
'current_stock' => $stock
]);
}
// API untuk mendapatkan mutasi yang perlu diterima oleh dealer
public function getPendingMutations(Request $request)
{
$dealerId = $request->dealer_id;
// Get mutations that need action from this dealer:
// 1. 'sent' status where this dealer is the recipient (need to receive)
// 2. 'received' status where this dealer is the recipient (show as waiting for admin approval)
// 3. 'approved' status where this dealer is the recipient (show as completed)
// 4. 'rejected' status where this dealer is the recipient (show as rejected)
$data = Mutation::with(['fromDealer', 'toDealer', 'requestedBy.role'])
->where(function($query) use ($dealerId) {
// Mutations sent to this dealer that need to be received
$query->where('to_dealer_id', $dealerId)
->where('status', 'sent');
// OR mutations received by this dealer (waiting for admin approval)
$query->orWhere(function($subQuery) use ($dealerId) {
$subQuery->where('to_dealer_id', $dealerId)
->where('status', 'received');
});
// OR mutations approved/rejected for this dealer (historical data)
$query->orWhere(function($subQuery) use ($dealerId) {
$subQuery->where('to_dealer_id', $dealerId)
->whereIn('status', ['approved', 'rejected']);
});
})
->orderBy('mutations.id', 'desc'); // Default order by ID desc
return DataTables::of($data)
->addIndexColumn()
->addColumn('mutation_number', function($row) {
return $row->mutation_number;
})
->addColumn('from_dealer', function($row) {
return $row->fromDealer->name ?? '-';
})
->addColumn('to_dealer', function($row) {
return $row->toDealer->name ?? '-';
})
->addColumn('status', function($row) {
$status = $row->status instanceof MutationStatus ? $row->status : MutationStatus::from($row->status);
$textColorClass = $status->textColorClass();
$label = $status->label();
return "<span class=\"font-weight-bold {$textColorClass}\">{$label}</span>";
})
->addColumn('total_items', function($row) {
return number_format($row->total_items, 0);
})
->addColumn('created_at', function($row) {
return $row->created_at->format('d M Y, H:i');
})
->addColumn('action', function($row) use ($dealerId) {
$buttons = '';
if ($row->status->value === 'sent' && $row->to_dealer_id == $dealerId) {
// For sent mutations where current dealer is recipient - show detail button for receiving
$buttons .= '<button type="button" class="btn btn-info btn-sm btn-detail" onclick="showMutationDetail('.$row->id.')">
Detail & Terima
</button>';
} elseif ($row->status->value === 'received' && $row->to_dealer_id == $dealerId) {
// For received mutations where current dealer is recipient - only show detail (approval is admin only)
$buttons .= '<button type="button" class="btn btn-info btn-sm btn-detail" onclick="showMutationDetail('.$row->id.')">
Detail
</button>';
$buttons .= '<div class="mt-1"><small class="text-muted">Menunggu persetujuan admin</small></div>';
} elseif ($row->status->value === 'approved' && $row->to_dealer_id == $dealerId) {
// For approved mutations - show detail only
$buttons .= '<button type="button" class="btn btn-info btn-sm btn-detail" onclick="showMutationDetail('.$row->id.')">
Detail
</button>';
$buttons .= '<div class="mt-1"><small class="text-success">Disetujui admin</small></div>';
} elseif ($row->status->value === 'rejected' && $row->to_dealer_id == $dealerId) {
// For rejected mutations - show detail only
$buttons .= '<button type="button" class="btn btn-info btn-sm btn-detail" onclick="showMutationDetail('.$row->id.')">
Detail
</button>';
$buttons .= '<div class="mt-1"><small class="text-danger">Ditolak admin</small></div>';
}
return $buttons;
})
->rawColumns(['status', 'action'])
->make(true);
}
// API untuk mendapatkan detail mutasi
public function getDetail(Mutation $mutation)
{
try {
$mutation->load([
'fromDealer',
'toDealer',
'requestedBy.role',
'approvedBy.role',
'receivedBy.role',
'rejectedBy.role',
'cancelledBy.role',
'mutationDetails.product'
]);
// Format created_at
$mutation->created_at_formatted = $mutation->created_at->format('d M Y, H:i');
// Add status color and label
$mutation->status_color = $mutation->status_color;
$mutation->status_label = $mutation->status_label;
// Check if can be received
$mutation->can_be_received = $mutation->canBeReceived();
return response()->json([
'success' => true,
'data' => $mutation
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => 'Gagal memuat detail mutasi: ' . $e->getMessage()
], 500);
}
}
public function print($id)
{
try {
$mutation = Mutation::with([
'fromDealer',
'toDealer',
'requestedBy.role',
'approvedBy.role',
'receivedBy.role',
'rejectedBy.role',
'cancelledBy.role',
'mutationDetails.product.category'
])->findOrFail($id);
return view('warehouse_management.mutations.print', compact('mutation'));
} catch (\Exception $e) {
Log::error('Error printing mutation: ' . $e->getMessage());
return back()->with('error', 'Gagal membuka halaman print mutasi.');
}
}
}

View File

@@ -0,0 +1,524 @@
<?php
namespace App\Http\Controllers\WarehouseManagement;
use App\Enums\OpnameStatus;
use App\Http\Controllers\Controller;
use App\Models\Dealer;
use App\Models\Menu;
use App\Models\Opname;
use App\Models\OpnameDetail;
use App\Models\Product;
use App\Models\Stock;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\Rule;
use Yajra\DataTables\Facades\DataTables;
use Illuminate\Support\Facades\Gate;
class OpnamesController extends Controller
{
public function index(Request $request){
$menu = Menu::where('link','opnames.index')->first();
abort_if(!Gate::allows('view', $menu), 403);
$dealers = Dealer::all();
if($request->ajax()){
$data = Opname::query()
->with(['user','dealer', 'details.product'])
->orderBy('created_at', 'desc');
// Filter berdasarkan dealer yang dipilih
if ($request->filled('dealer_filter')) {
$data->where('dealer_id', $request->dealer_filter);
}
// Filter berdasarkan tanggal
if ($request->filled('date_from')) {
try {
$dateFrom = \Carbon\Carbon::parse($request->date_from)->format('Y-m-d');
$data->whereDate('opname_date', '>=', $dateFrom);
} catch (\Exception $e) {
// Fallback to original format
$data->whereDate('opname_date', '>=', $request->date_from);
}
}
if ($request->filled('date_to')) {
try {
$dateTo = \Carbon\Carbon::parse($request->date_to)->format('Y-m-d');
$data->whereDate('opname_date', '<=', $dateTo);
} catch (\Exception $e) {
// Fallback to original format
$data->whereDate('opname_date', '<=', $request->date_to);
}
}
return DataTables::of($data)
->addColumn('user_name', function ($row){
return $row->user ? $row->user->name : '-';
})
->addColumn('dealer_name', function ($row){
return $row->dealer ? $row->dealer->name : '-';
})
->editColumn('opname_date', function ($row){
return $row->opname_date ? Carbon::parse($row->opname_date)->format('d M Y') : '-';
})
->editColumn('created_at', function ($row) {
return Carbon::parse($row->created_at)->format('d M Y H:i');
})
->editColumn('status', function ($row) {
$status = $row->status instanceof OpnameStatus ? $row->status : OpnameStatus::from($row->status);
$textColorClass = $status->textColorClass();
$label = $status->label();
return "<span class=\"font-weight-bold {$textColorClass}\">{$label}</span>";
})
->addColumn('stock_info', function ($row) {
// Use eager loaded details
$details = $row->details;
if ($details->isEmpty()) {
return '<span class="text-muted">Tidak ada data</span>';
}
$totalProducts = $details->count();
$matchingProducts = $details->where('difference', 0)->count();
$differentProducts = $totalProducts - $matchingProducts;
$info = [];
if ($matchingProducts > 0) {
$info[] = "<span class='text-success'><i class='fa fa-check-circle'></i> {$matchingProducts} sesuai</span>";
}
if ($differentProducts > 0) {
// Get more details about differences
$positiveDiff = $details->where('difference', '>', 0)->count();
$negativeDiff = $details->where('difference', '<', 0)->count();
$diffInfo = [];
if ($positiveDiff > 0) {
$diffInfo[] = "+{$positiveDiff}";
}
if ($negativeDiff > 0) {
$diffInfo[] = "-{$negativeDiff}";
}
$diffText = implode(', ', $diffInfo);
$info[] = "<span class='text-danger'><i class='fa fa-exclamation-triangle'></i> {$differentProducts} selisih ({$diffText})</span>";
}
// Add total products info
$info[] = "<small class='text-muted'>(Total: {$totalProducts} produk)</small>";
return '<div class="stock-info-cell">' . implode('<br>', $info) . '</div>';
})
->addColumn('action', function ($row) use ($menu) {
$btn = '<div class="d-flex">';
$btn .= '<a href="'.route('opnames.show', $row->id).'" class="btn btn-primary btn-sm" style="margin-right: 8px;">Detail</a>';
$btn .= '<a href="'.route('opnames.print', $row->id).'" class="btn btn-success btn-sm" target="_blank">Print</a>';
$btn .= '</div>';
return $btn;
})
->rawColumns(['action', 'status', 'stock_info'])
->make(true);
}
return view('warehouse_management.opnames.index', compact('dealers'));
}
public function create(){
try{
$dealers = Dealer::all();
$products = Product::where('active', true)->get();
// Get initial stock data for the first dealer (if any)
$initialDealerId = $dealers->first()?->id;
$stocks = [];
if ($initialDealerId) {
$stocks = Stock::where('dealer_id', $initialDealerId)
->whereIn('product_id', $products->pluck('id'))
->get()
->keyBy('product_id');
}
return view('warehouse_management.opnames.create', compact('dealers', 'products', 'stocks'));
} catch(\Exception $ex) {
Log::error($ex->getMessage());
return back()->with('error', 'Terjadi kesalahan saat memuat data');
}
}
public function store(Request $request)
{
try {
DB::beginTransaction();
// Check if this is from transaction form or regular opname form
$isTransactionForm = $request->has('form') && $request->form === 'opname';
if ($isTransactionForm) {
// Simplified validation for transaction form
$request->validate([
'dealer_id' => 'required|exists:dealers,id',
'user_id' => 'required|exists:users,id',
'opname_date' => [
'nullable',
'string',
'date_format:Y-m-d',
'before_or_equal:today'
],
'description' => 'nullable|string|max:1000',
'product_id' => 'required|array|min:1',
'product_id.*' => 'required|exists:products,id',
'system_stock' => 'required|array',
'system_stock.*' => 'required|numeric|min:0',
'physical_stock' => 'required|array',
'physical_stock.*' => 'nullable|numeric|min:0'
]);
// Process transaction form data with proper date parsing
$dealerId = $request->dealer_id;
$userId = $request->user_id;
// Parse opname date (YYYY-MM-DD format) or use today if empty
$inputDate = $request->opname_date ?: now()->format('Y-m-d');
Log::info('Parsing opname date', ['input' => $request->opname_date, 'using' => $inputDate]);
$opnameDate = Carbon::createFromFormat('Y-m-d', $inputDate);
Log::info('Successfully parsed opname date', ['parsed' => $opnameDate->format('Y-m-d')]);
$note = $request->description;
$productIds = $request->product_id;
$systemStocks = $request->system_stock;
$physicalStocks = $request->physical_stock;
// Log input data untuk debugging
Log::info('Transaction form input data', [
'product_ids' => $productIds,
'system_stocks' => $systemStocks,
'physical_stocks' => $physicalStocks,
'dealer_id' => $dealerId,
'user_id' => $userId
]);
} else {
// Original validation for regular opname form
$request->validate([
'dealer' => 'required|exists:dealers,id',
'product' => 'required|array|min:1',
'product.*' => 'required|exists:products,id',
'system_quantity' => 'required|array',
'system_quantity.*' => 'required|numeric|min:0',
'physical_quantity' => 'required|array',
'physical_quantity.*' => 'required|numeric|min:0',
'note' => 'nullable|string|max:1000',
'item_notes' => 'nullable|array',
'item_notes.*' => 'nullable|string|max:255',
'opname_date' => 'nullable|date' // Add opname_date validation for regular form
]);
// Process regular form data
$dealerId = $request->dealer;
$userId = auth()->id();
// Use provided date or current date
$inputDate = $request->opname_date ?: now()->format('Y-m-d');
$opnameDate = $request->opname_date ?
Carbon::createFromFormat('Y-m-d', $inputDate) :
now();
$note = $request->note;
$productIds = $request->product;
$systemStocks = $request->system_quantity;
$physicalStocks = $request->physical_quantity;
}
// 2. Simplified validation - all products are valid, set defaults for empty physical stocks
$validProductIds = array_filter($productIds);
if (empty($validProductIds) || count($validProductIds) === 0) {
throw new \Exception('Minimal harus ada satu produk untuk opname.');
}
// 3. Validasi duplikasi produk
$productCounts = array_count_values($validProductIds);
foreach ($productCounts as $productId => $count) {
if ($count > 1) {
throw new \Exception('Produk tidak boleh duplikat dalam satu opname.');
}
}
// 4. Validasi dealer
$dealer = Dealer::findOrFail($dealerId);
// 5. Validasi user exists
$user = User::findOrFail($userId);
// 6. Validasi produk aktif
$filteredProductIds = array_filter($productIds);
$inactiveProducts = Product::whereIn('id', $filteredProductIds)
->where('active', false)
->pluck('name')
->toArray();
if (!empty($inactiveProducts)) {
throw new \Exception('Produk berikut tidak aktif: ' . implode(', ', $inactiveProducts));
}
// 7. Validasi stock difference (for transaction form, we'll allow any difference without note requirement)
$stockDifferences = [];
if (!$isTransactionForm) {
// Only validate notes for regular opname form
foreach ($productIds as $index => $productId) {
if (!$productId) continue;
$systemStock = floatval($systemStocks[$index] ?? 0);
$physicalStock = floatval($physicalStocks[$index] ?? 0);
$itemNote = $request->input("item_notes.{$index}");
// Jika ada perbedaan stock dan note kosong
if (abs($systemStock - $physicalStock) > 0.01 && empty($itemNote)) {
$product = Product::find($productId);
$stockDifferences[] = $product->name;
}
}
if (!empty($stockDifferences)) {
throw new \Exception(
'Catatan harus diisi untuk produk berikut karena ada perbedaan stock: ' .
implode(', ', $stockDifferences)
);
}
}
// 8. Create Opname master record with approved status
$opname = Opname::create([
'dealer_id' => $dealerId,
'opname_date' => $opnameDate,
'user_id' => $userId,
'note' => $note,
'status' => OpnameStatus::APPROVED, // Set status langsung approved
'approved_by' => $userId, // Set current user sebagai approver
'approved_at' => now() // Set waktu approval
]);
// 9. Create OpnameDetails and update stock - only for valid entries
$details = [];
$processedCount = 0;
foreach ($productIds as $index => $productId) {
if (!$productId) continue;
// Set default value to 0 if physical stock is empty or invalid
$physicalStockValue = $physicalStocks[$index] ?? null;
if ($physicalStockValue === '' || $physicalStockValue === null || !is_numeric($physicalStockValue)) {
$physicalStockValue = 0;
}
$systemStock = floatval($systemStocks[$index] ?? 0);
$physicalStock = floatval($physicalStockValue);
$difference = $physicalStock - $systemStock;
$processedCount++;
// Get item note (only for regular opname form)
$itemNote = null;
if (!$isTransactionForm) {
$itemNote = $request->input("item_notes.{$index}");
}
// Create opname detail
$details[] = [
'opname_id' => $opname->id,
'product_id' => $productId,
'system_stock' => $systemStock,
'physical_stock' => $physicalStock,
'difference' => $difference,
'note' => $itemNote,
'created_at' => now(),
'updated_at' => now()
];
// Update stock langsung karena auto approve
$stock = Stock::firstOrCreate(
[
'product_id' => $productId,
'dealer_id' => $dealerId
],
['quantity' => 0]
);
// Update stock dengan physical stock
$stock->updateStock(
$physicalStock,
$opname,
"Stock adjustment from auto-approved opname #{$opname->id}"
);
}
// Validate we have at least one detail to insert
if (empty($details)) {
throw new \Exception('Tidak ada data produk yang valid untuk diproses.');
}
// Bulk insert untuk performa lebih baik
OpnameDetail::insert($details);
// 10. Log aktivitas dengan detail produk yang diproses
$processedProducts = collect($details)->map(function($detail) {
return [
'product_id' => $detail['product_id'],
'system_stock' => $detail['system_stock'],
'physical_stock' => $detail['physical_stock'],
'difference' => $detail['difference']
];
});
Log::info('Opname created and auto-approved', [
'opname_id' => $opname->id,
'dealer_id' => $opname->dealer_id,
'user_id' => $userId,
'approver_id' => $userId,
'product_count' => count($details),
'processed_count' => $processedCount,
'form_type' => $isTransactionForm ? 'transaction' : 'regular',
'opname_date' => $opnameDate->format('Y-m-d'),
'processed_products' => $processedProducts->toArray()
]);
DB::commit();
if ($isTransactionForm) {
// Redirect back to transaction page with success message and tab indicator
return redirect()
->route('transaction')
->with('success', "Opname berhasil disimpan. {$processedCount} produk telah diproses.")
->with('active_tab', 'opname');
} else {
// Redirect to opname index for regular form
return redirect()
->route('opnames.index')
->with('success', "Opname berhasil disimpan. {$processedCount} produk telah diproses.");
}
} catch (\Illuminate\Validation\ValidationException $e) {
DB::rollBack();
Log::error('Validation error in OpnamesController@store', [
'errors' => $e->errors(),
'input' => $request->all()
]);
if ($isTransactionForm) {
return redirect()
->route('transaction')
->withErrors($e->validator)
->withInput()
->with('error', 'Terjadi kesalahan validasi. Periksa kembali data yang dimasukkan.')
->with('active_tab', 'opname');
} else {
return back()->withErrors($e->validator)->withInput();
}
} catch (\Exception $e) {
DB::rollBack();
Log::error('Error in OpnamesController@store: ' . $e->getMessage());
Log::error($e->getTraceAsString());
Log::error('Request data:', $request->all());
$errorMessage = $e->getMessage();
if ($isTransactionForm) {
return redirect()
->route('transaction')
->with('error', $errorMessage)
->withInput()
->with('active_tab', 'opname');
} else {
return back()
->with('error', $errorMessage)
->withInput();
}
}
}
public function show(Request $request, $id)
{
try {
$opname = Opname::with('details.product', 'user')->findOrFail($id);
if ($request->ajax()) {
return DataTables::of($opname->details)
->addIndexColumn()
->addColumn('opname_date', function () use ($opname) {
return Carbon::parse($opname->opname_date)->format('d M Y');
})
->addColumn('user_name', function () use ($opname) {
return $opname->user ? $opname->user->name : '-';
})
->addColumn('product_name', function ($detail) {
return $detail->product->name ?? '-';
})
->addColumn('system_stock', function ($detail) {
return $detail->system_stock;
})
->addColumn('physical_stock', function ($detail) {
return $detail->physical_stock;
})
->addColumn('difference', function ($detail) {
return $detail->difference;
})
->make(true);
}
return view('warehouse_management.opnames.detail', compact('opname'));
} catch (\Exception $ex) {
Log::error($ex->getMessage());
abort(500, 'Something went wrong');
}
}
// Add new method to get stock data via AJAX
public function getStockData(Request $request)
{
try {
$dealerId = $request->dealer_id;
$productIds = $request->product_ids;
if (!$dealerId || !$productIds) {
return response()->json(['error' => 'Dealer ID dan Product IDs diperlukan'], 400);
}
$stocks = Stock::where('dealer_id', $dealerId)
->whereIn('product_id', $productIds)
->get()
->mapWithKeys(function ($stock) {
return [$stock->product_id => $stock->quantity];
});
return response()->json(['stocks' => $stocks]);
} catch (\Exception $e) {
Log::error('Error getting stock data: ' . $e->getMessage());
return response()->json(['error' => 'Terjadi kesalahan saat mengambil data stok'], 500);
}
}
public function print($id)
{
try {
$opname = Opname::with(['details.product.category', 'user', 'dealer'])
->findOrFail($id);
return view('warehouse_management.opnames.print', compact('opname'));
} catch (\Exception $e) {
Log::error('Error printing opname: ' . $e->getMessage());
return back()->with('error', 'Gagal membuka halaman print opname.');
}
}
}

View File

@@ -0,0 +1,136 @@
<?php
namespace App\Http\Controllers\WarehouseManagement;
use App\Http\Controllers\Controller;
use App\Models\Menu;
use App\Models\ProductCategory;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Yajra\DataTables\Facades\DataTables;
use Illuminate\Support\Facades\Gate;
class ProductCategoriesController extends Controller
{
/**
* Display a listing of the resource.
*
* @return \Illuminate\Http\Response
*/
public function index(Request $request)
{
$menu = Menu::where('link','product_categories.index')->first();
abort_if(!Gate::allows('view', $menu), 403);
if($request->ajax()){
$data = ProductCategory::query();
return DataTables::of($data)
->addIndexColumn()
->addColumn('parent', function ($row) {
return $row->parent ? $row->parent->name : '-';
})
->addColumn('action', function ($row) use ($menu) {
$btn = '';
if (Gate::allows('delete', $menu)) {
$btn .= '<button style="margin-right: 8px;" class="btn btn-danger btn-sm btn-destroy-product-category" data-action="' . route('product_categories.destroy', $row->id) . '" data-id="' . $row->id . '">Hapus</button>';
}
if (Gate::allows('update', $menu)) {
$btn .= '<button class="btn btn-warning btn-sm btn-edit-product-category" data-url="' . route('product_categories.edit', $row->id) . '" data-action="' . route('product_categories.update', $row->id) . '" data-id="' . $row->id . '">Edit</button>';
}
return $btn;
})
->rawColumns(['action'])
->make(true);
}
return view('warehouse_management.product_categories.index');
}
/**
* Show the form for creating a new resource.
*
* @return \Illuminate\Http\Response
*/
public function create()
{
//
}
/**
* Store a newly created resource in storage.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\Response
*/
public function store(Request $request)
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'parent_id' => 'nullable|exists:product_categories,id',
]);
ProductCategory::create($validated);
return response()->json(['success' => true, 'message' => 'Kategori berhasil ditambahkan.']);
}
/**
* Display the specified resource.
*
* @param int $id
* @return \Illuminate\Http\Response
*/
public function show($id)
{
//
}
/**
* Show the form for editing the specified resource.
*
* @param int $id
* @return \Illuminate\Http\Response
*/
public function edit($id)
{
$category = ProductCategory::findOrFail($id);
return response()->json($category);
}
/**
* Update the specified resource in storage.
*
* @param \Illuminate\Http\Request $request
* @param int $id
* @return \Illuminate\Http\Response
*/
public function update(Request $request, $id)
{
$validated = $request->validate([
'name' => 'required|string|max:255',
]);
$category = ProductCategory::findOrFail($id);
$category->update($validated);
return response()->json(['success' => true, 'message' => 'Kategori berhasil diperbarui.']);
}
/**
* Remove the specified resource from storage.
*
* @param int $id
* @return \Illuminate\Http\Response
*/
public function destroy($id)
{
ProductCategory::findOrFail($id)->delete();
return response()->json(['success' => true, 'message' => 'Kategorii berhasil dihapus.']);
}
public function product_category_parents(Request $request)
{
$parents = ProductCategory::whereNull('parent_id')->get(['id', 'name']);
return response()->json($parents);
}
}

View File

@@ -0,0 +1,288 @@
<?php
namespace App\Http\Controllers\WarehouseManagement;
use App\Http\Controllers\Controller;
use App\Models\Dealer;
use App\Models\Menu;
use App\Models\Product;
use App\Models\ProductCategory;
use App\Exports\ProductStockDealers;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\DB;
use Yajra\DataTables\Facades\DataTables;
use Illuminate\Validation\Rule;
use Maatwebsite\Excel\Facades\Excel;
class ProductsController extends Controller
{
/**
* Display a listing of the resource.
*
* @return \Illuminate\Http\Response
*/
public function index(Request $request)
{
$menu = Menu::where('link','products.index')->first();
abort_if(!Gate::allows('view', $menu), 403);
if($request->ajax()){
Log::info('Products DataTables request received');
Log::info('Request parameters:', $request->all());
try {
// Check if products exist
$productCount = Product::count();
Log::info('Total products in database: ' . $productCount);
$data = Product::with(['category', 'stocks'])
->select(['id', 'code', 'name', 'product_category_id', 'unit', 'active']);
Log::info('Query built, executing DataTables...');
return DataTables::of($data)
->addIndexColumn()
->addColumn('code', function ($row) {
return $row->code;
})
->addColumn('name', function ($row) {
return $row->name;
})
->addColumn('category_name', function ($row) {
return $row->category ? $row->category->name : '-';
})
->addColumn('unit', function ($row) {
return $row->unit ?? '-';
})
->addColumn('total_stock', function ($row){
try {
$totalStock = $row->stocks()->sum('quantity');
return number_format($totalStock, 2);
} catch (\Exception $e) {
Log::error('Error calculating total stock for product ' . $row->id . ': ' . $e->getMessage());
return '0.00';
}
})
->addColumn('action', function ($row) use ($menu) {
$btn = '<div class="d-flex">';
if (Gate::allows('update', $menu)) {
$btn .= '<a href="' . route('products.edit', $row->id) . '" class="btn btn-warning btn-sm" style="margin-right: 8px;">Edit</a>';
$btn .= '<button class="btn btn-sm btn-toggle-active '
. ($row->active ? 'btn-danger' : 'btn-success') . '"
data-url="' . route('products.toggleActive', $row->id) . '" data-active="'.$row->active.'" style="margin-right: 8px;">'
. ($row->active ? 'Nonaktifkan' : 'Aktifkan') . '</button>';
}
$btn .= '<button class="btn btn-sm btn-secondary btn-product-stock-dealers"
data-id="'.$row->id.'"
data-url="'.route('products.dealers_stock').'"
data-name="'.$row->name.'">Dealer</button>';
$btn .= '</div>';
return $btn;
})
->filterColumn('category_name', function($query, $keyword) {
$query->whereHas('category', function($q) use ($keyword) {
$q->where('name', 'like', "%{$keyword}%");
});
})
->orderColumn('code', function ($query, $order) {
$query->orderBy('products.code', $order);
})
->orderColumn('name', function ($query, $order) {
$query->orderBy('products.name', $order);
})
->orderColumn('category_name', function ($query, $order) {
$query->orderBy(
DB::raw('(SELECT name FROM product_categories WHERE product_categories.id = products.product_category_id)'),
$order
);
})
->orderColumn('unit', function ($query, $order) {
$query->orderBy('products.unit', $order);
})
->rawColumns(['action'])
->make(true);
} catch (\Exception $e) {
Log::error('Products DataTables error: ' . $e->getMessage());
Log::error('Stack trace: ' . $e->getTraceAsString());
return response()->json(['error' => 'Failed to load data'], 500);
}
}
return view('warehouse_management.products.index');
}
/**
* Show the form for creating a new resource.
*
* @return \Illuminate\Http\Response
*/
public function create()
{
$categories = ProductCategory::with('children')->whereNull('parent_id')->get();
$dealers = Dealer::all();
return view('warehouse_management.products.create', compact('categories', 'dealers'));
}
/**
* Store a newly created resource in storage.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\Response
*/
public function store(Request $request)
{
try{
$request->validate([
'code' => [
'required',
'string',
Rule::unique('products')->whereNull('deleted_at'),
],
'name' => 'required|string',
'description' => 'nullable|string',
'unit' => 'nullable|string',
'active' => 'required|boolean',
'product_category_id' => 'required|exists:product_categories,id'
]);
// Create product
$product = Product::create([
'code' => $request->code,
'name' => $request->name,
'unit' => $request->unit,
'active' => $request->active,
'description' => $request->description,
'product_category_id' => $request->product_category_id,
]);
return redirect()->route('products.index')->with('success', 'Produk berhasil ditambahkan.');
}catch(\Exception $ex){
Log::error($ex->getMessage());
throw $ex;
}
}
/**
* Display the specified resource.
*
* @param int $id
* @return \Illuminate\Http\Response
*/
public function show($id)
{
//
}
/**
* Show the form for editing the specified resource.
*
* @param int $id
* @return \Illuminate\Http\Response
*/
public function edit($id)
{
$product = Product::findOrFail($id);
return view('warehouse_management.products.edit', [
'product' => $product->load('dealers'),
'dealers' => Dealer::all(),
'categories' => ProductCategory::with('children')->get(),
]);
}
/**
* Update the specified resource in storage.
*
* @param \Illuminate\Http\Request $request
* @param int $id
* @return \Illuminate\Http\Response
*/
public function update(Request $request, Product $product)
{
try{
$request->validate([
'code' => [
'required',
'string',
Rule::unique('products')->ignore($product->id)->whereNull('deleted_at'),
],
'name' => 'required|string',
'description' => 'nullable|string',
'unit' => 'nullable|string',
'active' => 'required|boolean',
'product_category_id' => 'required|exists:product_categories,id'
]);
$product->update($request->only(['code', 'name', 'description', 'unit','active', 'product_category_id']));
return redirect()->route('products.index')->with('success', 'Produk berhasil diperbarui.');
}catch(\Exception $ex){
Log::error($ex->getMessage());
}
}
/**
* Remove the specified resource from storage.
*
* @param int $id
* @return \Illuminate\Http\Response
*/
public function destroy(Product $product)
{
$product->delete();
return redirect()->route('products.index')->with('success', 'Produk berhasil dihapus.');
}
public function toggleActive(Request $request, Product $product)
{
$product->active = !$product->active;
$product->save();
return response()->json([
'success' => true,
'active' => $product->active,
'message' => 'Status produk berhasil diperbarui.'
]);
}
public function all_products(){
try{
$products = Product::where('active', true)->select('id','name')->get();
return response()->json($products);
}catch(\Exception $ex){
Log::error($ex->getMessage());
}
}
public function dealers_stock(Request $request){
$productId = $request->get('product_id');
$product = Product::with(['stocks.dealer'])->findOrFail($productId);
$data = $product->stocks->map(function ($stock) {
return [
'dealer_name' => $stock->dealer->name ?? '-',
'quantity' => $stock->quantity
];
});
return DataTables::of($data)->make(true);
}
public function exportDealersStock()
{
try {
$fileName = 'stok_produk_dealers_' . date('Y-m-d_H-i-s') . '.xlsx';
return Excel::download(new ProductStockDealers(), $fileName);
} catch (\Exception $e) {
Log::error('Export dealers stock error: ' . $e->getMessage());
return back()->with('error', 'Gagal mengexport data. Silakan coba lagi.');
}
}
}

View File

@@ -0,0 +1,227 @@
<?php
namespace App\Http\Controllers\WarehouseManagement;
use App\Http\Controllers\Controller;
use App\Models\StockLog;
use App\Models\Menu;
use App\Models\Dealer;
use App\Models\Product;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Yajra\DataTables\DataTables;
use Illuminate\Support\Facades\Gate;
class StockAuditController extends Controller
{
public function index(Request $request)
{
$menu = Menu::where('link', 'stock-audit.index')->first();
abort_if(!Gate::allows('view', $menu), 403);
$dealers = Dealer::all();
$products = Product::all();
if ($request->ajax()) {
Log::info('Stock audit ajax request received', [
'filters' => $request->only(['dealer', 'product', 'change_type', 'date']),
'user_id' => auth()->id(),
'user_dealer_id' => auth()->user()->dealer_id
]);
$data = StockLog::query()
->with([
'stock.product',
'stock.dealer',
'user.role',
'source'
])
->leftJoin('stocks', 'stock_logs.stock_id', '=', 'stocks.id')
->leftJoin('products', 'stocks.product_id', '=', 'products.id')
->leftJoin('dealers', 'stocks.dealer_id', '=', 'dealers.id')
->leftJoin('users', 'stock_logs.user_id', '=', 'users.id')
->select('stock_logs.*');
// Apply filters from request
if ($request->filled('dealer')) {
$data->where('dealers.name', 'like', '%' . $request->dealer . '%');
}
if ($request->filled('product')) {
$data->where('products.name', 'like', '%' . $request->product . '%');
}
if ($request->filled('change_type')) {
$data->where('stock_logs.change_type', $request->change_type);
}
if ($request->filled('date')) {
$data->whereDate('stock_logs.created_at', $request->date);
}
return DataTables::of($data)
->addIndexColumn()
->addColumn('product_name', function($row) {
return $row->stock->product->name ?? '-';
})
->addColumn('dealer_name', function($row) {
return $row->stock->dealer->name ?? '-';
})
->addColumn('change_type', function($row) {
$changeType = $row->change_type;
$class = match($changeType->value) {
'increase' => 'text-success',
'decrease' => 'text-danger',
'adjustment' => 'text-warning',
'no_change' => 'text-muted',
default => 'text-dark'
};
return "<span class=\"font-weight-bold {$class}\">{$changeType->label()}</span>";
})
->addColumn('quantity_change', function($row) {
$change = $row->quantity_change;
if ($change > 0) {
return "<span class=\"text-success\">+{$change}</span>";
} elseif ($change < 0) {
return "<span class=\"text-danger\">{$change}</span>";
} else {
return "<span class=\"text-muted\">0</span>";
}
})
->addColumn('stock_before_after', function($row) {
return "{$row->previous_quantity}{$row->new_quantity}";
})
->addColumn('source_info', function($row) {
if ($row->source_type === 'App\\Models\\Mutation') {
$mutationNumber = $row->source ? $row->source->mutation_number : '-';
return "Mutasi: {$mutationNumber}";
} elseif ($row->source_type === 'App\\Models\\Opname') {
$opname_id = $row->source ? $row->source->id : '-';
return "Opname: #{$opname_id}";
} elseif ($row->source_type === 'App\\Models\\Transaction')
{
$transaction_id = $row->source ? $row->source->id : '-';
return "Transaksi: #{$transaction_id}";
}else {
return $row->source_type ?? '-';
}
})
->addColumn('user_name', function($row) {
return $row->user->name ?? '-';
})
->addColumn('created_at', function($row) {
return $row->created_at->format('d M Y, H:i');
})
->addColumn('action', function($row) {
$buttons = '<button type="button" class="btn btn-info btn-sm" onclick="showAuditDetail('.$row->id.')">
Detail
</button>';
return $buttons;
})
// Filtering
->filterColumn('product_name', function($query, $keyword) {
$query->where('products.name', 'like', "%{$keyword}%");
})
->filterColumn('dealer_name', function($query, $keyword) {
$query->where('dealers.name', 'like', "%{$keyword}%");
})
->filterColumn('change_type', function($query, $keyword) {
$query->where('stock_logs.change_type', 'like', "%{$keyword}%");
})
->filterColumn('source_info', function($query, $keyword) {
$query->where(function($q) use ($keyword) {
$q->where('stock_logs.source_type', 'like', "%{$keyword}%")
->orWhere('stock_logs.description', 'like', "%{$keyword}%");
});
})
->filterColumn('user_name', function($query, $keyword) {
$query->where('users.name', 'like', "%{$keyword}%");
})
->filterColumn('created_at', function($query, $keyword) {
$query->whereDate('stock_logs.created_at', 'like', "%{$keyword}%");
})
// Order column mapping
->orderColumn('product_name', function($query, $order) {
return $query->orderBy('products.name', $order);
})
->orderColumn('dealer_name', function($query, $order) {
return $query->orderBy('dealers.name', $order);
})
->orderColumn('user_name', function($query, $order) {
return $query->orderBy('users.name', $order);
})
->orderColumn('created_at', function($query, $order) {
return $query->orderBy('stock_logs.created_at', $order);
})
->orderColumn('quantity_change', function($query, $order) {
return $query->orderBy('stock_logs.quantity_change', $order);
})
->orderColumn('stock_before_after', function($query, $order) {
return $query->orderBy('stock_logs.previous_quantity', $order);
})
->orderColumn('change_type', function($query, $order) {
return $query->orderBy('stock_logs.change_type', $order);
})
->orderColumn('source_info', function($query, $order) {
return $query->orderBy('stock_logs.source_type', $order);
})
->rawColumns(['change_type', 'quantity_change', 'action'])
->make(true);
}
return view('warehouse_management.stock_audit.index', compact('menu', 'dealers', 'products'));
}
public function getDetail(StockLog $stockLog)
{
try {
$stockLog->load([
'stock.product',
'stock.dealer',
'user.role',
'source'
]);
// Format data untuk response
$stockLog->created_at_formatted = $stockLog->created_at->format('d M Y, H:i');
$stockLog->change_type_label = $stockLog->change_type->label();
// Detail source berdasarkan tipe
$sourceDetail = null;
if ($stockLog->source) {
if ($stockLog->source_type === 'App\\Models\\Mutation') {
$mutation = $stockLog->source;
$mutation->load(['fromDealer', 'toDealer', 'requestedBy', 'approvedBy']);
// Format approved_at date if exists
if ($mutation->approved_at) {
$mutation->approved_at_formatted = $mutation->approved_at->format('d M Y, H:i');
}
$sourceDetail = [
'type' => 'mutation',
'data' => $mutation
];
} elseif ($stockLog->source_type === 'App\\Models\\StockOpname') {
$opname = $stockLog->source;
$opname->load(['dealer', 'user']);
$sourceDetail = [
'type' => 'opname',
'data' => $opname
];
}
}
return response()->json([
'success' => true,
'data' => $stockLog,
'source_detail' => $sourceDetail
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => 'Gagal memuat detail audit: ' . $e->getMessage()
], 500);
}
}
}

45
app/Http/Controllers/WorkController.php Normal file → Executable file
View File

@@ -26,16 +26,35 @@ class WorkController extends Controller
$data = DB::table('works as w')->leftJoin('categories as c', 'c.id', '=', 'w.category_id')->select('w.shortname as shortname', 'w.id as work_id', 'w.name as name', 'w.desc as desc', 'c.name as category_name', 'w.category_id as category_id'); $data = DB::table('works as w')->leftJoin('categories as c', 'c.id', '=', 'w.category_id')->select('w.shortname as shortname', 'w.id as work_id', 'w.name as name', 'w.desc as desc', 'c.name as category_name', 'w.category_id as category_id');
return DataTables::of($data)->addIndexColumn() return DataTables::of($data)->addIndexColumn()
->addColumn('action', function($row) use ($menu) { ->addColumn('action', function($row) use ($menu) {
$btn = ''; $btn = '<div class="d-flex flex-row gap-1">';
if(Auth::user()->can('delete', $menu)) { // Products Management Button
$btn .= '<button class="btn btn-danger btn-sm btn-bold" data-action="'. route('work.destroy', $row->work_id) .'" id="destroyWork'. $row->work_id .'" onclick="destroyWork('. $row->work_id .')"> Hapus </button>'; if(Gate::allows('view', $menu)) {
$btn .= '<a href="'. route('work.products.index', ['work' => $row->work_id]) .'" class="btn btn-info btn-sm" title="Kelola Produk">
Produk
</a>';
} }
if(Auth::user()->can('update', $menu)) { // Set Prices Button
$btn .= '<button class="btn btn-warning btn-sm btn-bold" id="editWork'. $row->work_id .'" data-url="'. route('work.edit', $row->work_id) .'" data-action="'. route('work.update', $row->work_id) .'" onclick="editWork('. $row->work_id .')"> Edit </button>'; if(Gate::allows('view', $menu)) {
$btn .= '<a href="'. route('work.set-prices', ['work' => $row->work_id]) .'" class="btn btn-primary btn-sm" title="Set Harga per Dealer">
Harga
</a>';
} }
if(Gate::allows('update', $menu)) {
$btn .= '<button class="btn btn-warning btn-sm" id="editWork'. $row->work_id .'" data-url="'. route('work.edit', $row->work_id) .'" data-action="'. route('work.update', $row->work_id) .'" onclick="editWork('. $row->work_id .')">
Edit
</button>';
}
if(Gate::allows('delete', $menu)) {
$btn .= '<button class="btn btn-danger btn-sm" data-action="'. route('work.destroy', $row->work_id) .'" id="destroyWork'. $row->work_id .'" onclick="destroyWork('. $row->work_id .')">
Hapus
</button>';
}
$btn .= '</div>';
return $btn; return $btn;
}) })
->rawColumns(['action']) ->rawColumns(['action'])
@@ -145,4 +164,20 @@ class WorkController extends Controller
return response()->json($response); return response()->json($response);
} }
/**
* Show the form for setting prices per dealer for a specific work.
*
* @param \App\Models\Work $work
* @return \Illuminate\Http\Response
*/
public function showPrices(Work $work)
{
$menu = Menu::where('link', 'work.index')->first();
abort_if(Gate::denies('view', $menu), 403, 'Unauthorized User');
$dealers = \App\Models\Dealer::all();
return view('back.master.work_prices', compact('work', 'dealers'));
}
} }

View File

@@ -0,0 +1,363 @@
<?php
namespace App\Http\Controllers;
use App\Models\Work;
use App\Models\Dealer;
use App\Models\WorkDealerPrice;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Yajra\DataTables\DataTables;
class WorkDealerPriceController extends Controller
{
/**
* Display a listing of work prices for a specific work
*/
public function index(Request $request, Work $work)
{
if ($request->ajax()) {
$data = WorkDealerPrice::with(['dealer'])
->where('work_id', $work->id)
->select('work_dealer_prices.*');
return DataTables::of($data)
->addIndexColumn()
->addColumn('dealer_name', function($row) {
return $row->dealer->name;
})
->addColumn('formatted_price', function($row) {
return $row->formatted_price;
})
->addColumn('action', function($row) {
$btn = '<div class="d-flex flex-row gap-1">';
$btn .= '<button class="btn btn-warning btn-sm" onclick="editPrice(' . $row->id . ')" title="Edit Harga">
<i class="fa fa-edit"></i>
</button>';
$btn .= '<button class="btn btn-danger btn-sm" onclick="deletePrice(' . $row->id . ')" title="Hapus Harga">
<i class="fa fa-trash"></i>
</button>';
$btn .= '</div>';
return $btn;
})
->rawColumns(['action'])
->make(true);
}
$dealers = Dealer::all();
return view('back.master.work_prices', compact('work', 'dealers'));
}
/**
* Store a newly created price
*/
public function store(Request $request)
{
try {
$request->validate([
'work_id' => 'required|exists:works,id',
'dealer_id' => 'required|exists:dealers,id',
'price' => 'required|numeric|min:0',
'currency' => 'required|string|max:3',
'is_active' => 'nullable|in:0,1',
], [
'work_id.required' => 'ID pekerjaan harus diisi',
'work_id.exists' => 'Pekerjaan tidak ditemukan',
'dealer_id.required' => 'ID dealer harus diisi',
'dealer_id.exists' => 'Dealer tidak ditemukan',
'price.required' => 'Harga harus diisi',
'price.numeric' => 'Harga harus berupa angka',
'price.min' => 'Harga minimal 0',
'currency.required' => 'Mata uang harus diisi',
'currency.max' => 'Mata uang maksimal 3 karakter',
'is_active.in' => 'Status aktif harus 0 atau 1',
]);
// Check if price already exists for this work-dealer combination (including soft deleted)
$existingPrice = WorkDealerPrice::withTrashed()
->where('work_id', $request->work_id)
->where('dealer_id', $request->dealer_id)
->first();
// Also check for active records to prevent duplicates
$activePrice = WorkDealerPrice::where('work_id', $request->work_id)
->where('dealer_id', $request->dealer_id)
->where('id', '!=', $existingPrice ? $existingPrice->id : 0)
->first();
if ($activePrice) {
return response()->json([
'status' => 422,
'message' => 'Harga untuk dealer ini sudah ada. Silakan edit harga yang sudah ada.'
], 422);
}
// Use database transaction to prevent race conditions
DB::beginTransaction();
try {
if ($existingPrice) {
if ($existingPrice->trashed()) {
// Restore soft deleted record and update
$existingPrice->restore();
}
// Update existing price
$existingPrice->update([
'price' => $request->price,
'currency' => $request->currency,
'is_active' => $request->has('is_active') ? (bool)$request->is_active : true,
]);
$price = $existingPrice;
$message = 'Harga berhasil diperbarui';
} else {
// Create new price
$price = WorkDealerPrice::create([
'work_id' => $request->work_id,
'dealer_id' => $request->dealer_id,
'price' => $request->price,
'currency' => $request->currency,
'is_active' => $request->has('is_active') ? (bool)$request->is_active : true,
]);
$message = 'Harga berhasil disimpan';
}
DB::commit();
return response()->json([
'status' => 200,
'data' => $price,
'message' => $message
]);
} catch (\Exception $e) {
DB::rollback();
throw $e;
}
} catch (\Illuminate\Validation\ValidationException $e) {
return response()->json([
'status' => 422,
'message' => 'Validasi gagal',
'errors' => $e->errors()
], 422);
} catch (\Illuminate\Database\QueryException $e) {
// Handle unique constraint violation
if ($e->getCode() == 23000) {
return response()->json([
'status' => 422,
'message' => 'Harga untuk dealer ini sudah ada. Silakan edit harga yang sudah ada.'
], 422);
}
return response()->json([
'status' => 500,
'message' => 'Terjadi kesalahan database: ' . $e->getMessage()
], 500);
} catch (\Exception $e) {
return response()->json([
'status' => 500,
'message' => 'Terjadi kesalahan: ' . $e->getMessage()
], 500);
}
}
/**
* Show the form for editing the specified price
*/
public function edit($id)
{
$price = WorkDealerPrice::with(['work', 'dealer'])->findOrFail($id);
return response()->json([
'status' => 200,
'data' => $price,
'message' => 'Data harga berhasil diambil'
]);
}
/**
* Update the specified price
*/
public function update(Request $request, $id)
{
$request->validate([
'price' => 'required|numeric|min:0',
'currency' => 'required|string|max:3',
'is_active' => 'boolean',
]);
$price = WorkDealerPrice::findOrFail($id);
$price->update($request->all());
return response()->json([
'status' => 200,
'message' => 'Harga berhasil diperbarui'
]);
}
/**
* Remove the specified price
*/
public function destroy($id)
{
try {
$price = WorkDealerPrice::findOrFail($id);
$price->delete(); // Soft delete
return response()->json([
'status' => 200,
'message' => 'Harga berhasil dihapus'
]);
} catch (\Exception $e) {
return response()->json([
'status' => 500,
'message' => 'Terjadi kesalahan saat menghapus harga: ' . $e->getMessage()
], 500);
}
}
/**
* Get price for specific work and dealer
*/
public function getPrice(Request $request)
{
$request->validate([
'work_id' => 'required|exists:works,id',
'dealer_id' => 'required|exists:dealers,id',
]);
$price = WorkDealerPrice::getPriceForWorkAndDealer(
$request->work_id,
$request->dealer_id
);
return response()->json([
'status' => 200,
'data' => $price,
'message' => $price ? 'Harga ditemukan' : 'Harga tidak ditemukan'
]);
}
/**
* Toggle status of a price
*/
public function toggleStatus(Request $request, Work $work)
{
try {
$request->validate([
'dealer_id' => 'required|exists:dealers,id',
'is_active' => 'required|in:0,1,true,false',
], [
'dealer_id.required' => 'ID dealer harus diisi',
'dealer_id.exists' => 'Dealer tidak ditemukan',
'is_active.required' => 'Status aktif harus diisi',
'is_active.in' => 'Status aktif harus 0, 1, true, atau false',
]);
// Convert string values to boolean
$isActive = filter_var($request->is_active, FILTER_VALIDATE_BOOLEAN);
// Find existing price (including soft deleted)
$existingPrice = WorkDealerPrice::withTrashed()
->where('work_id', $work->id)
->where('dealer_id', $request->dealer_id)
->first();
if (!$existingPrice) {
// Create new record with default price 0 if no record exists
$existingPrice = WorkDealerPrice::create([
'work_id' => $work->id,
'dealer_id' => $request->dealer_id,
'price' => 0,
'currency' => 'IDR',
'is_active' => $isActive,
]);
} else {
// Restore if soft deleted
if ($existingPrice->trashed()) {
$existingPrice->restore();
}
// Update status
$existingPrice->update([
'is_active' => $isActive
]);
}
return response()->json([
'status' => 200,
'data' => $existingPrice,
'message' => 'Status berhasil diubah menjadi ' . ($isActive ? 'Aktif' : 'Nonaktif')
]);
} catch (\Illuminate\Validation\ValidationException $e) {
return response()->json([
'status' => 422,
'message' => 'Validasi gagal',
'errors' => $e->errors()
], 422);
} catch (\Exception $e) {
return response()->json([
'status' => 500,
'message' => 'Terjadi kesalahan: ' . $e->getMessage()
], 500);
}
}
/**
* Bulk create prices for a work
*/
public function bulkCreate(Request $request, Work $work)
{
$request->validate([
'prices' => 'required|array',
'prices.*.dealer_id' => 'required|exists:dealers,id',
'prices.*.price' => 'required|numeric|min:0',
'prices.*.currency' => 'required|string|max:3',
]);
DB::beginTransaction();
try {
foreach ($request->prices as $priceData) {
// Check if price already exists
$existingPrice = WorkDealerPrice::where('work_id', $work->id)
->where('dealer_id', $priceData['dealer_id'])
->first();
if ($existingPrice) {
// Update existing price
$existingPrice->update([
'price' => $priceData['price'],
'currency' => $priceData['currency'],
'is_active' => true,
]);
} else {
// Create new price
WorkDealerPrice::create([
'work_id' => $work->id,
'dealer_id' => $priceData['dealer_id'],
'price' => $priceData['price'],
'currency' => $priceData['currency'],
'is_active' => true,
]);
}
}
DB::commit();
return response()->json([
'status' => 200,
'message' => 'Harga berhasil disimpan'
]);
} catch (\Exception $e) {
DB::rollback();
return response()->json([
'status' => 500,
'message' => 'Terjadi kesalahan: ' . $e->getMessage()
], 500);
}
}
}

View File

@@ -0,0 +1,247 @@
<?php
namespace App\Http\Controllers;
use App\Models\Work;
use App\Models\Product;
use App\Models\WorkProduct;
use App\Models\Menu;
use App\Services\StockService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Log;
use Yajra\DataTables\DataTables;
class WorkProductController extends Controller
{
protected $stockService;
public function __construct(StockService $stockService)
{
$this->stockService = $stockService;
}
/**
* Display work products for a specific work
*/
public function index(Request $request, $workId)
{
$menu = Menu::where('link', 'work.index')->first();
abort_if(Gate::denies('view', $menu), 403, 'Unauthorized User');
$work = Work::with('category')->findOrFail($workId);
if ($request->ajax()) {
Log::info('Work products index AJAX request for work ID: ' . $workId);
$workProducts = WorkProduct::with(['product', 'product.category'])
->where('work_id', $workId)
->get();
Log::info('Found ' . $workProducts->count() . ' work products');
return DataTables::of($workProducts)
->addIndexColumn()
->addColumn('product_name', function($row) {
return $row->product->name;
})
->addColumn('product_code', function($row) {
return $row->product->code;
})
->addColumn('product_category', function($row) {
return $row->product->category ? $row->product->category->name : '-';
})
->addColumn('unit', function($row) {
return $row->product->unit;
})
->addColumn('quantity_required', function($row) {
return number_format($row->quantity_required, 2);
})
->addColumn('action', function($row) use ($menu) {
$btn = '<div class="d-flex flex-row gap-1">';
if(Gate::allows('update', $menu)) {
$btn .= '<button class="btn btn-warning btn-sm btn-edit-work-product" data-id="'.$row->id.'">
Edit
</button>';
}
if(Gate::allows('delete', $menu)) {
$btn .= '<button class="btn btn-danger btn-sm btn-delete-work-product" data-id="'.$row->id.'">
Hapus
</button>';
}
$btn .= '</div>';
return $btn;
})
->rawColumns(['action'])
->make(true);
}
$products = Product::where('active', true)->with('category')->get();
return view('back.master.work-products', compact('work', 'products'));
}
/**
* Store work product relationship
*/
public function store(Request $request)
{
$menu = Menu::where('link', 'work.index')->first();
abort_if(Gate::denies('create', $menu), 403, 'Unauthorized User');
$request->validate([
'work_id' => 'required|exists:works,id',
'product_id' => 'required|exists:products,id',
'quantity_required' => 'required|numeric|min:0.01',
'notes' => 'nullable|string'
]);
// Check if combination already exists
$exists = WorkProduct::where('work_id', $request->work_id)
->where('product_id', $request->product_id)
->exists();
if ($exists) {
return response()->json([
'status' => 422,
'message' => 'Produk sudah ditambahkan ke pekerjaan ini'
], 422);
}
WorkProduct::create($request->all());
return response()->json([
'status' => 200,
'message' => 'Produk berhasil ditambahkan ke pekerjaan'
]);
}
/**
* Show work product for editing
*/
public function show($workId, $workProductId)
{
$menu = Menu::where('link', 'work.index')->first();
abort_if(Gate::denies('view', $menu), 403, 'Unauthorized User');
try {
$workProduct = WorkProduct::with(['work', 'product', 'product.category'])
->where('work_id', $workId)
->where('id', $workProductId)
->firstOrFail();
return response()->json([
'status' => 200,
'data' => $workProduct
]);
} catch (\Exception $e) {
Log::error('Error fetching work product: ' . $e->getMessage());
return response()->json([
'status' => 404,
'message' => 'Work product tidak ditemukan'
], 404);
}
}
/**
* Update work product relationship
*/
public function update(Request $request, $workId, $workProductId)
{
$menu = Menu::where('link', 'work.index')->first();
abort_if(Gate::denies('update', $menu), 403, 'Unauthorized User');
$request->validate([
'quantity_required' => 'required|numeric|min:0.01',
'notes' => 'nullable|string'
]);
try {
$workProduct = WorkProduct::where('work_id', $workId)
->where('id', $workProductId)
->firstOrFail();
$workProduct->update($request->only(['quantity_required', 'notes']));
return response()->json([
'status' => 200,
'message' => 'Data produk pekerjaan berhasil diupdate'
]);
} catch (\Exception $e) {
Log::error('Error updating work product: ' . $e->getMessage());
return response()->json([
'status' => 404,
'message' => 'Work product tidak ditemukan'
], 404);
}
}
/**
* Remove work product relationship
*/
public function destroy($workId, $workProductId)
{
$menu = Menu::where('link', 'work.index')->first();
abort_if(Gate::denies('delete', $menu), 403, 'Unauthorized User');
try {
$workProduct = WorkProduct::where('work_id', $workId)
->where('id', $workProductId)
->firstOrFail();
$workProduct->delete();
return response()->json([
'status' => 200,
'message' => 'Produk berhasil dihapus dari pekerjaan'
]);
} catch (\Exception $e) {
Log::error('Error deleting work product: ' . $e->getMessage());
return response()->json([
'status' => 404,
'message' => 'Work product tidak ditemukan'
], 404);
}
}
/**
* Get stock prediction for work
*/
public function stockPrediction(Request $request, $workId)
{
$quantity = $request->get('quantity', 1);
$prediction = $this->stockService->getStockUsagePrediction($workId, $quantity);
return response()->json([
'status' => 200,
'data' => $prediction
]);
}
/**
* Check stock availability for work at specific dealer
*/
public function checkStock(Request $request)
{
$request->validate([
'work_id' => 'required|exists:works,id',
'dealer_id' => 'required|exists:dealers,id',
'quantity' => 'required|integer|min:1'
]);
$availability = $this->stockService->checkStockAvailability(
$request->work_id,
$request->dealer_id,
$request->quantity
);
return response()->json([
'status' => 200,
'data' => $availability
]);
}
}

0
app/Http/Kernel.php Normal file → Executable file
View File

0
app/Http/Middleware/Authenticate.php Normal file → Executable file
View File

0
app/Http/Middleware/EncryptCookies.php Normal file → Executable file
View File

View File

0
app/Http/Middleware/RedirectIfAuthenticated.php Normal file → Executable file
View File

0
app/Http/Middleware/TrimStrings.php Normal file → Executable file
View File

0
app/Http/Middleware/TrustHosts.php Normal file → Executable file
View File

0
app/Http/Middleware/TrustProxies.php Normal file → Executable file
View File

0
app/Http/Middleware/VerifyCsrfToken.php Normal file → Executable file
View File

1
app/Http/Middleware/adminRole.php Normal file → Executable file
View File

@@ -20,7 +20,6 @@ class adminRole
{ {
// check if user can access admin area // check if user can access admin area
$user = Privilege::join('menus AS m', 'm.id', '=', 'privileges.menu_id')->where('m.link', 'adminarea')->where('role_id', Auth::user()->role_id)->where('view', 1)->get(); $user = Privilege::join('menus AS m', 'm.id', '=', 'privileges.menu_id')->where('m.link', 'adminarea')->where('role_id', Auth::user()->role_id)->where('view', 1)->get();
// dd($user);
if (!$user) { if (!$user) {
abort(404); abort(404);
} }

0
app/Http/Middleware/mechanicRole.php Normal file → Executable file
View File

View File

@@ -0,0 +1,59 @@
<?php
namespace App\Http\Requests\KPI;
use Illuminate\Foundation\Http\FormRequest;
use Carbon\Carbon;
class StoreKpiTargetRequest 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.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'user_id' => 'required|exists:users,id',
'target_value' => 'required|integer|min:1',
'description' => 'nullable|string|max:1000',
'is_active' => 'boolean'
];
}
/**
* Get custom messages for validator errors.
*/
public function messages(): array
{
return [
'user_id.required' => 'Mekanik harus dipilih',
'user_id.exists' => 'Mekanik yang dipilih tidak valid',
'target_value.required' => 'Target nilai harus diisi',
'target_value.integer' => 'Target nilai harus berupa angka',
'target_value.min' => 'Target nilai minimal 1',
'description.max' => 'Deskripsi maksimal 1000 karakter',
];
}
/**
* Prepare the data for validation.
*/
protected function prepareForValidation(): void
{
$this->merge([
'is_active' => $this->boolean('is_active', true)
]);
}
}

View File

@@ -0,0 +1,59 @@
<?php
namespace App\Http\Requests\KPI;
use Illuminate\Foundation\Http\FormRequest;
use Carbon\Carbon;
class UpdateKpiTargetRequest 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.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'user_id' => 'required|exists:users,id',
'target_value' => 'required|integer|min:1',
'description' => 'nullable|string|max:1000',
'is_active' => 'boolean'
];
}
/**
* Get custom messages for validator errors.
*/
public function messages(): array
{
return [
'user_id.required' => 'Mekanik harus dipilih',
'user_id.exists' => 'Mekanik yang dipilih tidak valid',
'target_value.required' => 'Target nilai harus diisi',
'target_value.integer' => 'Target nilai harus berupa angka',
'target_value.min' => 'Target nilai minimal 1',
'description.max' => 'Deskripsi maksimal 1000 karakter',
];
}
/**
* Prepare the data for validation.
*/
protected function prepareForValidation(): void
{
$this->merge([
'is_active' => $this->boolean('is_active', true)
]);
}
}

0
app/Models/Category.php Normal file → Executable file
View File

65
app/Models/Dealer.php Normal file → Executable file
View File

@@ -22,4 +22,69 @@ class Dealer extends Model
{ {
return $this->hasMany(Transaction::class, 'dealer_id', 'id'); return $this->hasMany(Transaction::class, 'dealer_id', 'id');
} }
public function opnames(){
return $this->hasMany(Opname::class);
}
public function outgoingMutations()
{
return $this->hasMany(Mutation::class, 'from_dealer_id');
}
public function incomingMutations()
{
return $this->hasMany(Mutation::class, 'to_dealer_id');
}
public function stocks()
{
return $this->hasMany(Stock::class);
}
public function products()
{
return $this->belongsToMany(Product::class, 'stocks', 'dealer_id', 'product_id')
->withPivot('quantity')
->withTimestamps();
}
/**
* Get all work prices for this dealer
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function workPrices()
{
return $this->hasMany(WorkDealerPrice::class);
}
/**
* Get price for specific work
*
* @param int $workId
* @return WorkDealerPrice|null
*/
public function getPriceForWork($workId)
{
return $this->workPrices()
->where('work_id', $workId)
->active()
->first();
}
/**
* Get all active work prices for this dealer
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function activeWorkPrices()
{
return $this->hasMany(WorkDealerPrice::class)->active();
}
public function roles()
{
return $this->belongsToMany(Role::class, 'role_dealer');
}
} }

View File

@@ -0,0 +1,168 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Carbon\Carbon;
class KpiAchievement extends Model
{
use HasFactory;
protected $fillable = [
'user_id',
'kpi_target_id',
'target_value',
'actual_value',
'achievement_percentage',
'year',
'month',
'notes'
];
protected $casts = [
'achievement_percentage' => 'decimal:2',
'year' => 'integer',
'month' => 'integer'
];
protected $attributes = [
'actual_value' => 0,
'achievement_percentage' => 0
];
/**
* Get the user that owns the achievement
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/**
* Get the KPI target for this achievement
*/
public function kpiTarget(): BelongsTo
{
return $this->belongsTo(KpiTarget::class);
}
/**
* Scope to get achievements for specific year and month
*/
public function scopeForPeriod($query, $year, $month)
{
return $query->where('year', $year)->where('month', $month);
}
/**
* Scope to get achievements for current month
*/
public function scopeCurrentMonth($query)
{
return $query->where('year', now()->year)->where('month', now()->month);
}
/**
* Scope to get achievements within year range
*/
public function scopeWithinYearRange($query, $startYear, $endYear)
{
return $query->whereBetween('year', [$startYear, $endYear]);
}
/**
* Scope to get achievements for specific user
*/
public function scopeForUser($query, $userId)
{
return $query->where('user_id', $userId);
}
/**
* Get achievement status
*/
public function getStatusAttribute(): string
{
if ($this->achievement_percentage >= 100) {
return 'exceeded';
} elseif ($this->achievement_percentage >= 80) {
return 'good';
} elseif ($this->achievement_percentage >= 60) {
return 'fair';
} else {
return 'poor';
}
}
/**
* Get status color for display
*/
public function getStatusColorAttribute(): string
{
return match($this->status) {
'exceeded' => 'success',
'good' => 'info',
'fair' => 'warning',
'poor' => 'danger',
default => 'secondary'
};
}
/**
* Get period display name (e.g., "Januari 2024")
*/
public function getPeriodDisplayName(): string
{
$monthNames = [
1 => 'Januari', 2 => 'Februari', 3 => 'Maret', 4 => 'April',
5 => 'Mei', 6 => 'Juni', 7 => 'Juli', 8 => 'Agustus',
9 => 'September', 10 => 'Oktober', 11 => 'November', 12 => 'Desember'
];
return $monthNames[$this->month] . ' ' . $this->year;
}
/**
* Get period start date
*/
public function getPeriodStartDate(): Carbon
{
return Carbon::createFromDate($this->year, $this->month, 1);
}
/**
* Get period end date
*/
public function getPeriodEndDate(): Carbon
{
return Carbon::createFromDate($this->year, $this->month, 1)->endOfMonth();
}
/**
* Get target value (from stored value or from relation)
*/
public function getTargetValueAttribute(): int
{
// Return stored target value if available, otherwise get from relation
return $this->target_value ?? $this->kpiTarget?->target_value ?? 0;
}
/**
* Get current target value from relation (for comparison)
*/
public function getCurrentTargetValueAttribute(): int
{
return $this->kpiTarget?->target_value ?? 0;
}
/**
* Check if stored target value differs from current target value
*/
public function hasTargetValueChanged(): bool
{
return $this->target_value !== $this->current_target_value;
}
}

61
app/Models/KpiTarget.php Normal file
View File

@@ -0,0 +1,61 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Carbon\Carbon;
class KpiTarget extends Model
{
use HasFactory;
protected $fillable = [
'user_id',
'target_value',
'is_active',
'description'
];
protected $casts = [
'is_active' => 'boolean'
];
protected $attributes = [
'is_active' => true
];
/**
* Get the user that owns the KPI target
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/**
* Get the achievements for this target
*/
public function achievements(): HasMany
{
return $this->hasMany(KpiAchievement::class);
}
/**
* Scope to get active targets
*/
public function scopeActive($query)
{
return $query->where('is_active', true);
}
/**
* Check if target is currently active
*/
public function isCurrentlyActive(): bool
{
return $this->is_active;
}
}

0
app/Models/Menu.php Normal file → Executable file
View File

286
app/Models/Mutation.php Executable file
View File

@@ -0,0 +1,286 @@
<?php
namespace App\Models;
use App\Enums\MutationStatus;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\DB;
class Mutation extends Model
{
use HasFactory, SoftDeletes;
protected $fillable = [
'mutation_number',
'from_dealer_id',
'to_dealer_id',
'status',
'requested_by',
'approved_by',
'approved_at',
'approval_notes',
'received_by',
'received_at',
'reception_notes',
'shipping_notes',
'rejection_reason',
'rejected_by',
'rejected_at',
'cancelled_by',
'cancelled_at',
'cancellation_reason'
];
protected $casts = [
'status' => MutationStatus::class,
'approved_at' => 'datetime',
'received_at' => 'datetime',
'rejected_at' => 'datetime',
'cancelled_at' => 'datetime'
];
protected static function booted()
{
static::creating(function ($mutation) {
if (empty($mutation->mutation_number)) {
$mutation->mutation_number = $mutation->generateMutationNumber();
}
});
}
public function fromDealer()
{
return $this->belongsTo(Dealer::class, 'from_dealer_id');
}
public function toDealer()
{
return $this->belongsTo(Dealer::class, 'to_dealer_id');
}
public function requestedBy()
{
return $this->belongsTo(User::class, 'requested_by');
}
public function approvedBy()
{
return $this->belongsTo(User::class, 'approved_by');
}
public function receivedBy()
{
return $this->belongsTo(User::class, 'received_by');
}
public function rejectedBy()
{
return $this->belongsTo(User::class, 'rejected_by');
}
public function cancelledBy()
{
return $this->belongsTo(User::class, 'cancelled_by');
}
public function mutationDetails()
{
return $this->hasMany(MutationDetail::class);
}
public function stockLogs()
{
return $this->morphMany(StockLog::class, 'source');
}
// Helper methods
public function getStatusLabelAttribute()
{
return $this->status->label();
}
public function getStatusColorAttribute()
{
return $this->status->color();
}
public function getTotalItemsAttribute()
{
return $this->mutationDetails()->sum('quantity_requested');
}
public function getTotalApprovedItemsAttribute()
{
return $this->mutationDetails()->sum('quantity_approved');
}
public function canBeReceived()
{
return $this->status === MutationStatus::SENT;
}
public function canBeApproved()
{
return $this->status === MutationStatus::RECEIVED;
}
public function canBeCancelled()
{
return $this->status === MutationStatus::SENT;
}
// Receive mutation by destination dealer
public function receive($userId, $receptionNotes = null)
{
if (!$this->canBeReceived()) {
throw new \Exception('Mutasi tidak dapat diterima dalam status saat ini');
}
$this->update([
'status' => MutationStatus::RECEIVED,
'received_by' => $userId,
'received_at' => now(),
'reception_notes' => $receptionNotes
]);
return $this;
}
// Approve mutation and move stock immediately
public function approve($userId, $approvalNotes = null)
{
if (!$this->canBeApproved()) {
throw new \Exception('Mutasi tidak dapat disetujui dalam status saat ini');
}
DB::beginTransaction();
try {
// Update status to approved first
$this->update([
'status' => MutationStatus::APPROVED,
'approved_by' => $userId,
'approved_at' => now(),
'approval_notes' => $approvalNotes
]);
// Immediately move stock after approval
foreach ($this->mutationDetails as $detail) {
// Process all details that have quantity_requested > 0
// because goods have been sent from source dealer
if ($detail->quantity_requested > 0) {
$this->processStockMovement($detail);
}
}
DB::commit();
} catch (\Exception $e) {
DB::rollback();
throw $e;
}
return $this;
}
// Reject mutation
public function reject($userId, $rejectionReason)
{
if (!$this->canBeApproved()) {
throw new \Exception('Mutasi tidak dapat ditolak dalam status saat ini');
}
$this->update([
'status' => MutationStatus::REJECTED,
'rejected_by' => $userId,
'rejected_at' => now(),
'rejection_reason' => $rejectionReason
]);
return $this;
}
// Cancel mutation
public function cancel($userId, $cancellationReason = null)
{
if (!$this->canBeCancelled()) {
throw new \Exception('Mutasi tidak dapat dibatalkan dalam status saat ini');
}
$this->update([
'status' => MutationStatus::CANCELLED,
'cancelled_by' => $userId,
'cancelled_at' => now(),
'cancellation_reason' => $cancellationReason
]);
return $this;
}
// Complete method removed - Stock moves automatically after approval
private function processStockMovement(MutationDetail $detail)
{
// Kurangi stock dari dealer asal berdasarkan quantity_requested (barang yang dikirim)
$fromStock = Stock::firstOrCreate([
'product_id' => $detail->product_id,
'dealer_id' => $this->from_dealer_id
], ['quantity' => 0]);
if ($fromStock->quantity < $detail->quantity_requested) {
throw new \Exception("Stock tidak mencukupi untuk produk {$detail->product->name} di {$this->fromDealer->name}");
}
$fromStock->updateStock(
$fromStock->quantity - $detail->quantity_requested,
$this,
"Mutasi keluar ke {$this->toDealer->name} - {$this->mutation_number} (Dikirim: {$detail->quantity_requested}, Diterima: {$detail->quantity_approved})"
);
// Tambah stock ke dealer tujuan berdasarkan quantity_approved (barang yang diterima)
$toStock = Stock::firstOrCreate([
'product_id' => $detail->product_id,
'dealer_id' => $this->to_dealer_id
], ['quantity' => 0]);
$toStock->updateStock(
$toStock->quantity + $detail->quantity_approved,
$this,
"Mutasi masuk dari {$this->fromDealer->name} - {$this->mutation_number} (Dikirim: {$detail->quantity_requested}, Diterima: {$detail->quantity_approved})"
);
// Jika ada selisih (kehilangan), catat sebagai stock log terpisah untuk audit
$lostQuantity = $detail->quantity_requested - $detail->quantity_approved;
if ($lostQuantity > 0) {
// Buat stock log untuk barang yang hilang/rusak
StockLog::create([
'stock_id' => $fromStock->id,
'previous_quantity' => $fromStock->quantity + $detail->quantity_requested, // Stock sebelum pengurangan
'new_quantity' => $fromStock->quantity, // Stock setelah pengurangan
'source_type' => get_class($this),
'source_id' => $this->id,
'description' => "Kehilangan/kerusakan saat mutasi ke {$this->toDealer->name} - {$this->mutation_number} (Hilang: {$lostQuantity})",
'user_id' => auth()->id()
]);
}
}
private function generateMutationNumber()
{
$prefix = 'MUT';
$date = now()->format('Ymd');
$lastNumber = static::whereDate('created_at', today())
->whereNotNull('mutation_number')
->latest('id')
->first();
if ($lastNumber) {
$lastSequence = (int) substr($lastNumber->mutation_number, -4);
$sequence = str_pad($lastSequence + 1, 4, '0', STR_PAD_LEFT);
} else {
$sequence = '0001';
}
return "{$prefix}{$date}{$sequence}";
}
}

116
app/Models/MutationDetail.php Executable file
View File

@@ -0,0 +1,116 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class MutationDetail extends Model
{
use HasFactory;
protected $fillable = [
'mutation_id',
'product_id',
'quantity_requested',
'quantity_approved',
'notes'
];
protected $casts = [
'quantity_requested' => 'decimal:2',
'quantity_approved' => 'decimal:2'
];
public function mutation()
{
return $this->belongsTo(Mutation::class);
}
public function product()
{
return $this->belongsTo(Product::class);
}
// Helper methods
public function getQuantityDifferenceAttribute()
{
return $this->quantity_approved - $this->quantity_requested;
}
public function isFullyApproved()
{
return $this->quantity_approved !== null && $this->quantity_approved == $this->quantity_requested;
}
public function isPartiallyApproved()
{
return $this->quantity_approved !== null && $this->quantity_approved > 0 && $this->quantity_approved < $this->quantity_requested;
}
public function isRejected()
{
// Hanya dianggap ditolak jika mutasi sudah di-approve/reject dan quantity_approved = 0
$mutationStatus = $this->mutation->status->value ?? null;
return in_array($mutationStatus, ['approved', 'rejected']) && $this->quantity_approved == 0;
}
public function getApprovalStatusAttribute()
{
$mutationStatus = $this->mutation->status->value ?? null;
// Jika mutasi belum di-approve, semua detail statusnya "Menunggu"
if (!in_array($mutationStatus, ['approved', 'rejected'])) {
return 'Menunggu';
}
// Jika mutasi sudah di-approve, baru cek quantity_approved
if ($this->isFullyApproved()) {
return 'Disetujui Penuh';
} elseif ($this->isPartiallyApproved()) {
return 'Disetujui Sebagian';
} elseif ($this->isRejected()) {
return 'Ditolak';
} else {
return 'Menunggu';
}
}
public function getApprovalStatusColorAttribute()
{
$mutationStatus = $this->mutation->status->value ?? null;
// Jika mutasi belum di-approve, semua detail statusnya "info" (menunggu)
if (!in_array($mutationStatus, ['approved', 'rejected'])) {
return 'info';
}
// Jika mutasi sudah di-approve, baru cek quantity_approved
if ($this->isFullyApproved()) {
return 'success';
} elseif ($this->isPartiallyApproved()) {
return 'warning';
} elseif ($this->isRejected()) {
return 'danger';
} else {
return 'info';
}
}
// Scope untuk filter berdasarkan status approval
public function scopeFullyApproved($query)
{
return $query->whereColumn('quantity_approved', '=', 'quantity_requested');
}
public function scopePartiallyApproved($query)
{
return $query->where('quantity_approved', '>', 0)
->whereColumn('quantity_approved', '<', 'quantity_requested');
}
public function scopeRejected($query)
{
return $query->where('quantity_approved', '=', 0);
}
}

119
app/Models/Opname.php Executable file
View File

@@ -0,0 +1,119 @@
<?php
namespace App\Models;
use App\Enums\OpnameStatus;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class Opname extends Model
{
use HasFactory, SoftDeletes;
protected $fillable = [
'dealer_id',
'opname_date',
'user_id',
'note',
'status',
'approved_by',
'approved_at',
'rejection_note'
];
protected $casts = [
'approved_at' => 'datetime',
'status' => OpnameStatus::class
];
protected static function booted()
{
static::updated(function ($opname) {
// Jika status berubah menjadi approved
if ($opname->isDirty('status') && $opname->status === OpnameStatus::APPROVED) {
// Update stock untuk setiap detail opname
foreach ($opname->details as $detail) {
$stock = Stock::firstOrCreate(
[
'product_id' => $detail->product_id,
'dealer_id' => $opname->dealer_id
],
['quantity' => 0]
);
// Update stock dengan physical_stock dari opname
$stock->updateStock(
$detail->physical_stock,
$opname,
"Stock adjustment from approved opname #{$opname->id}"
);
}
}
});
}
public function dealer()
{
return $this->belongsTo(Dealer::class);
}
public function details()
{
return $this->hasMany(OpnameDetail::class);
}
public function user()
{
return $this->belongsTo(User::class);
}
public function approver()
{
return $this->belongsTo(User::class, 'approved_by');
}
// Method untuk approve opname
public function approve(User $approver)
{
if ($this->status !== OpnameStatus::PENDING) {
throw new \Exception('Only pending opnames can be approved');
}
$this->status = OpnameStatus::APPROVED;
$this->approved_by = $approver->id;
$this->approved_at = now();
$this->save();
return $this;
}
// Method untuk reject opname
public function reject(User $rejector, string $note)
{
if ($this->status !== OpnameStatus::PENDING) {
throw new \Exception('Only pending opnames can be rejected');
}
$this->status = OpnameStatus::REJECTED;
$this->approved_by = $rejector->id;
$this->approved_at = now();
$this->rejection_note = $note;
$this->save();
return $this;
}
// Method untuk submit opname untuk approval
public function submit()
{
if ($this->status !== OpnameStatus::DRAFT) {
throw new \Exception('Only draft opnames can be submitted');
}
$this->status = OpnameStatus::PENDING;
$this->save();
return $this;
}
}

30
app/Models/OpnameDetail.php Executable file
View File

@@ -0,0 +1,30 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class OpnameDetail extends Model
{
use HasFactory, SoftDeletes;
protected $fillable = [
'opname_id',
'product_id',
'physical_stock',
'system_stock',
'difference',
'note',
];
public function opname()
{
return $this->belongsTo(Opname::class);
}
public function product()
{
return $this->belongsTo(Product::class);
}
}

210
app/Models/Postcheck.php Normal file
View File

@@ -0,0 +1,210 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Storage;
class Postcheck extends Model
{
use HasFactory;
protected $fillable = [
'transaction_id',
'postcheck_by',
'postcheck_at',
'police_number',
'spk_number',
'front_image',
'front_image_metadata',
'kilometer',
'pressure_high',
'pressure_low',
'cabin_temperature',
'cabin_temperature_image',
'cabin_temperature_image_metadata',
'ac_condition',
'ac_image',
'ac_image_metadata',
'blower_condition',
'blower_image',
'blower_image_metadata',
'evaporator_condition',
'evaporator_image',
'evaporator_image_metadata',
'compressor_condition',
'postcheck_notes'
];
protected $casts = [
'postcheck_at' => 'datetime',
'kilometer' => 'decimal:2',
'pressure_high' => 'decimal:2',
'pressure_low' => 'decimal:2',
'cabin_temperature' => 'decimal:2',
'front_image_metadata' => 'array',
'cabin_temperature_image_metadata' => 'array',
'ac_image_metadata' => 'array',
'blower_image_metadata' => 'array',
'evaporator_image_metadata' => 'array',
];
/**
* Get the transaction associated with the Postcheck
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function transaction()
{
return $this->belongsTo(Transaction::class);
}
/**
* Get the user who performed the postcheck
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function postcheckBy()
{
return $this->belongsTo(User::class, 'postcheck_by');
}
/**
* Get front image URL
*/
public function getFrontImageUrlAttribute()
{
return $this->front_image ? asset('storage/' . ltrim($this->front_image, '/')) : null;
}
/**
* Get cabin temperature image URL
*/
public function getCabinTemperatureImageUrlAttribute()
{
return $this->cabin_temperature_image ? asset('storage/' . ltrim($this->cabin_temperature_image, '/')) : null;
}
/**
* Get AC image URL
*/
public function getAcImageUrlAttribute()
{
return $this->ac_image ? asset('storage/' . ltrim($this->ac_image, '/')) : null;
}
/**
* Get blower image URL
*/
public function getBlowerImageUrlAttribute()
{
return $this->blower_image ? asset('storage/' . ltrim($this->blower_image, '/')) : null;
}
/**
* Get evaporator image URL
*/
public function getEvaporatorImageUrlAttribute()
{
return $this->evaporator_image ? asset('storage/' . ltrim($this->evaporator_image, '/')) : null;
}
/**
* Delete associated files when model is deleted
*/
protected static function boot()
{
parent::boot();
static::deleting(function ($postcheck) {
$imageFields = [
'front_image', 'cabin_temperature_image', 'ac_image',
'blower_image', 'evaporator_image'
];
foreach ($imageFields as $field) {
if ($postcheck->$field && Storage::disk('public')->exists($postcheck->$field)) {
Storage::disk('public')->delete($postcheck->$field);
}
}
});
}
/**
* Get the AC condition options
*
* @return array
*/
public static function getAcConditionOptions()
{
return ['sudah dikerjakan', 'sudah diganti'];
}
/**
* Get the blower condition options
*
* @return array
*/
public static function getBlowerConditionOptions()
{
return ['sudah dibersihkan atau dicuci', 'sudah diganti'];
}
/**
* Get the evaporator condition options
*
* @return array
*/
public static function getEvaporatorConditionOptions()
{
return ['sudah dikerjakan', 'sudah diganti'];
}
/**
* Get the compressor condition options
*
* @return array
*/
public static function getCompressorConditionOptions()
{
return ['sudah dikerjakan', 'sudah diganti'];
}
/**
* Scope to filter by transaction
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @param int $transactionId
* @return \Illuminate\Database\Eloquent\Builder
*/
public function scopeByTransaction($query, $transactionId)
{
return $query->where('transaction_id', $transactionId);
}
/**
* Scope to filter by user who performed postcheck
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @param int $userId
* @return \Illuminate\Database\Eloquent\Builder
*/
public function scopeByUser($query, $userId)
{
return $query->where('postcheck_by', $userId);
}
/**
* Scope to filter by date range
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @param string $startDate
* @param string $endDate
* @return \Illuminate\Database\Eloquent\Builder
*/
public function scopeByDateRange($query, $startDate, $endDate)
{
return $query->whereBetween('postcheck_at', [$startDate, $endDate]);
}
}

210
app/Models/Precheck.php Normal file
View File

@@ -0,0 +1,210 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Storage;
class Precheck extends Model
{
use HasFactory;
protected $fillable = [
'transaction_id',
'precheck_by',
'precheck_at',
'police_number',
'spk_number',
'front_image',
'front_image_metadata',
'kilometer',
'pressure_high',
'pressure_low',
'cabin_temperature',
'cabin_temperature_image',
'cabin_temperature_image_metadata',
'ac_condition',
'ac_image',
'ac_image_metadata',
'blower_condition',
'blower_image',
'blower_image_metadata',
'evaporator_condition',
'evaporator_image',
'evaporator_image_metadata',
'compressor_condition',
'precheck_notes'
];
protected $casts = [
'precheck_at' => 'datetime',
'kilometer' => 'decimal:2',
'pressure_high' => 'decimal:2',
'pressure_low' => 'decimal:2',
'cabin_temperature' => 'decimal:2',
'front_image_metadata' => 'array',
'cabin_temperature_image_metadata' => 'array',
'ac_image_metadata' => 'array',
'blower_image_metadata' => 'array',
'evaporator_image_metadata' => 'array',
];
/**
* Get the transaction associated with the Precheck
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function transaction()
{
return $this->belongsTo(Transaction::class);
}
/**
* Get the user who performed the precheck
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function precheckBy()
{
return $this->belongsTo(User::class, 'precheck_by');
}
/**
* Get front image URL
*/
public function getFrontImageUrlAttribute()
{
return $this->front_image ? asset('storage/' . ltrim($this->front_image, '/')) : null;
}
/**
* Get cabin temperature image URL
*/
public function getCabinTemperatureImageUrlAttribute()
{
return $this->cabin_temperature_image ? asset('storage/' . ltrim($this->cabin_temperature_image, '/')) : null;
}
/**
* Get AC image URL
*/
public function getAcImageUrlAttribute()
{
return $this->ac_image ? asset('storage/' . ltrim($this->ac_image, '/')) : null;
}
/**
* Get blower image URL
*/
public function getBlowerImageUrlAttribute()
{
return $this->blower_image ? asset('storage/' . ltrim($this->blower_image, '/')) : null;
}
/**
* Get evaporator image URL
*/
public function getEvaporatorImageUrlAttribute()
{
return $this->evaporator_image ? asset('storage/' . ltrim($this->evaporator_image, '/')) : null;
}
/**
* Delete associated files when model is deleted
*/
protected static function boot()
{
parent::boot();
static::deleting(function ($precheck) {
$imageFields = [
'front_image', 'cabin_temperature_image', 'ac_image',
'blower_image', 'evaporator_image'
];
foreach ($imageFields as $field) {
if ($precheck->$field && Storage::disk('public')->exists($precheck->$field)) {
Storage::disk('public')->delete($precheck->$field);
}
}
});
}
/**
* Get the AC condition options
*
* @return array
*/
public static function getAcConditionOptions()
{
return ['kotor', 'rusak', 'baik', 'tidak ada'];
}
/**
* Get the blower condition options
*
* @return array
*/
public static function getBlowerConditionOptions()
{
return ['kotor', 'rusak', 'baik', 'tidak ada'];
}
/**
* Get the evaporator condition options
*
* @return array
*/
public static function getEvaporatorConditionOptions()
{
return ['kotor', 'berlendir', 'bocor', 'bersih'];
}
/**
* Get the compressor condition options
*
* @return array
*/
public static function getCompressorConditionOptions()
{
return ['kotor', 'rusak', 'baik', 'tidak ada'];
}
/**
* Scope to filter by transaction
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @param int $transactionId
* @return \Illuminate\Database\Eloquent\Builder
*/
public function scopeByTransaction($query, $transactionId)
{
return $query->where('transaction_id', $transactionId);
}
/**
* Scope to filter by user who performed precheck
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @param int $userId
* @return \Illuminate\Database\Eloquent\Builder
*/
public function scopeByUser($query, $userId)
{
return $query->where('precheck_by', $userId);
}
/**
* Scope to filter by date range
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @param string $startDate
* @param string $endDate
* @return \Illuminate\Database\Eloquent\Builder
*/
public function scopeByDateRange($query, $startDate, $endDate)
{
return $query->whereBetween('precheck_at', [$startDate, $endDate]);
}
}

0
app/Models/Privilege.php Normal file → Executable file
View File

72
app/Models/Product.php Executable file
View File

@@ -0,0 +1,72 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class Product extends Model
{
use HasFactory, SoftDeletes;
protected $fillable = ['code','name','description','unit','active','product_category_id'];
public function category(){
return $this->belongsTo(ProductCategory::class, 'product_category_id');
}
public function opnameDetails(){
return $this->hasMany(OpnameDetail::class);
}
public function stocks(){
return $this->hasMany(Stock::class);
}
public function mutationDetails()
{
return $this->hasMany(MutationDetail::class);
}
public function dealers()
{
return $this->belongsToMany(Dealer::class, 'stocks', 'product_id', 'dealer_id')
->withPivot('quantity')
->withTimestamps();
}
// Helper method untuk mendapatkan total stock saat ini
public function getCurrentTotalStockAttribute()
{
return $this->stocks()->sum('quantity');
}
// Helper method untuk mendapatkan stock di dealer tertentu
public function getStockByDealer($dealerId)
{
return $this->stocks()->where('dealer_id', $dealerId)->first()?->quantity ?? 0;
}
/**
* Get all works that require this product
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
*/
public function works()
{
return $this->belongsToMany(Work::class, 'work_products')
->withPivot('quantity_required', 'notes')
->withTimestamps();
}
/**
* Get work products pivot records
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function workProducts()
{
return $this->hasMany(WorkProduct::class);
}
}

26
app/Models/ProductCategory.php Executable file
View File

@@ -0,0 +1,26 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class ProductCategory extends Model
{
use HasFactory, SoftDeletes;
protected $fillable = ['name','parent_id'];
public function products(){
return $this->hasMany(Product::class, 'product_category_id');
}
public function parent(){
return $this->belongsTo(ProductCategory::class, 'parent_id');
}
public function children(){
return $this->hasMany(ProductCategory::class,'parent_id');
}
}

16
app/Models/Role.php Normal file → Executable file
View File

@@ -4,6 +4,7 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Log;
class Role extends Model class Role extends Model
{ {
@@ -11,4 +12,19 @@ class Role extends Model
protected $fillable = [ protected $fillable = [
'name' 'name'
]; ];
public function dealers()
{
return $this->belongsToMany(Dealer::class, 'role_dealer');
}
public function users()
{
return $this->hasMany(User::class);
}
public function hasDealer($dealerId)
{
return $this->dealers()->where('dealers.id', $dealerId)->whereNull('dealers.deleted_at')->exists();
}
} }

56
app/Models/Stock.php Executable file
View File

@@ -0,0 +1,56 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Stock extends Model
{
use HasFactory;
protected $fillable = [
'product_id',
'dealer_id',
'quantity'
];
public function product()
{
return $this->belongsTo(Product::class);
}
public function dealer()
{
return $this->belongsTo(Dealer::class);
}
public function stockLogs()
{
return $this->hasMany(StockLog::class);
}
// Method untuk mengupdate stock
public function updateStock($newQuantity, $source, $description = null)
{
$previousQuantity = $this->quantity;
$quantityChange = $newQuantity - $previousQuantity;
$this->quantity = $newQuantity;
$this->save();
// Buat log perubahan
StockLog::create([
'stock_id' => $this->id,
'source_type' => get_class($source),
'source_id' => $source->id,
'previous_quantity' => $previousQuantity,
'new_quantity' => $newQuantity,
'quantity_change' => $quantityChange,
'description' => $description,
'user_id' => auth()->id()
]);
return $this;
}
}

70
app/Models/StockLog.php Executable file
View File

@@ -0,0 +1,70 @@
<?php
namespace App\Models;
use App\Enums\StockChangeType;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class StockLog extends Model
{
use HasFactory;
protected $fillable = [
'stock_id',
'source_type',
'source_id',
'previous_quantity',
'new_quantity',
'quantity_change',
'change_type',
'description',
'user_id'
];
protected $casts = [
'change_type' => StockChangeType::class,
'previous_quantity' => 'decimal:2',
'new_quantity' => 'decimal:2',
'quantity_change' => 'decimal:2'
];
protected static function booted()
{
static::creating(function ($stockLog) {
// Hitung quantity_change
$stockLog->quantity_change = $stockLog->new_quantity - $stockLog->previous_quantity;
// Tentukan change_type berdasarkan quantity_change
if ($stockLog->quantity_change == 0) {
// Jika quantity sama persis (tanpa toleransi)
$stockLog->change_type = StockChangeType::NO_CHANGE;
} else if ($stockLog->quantity_change > 0) {
$stockLog->change_type = StockChangeType::INCREASE;
} else {
$stockLog->change_type = StockChangeType::DECREASE;
}
});
}
public function stock()
{
return $this->belongsTo(Stock::class);
}
public function user()
{
return $this->belongsTo(User::class);
}
public function source()
{
return $this->morphTo();
}
// Helper method untuk mendapatkan label change_type
public function getChangeTypeLabelAttribute()
{
return $this->change_type->label();
}
}

61
app/Models/Transaction.php Normal file → Executable file
View File

@@ -10,16 +10,71 @@ class Transaction extends Model
{ {
use HasFactory, SoftDeletes; use HasFactory, SoftDeletes;
protected $fillable = [ protected $fillable = [
"user_id", "user_sa_id", "work_id", "form", "spk", "police_number", "warranty", "date", "qty", "status", "dealer_id" "user_id", "user_sa_id", "work_id", "form", "spk", "police_number", "warranty", "date", "qty", "status", "dealer_id",
"claimed_at", "claimed_by"
];
protected $casts = [
'claimed_at' => 'datetime',
]; ];
/** /**
* Get the work associated with the Transaction * Get the work associated with the Transaction
* *
* @return \Illuminate\Database\Eloquent\Relations\HasOne * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/ */
public function work() public function work()
{ {
return $this->hasOne(Work::class, 'id', 'work_id'); return $this->belongsTo(Work::class, 'work_id', 'id');
}
/**
* Get the dealer associated with the Transaction
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function dealer()
{
return $this->belongsTo(Dealer::class);
}
/**
* Get the user who created the transaction
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function user()
{
return $this->belongsTo(User::class);
}
/**
* Get the SA user associated with the transaction
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function userSa()
{
return $this->belongsTo(User::class, 'user_sa_id');
}
/**
* Get the precheck associated with the transaction
*
* @return \Illuminate\Database\Eloquent\Relations\HasOne
*/
public function precheck()
{
return $this->hasOne(Precheck::class);
}
/**
* Get the postcheck associated with the transaction
*
* @return \Illuminate\Database\Eloquent\Relations\HasOne
*/
public function postcheck()
{
return $this->hasOne(Postcheck::class);
} }
} }

246
app/Models/User.php Normal file → Executable file
View File

@@ -75,4 +75,250 @@ class User extends Authenticatable
{ {
return $this->hasOne(Dealer::class, 'id', 'dealer_id'); return $this->hasOne(Dealer::class, 'id', 'dealer_id');
} }
/**
* Get the role associated with the User
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function role()
{
return $this->belongsTo(Role::class, 'role_id');
}
/**
* Check if user has a specific role
*
* @param string $roleName
* @return bool
*/
public function hasRole($roleName)
{
// If role_id is 0 or null, user has no role
if (!$this->role_id) {
return false;
}
// For admin role, we can check if user has admin privileges
if (strtolower($roleName) === 'admin') {
return $this->isAdmin();
}
// Load role if not already loaded
if (!$this->relationLoaded('role')) {
$this->load('role');
}
return $this->role && strtolower($this->role->name) === strtolower($roleName);
}
/**
* Check if user is admin by checking admin privileges
*
* @return bool
*/
public function isAdmin()
{
// Check if user has admin privileges by checking if they can access admin area
try {
$adminPrivilege = \App\Models\Privilege::join('menus', 'menus.id', '=', 'privileges.menu_id')
->where('menus.link', 'adminarea')
->where('privileges.role_id', $this->role_id)
->where('privileges.view', 1)
->first();
return $adminPrivilege !== null;
} catch (\Exception $e) {
return false;
}
}
/**
* Get all KPI targets for the User
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function kpiTargets()
{
return $this->hasMany(KpiTarget::class);
}
/**
* Get all KPI achievements for the User
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function kpiAchievements()
{
return $this->hasMany(KpiAchievement::class);
}
/**
* Check if user is mechanic
*
* @return bool
*/
public function isMechanic()
{
return $this->hasRole('mechanic');
}
/**
* Get current KPI target (no longer filtered by year/month)
*
* @return KpiTarget|null
*/
public function getCurrentKpiTarget()
{
return $this->kpiTargets()
->where('is_active', true)
->first();
}
/**
* Get KPI achievement for specific year and month
*
* @param int $year
* @param int $month
* @return KpiAchievement|null
*/
public function getKpiAchievement($year = null, $month = null)
{
$year = $year ?? now()->year;
$month = $month ?? now()->month;
return $this->kpiAchievements()
->where('year', $year)
->where('month', $month)
->first();
}
public function accessibleDealers()
{
if (!$this->role_id) {
return collect();
}
// Load role with dealers
if (!$this->relationLoaded('role')) {
$this->load('role.dealers');
}
// If user has specific dealer_id, check if role allows access
if ($this->dealer_id) {
if ($this->role && $this->role->hasDealer($this->dealer_id)) {
return Dealer::where('id', $this->dealer_id)->get();
}
return collect();
}
// If no specific dealer_id, return all dealers accessible by role
return $this->role ? $this->role->dealers : collect();
}
public function canAccessDealer($dealerId)
{
if (!$this->role_id) {
return false;
}
// Load role with dealers
if (!$this->relationLoaded('role')) {
$this->load('role.dealers');
}
return $this->role && $this->role->hasDealer($dealerId);
}
public function getPrimaryDealer()
{
if ($this->dealer_id && $this->canAccessDealer($this->dealer_id)) {
return $this->dealer;
}
return null;
}
/**
* Get all accessible menus for a specific role
*
* @param int $roleId
* @return \Illuminate\Database\Eloquent\Collection
*/
public static function getAccessibleMenus($roleId)
{
return \App\Models\Privilege::join('menus', 'privileges.menu_id', '=', 'menus.id')
->where('privileges.role_id', $roleId)
->where('privileges.view', 1)
->select('menus.*', 'privileges.view', 'privileges.create', 'privileges.update', 'privileges.delete')
->orderBy('menus.id')
->get();
}
/**
* Get accessible menus for current user
*
* @return \Illuminate\Database\Eloquent\Collection
*/
public function getMyAccessibleMenus()
{
if (!$this->role_id) {
return collect();
}
return self::getAccessibleMenus($this->role_id);
}
/**
* Check if user can access specific menu
*
* @param string $menuLink
* @return bool
*/
public function canAccessMenu($menuLink)
{
if (!$this->role_id) {
return false;
}
return \App\Models\Privilege::join('menus', 'privileges.menu_id', '=', 'menus.id')
->where('privileges.role_id', $this->role_id)
->where('menus.link', $menuLink)
->where('privileges.view', 1)
->exists();
}
/**
* Check if role can access specific menu (static method)
*
* @param int $roleId
* @param string $menuLink
* @return bool
*/
public static function roleCanAccessMenu($roleId, $menuLink)
{
return \App\Models\Privilege::join('menus', 'privileges.menu_id', '=', 'menus.id')
->where('privileges.role_id', $roleId)
->where('menus.link', $menuLink)
->where('privileges.view', 1)
->exists();
}
/**
* Get all prechecks performed by this user
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function prechecks()
{
return $this->hasMany(Precheck::class, 'precheck_by');
}
/**
* Get all postchecks performed by this user
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function postchecks()
{
return $this->hasMany(Postcheck::class, 'postcheck_by');
}
} }

80
app/Models/Work.php Normal file → Executable file
View File

@@ -22,4 +22,84 @@ class Work extends Model
{ {
return $this->hasMany(Transaction::class, 'work_id', 'id'); return $this->hasMany(Transaction::class, 'work_id', 'id');
} }
/**
* Get all products required for this work
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
*/
public function products()
{
return $this->belongsToMany(Product::class, 'work_products')
->withPivot('quantity_required', 'notes')
->withTimestamps();
}
/**
* Get work products pivot records
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function workProducts()
{
return $this->hasMany(WorkProduct::class);
}
/**
* Get the category associated with the Work
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function category()
{
return $this->belongsTo(Category::class);
}
/**
* Get all dealer prices for this work
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function dealerPrices()
{
return $this->hasMany(WorkDealerPrice::class);
}
/**
* Get price for specific dealer
*
* @param int $dealerId
* @return WorkDealerPrice|null
*/
public function getPriceForDealer($dealerId)
{
return $this->dealerPrices()
->where('dealer_id', $dealerId)
->active()
->first();
}
/**
* Get price for specific dealer (including soft deleted)
*
* @param int $dealerId
* @return WorkDealerPrice|null
*/
public function getPriceForDealerWithTrashed($dealerId)
{
return $this->dealerPrices()
->withTrashed()
->where('dealer_id', $dealerId)
->first();
}
/**
* Get all active prices for this work
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function activeDealerPrices()
{
return $this->hasMany(WorkDealerPrice::class)->active();
}
} }

View File

@@ -0,0 +1,81 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class WorkDealerPrice extends Model
{
use HasFactory, SoftDeletes;
protected $fillable = [
'work_id',
'dealer_id',
'price',
'currency',
'is_active'
];
protected $casts = [
'price' => 'decimal:2',
'is_active' => 'boolean',
];
/**
* Get the work associated with the price
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function work()
{
return $this->belongsTo(Work::class);
}
/**
* Get the dealer associated with the price
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function dealer()
{
return $this->belongsTo(Dealer::class);
}
/**
* Scope to get only active prices
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @return \Illuminate\Database\Eloquent\Builder
*/
public function scopeActive($query)
{
return $query->where('is_active', true);
}
/**
* Get formatted price with currency
*
* @return string
*/
public function getFormattedPriceAttribute()
{
return number_format($this->price, 0, ',', '.') . ' ' . $this->currency;
}
/**
* Get price for specific work and dealer
*
* @param int $workId
* @param int $dealerId
* @return WorkDealerPrice|null
*/
public static function getPriceForWorkAndDealer($workId, $dealerId)
{
return static::where('work_id', $workId)
->where('dealer_id', $dealerId)
->active()
->first();
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class WorkProduct extends Model
{
use HasFactory;
protected $fillable = [
'work_id',
'product_id',
'quantity_required',
'notes'
];
protected $casts = [
'quantity_required' => 'decimal:2'
];
public function work()
{
return $this->belongsTo(Work::class);
}
public function product()
{
return $this->belongsTo(Product::class);
}
}

24
app/Providers/AppServiceProvider.php Normal file → Executable file
View File

@@ -3,8 +3,10 @@
namespace App\Providers; namespace App\Providers;
use App\Models\Menu; use App\Models\Menu;
use Carbon\Carbon;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Facades\View; use Illuminate\Support\Facades\View;
use Illuminate\Support\Facades\URL;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
{ {
@@ -15,7 +17,9 @@ class AppServiceProvider extends ServiceProvider
*/ */
public function register() public function register()
{ {
// $this->app->singleton(\App\Services\StockService::class, function ($app) {
return new \App\Services\StockService();
});
} }
/** /**
@@ -25,7 +29,8 @@ class AppServiceProvider extends ServiceProvider
*/ */
public function boot() public function boot()
{ {
View::composer(['layouts.partials.sidebarMenu', 'dashboard', 'dealer_recap', 'back.*'], function ($view) { Carbon::setLocale('id');
View::composer(['layouts.partials.sidebarMenu', 'dashboard', 'dealer_recap', 'back.*', 'warehouse_management.*', 'reports.*', 'kpi.*'], function ($view) {
$menuQuery = Menu::all(); $menuQuery = Menu::all();
$menus = []; $menus = [];
foreach($menuQuery as $menu) { foreach($menuQuery as $menu) {
@@ -34,5 +39,20 @@ class AppServiceProvider extends ServiceProvider
$view->with('menus', $menus); $view->with('menus', $menus);
}); });
// Force HTTPS in production if needed
if (config('app.env') === 'production') {
// Force the application URL to include port if specified
$appUrl = config('app.url');
if ($appUrl) {
URL::forceRootUrl($appUrl);
// Parse URL to check if it's HTTPS
$parsedUrl = parse_url($appUrl);
if (isset($parsedUrl['scheme']) && $parsedUrl['scheme'] === 'https') {
URL::forceScheme('https');
}
}
}
} }
} }

0
app/Providers/AuthServiceProvider.php Normal file → Executable file
View File

0
app/Providers/BroadcastServiceProvider.php Normal file → Executable file
View File

0
app/Providers/EventServiceProvider.php Normal file → Executable file
View File

0
app/Providers/RouteServiceProvider.php Normal file → Executable file
View File

454
app/Services/KpiService.php Normal file
View File

@@ -0,0 +1,454 @@
<?php
namespace App\Services;
use App\Models\User;
use App\Models\Transaction;
use App\Models\KpiTarget;
use App\Models\KpiAchievement;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class KpiService
{
/**
* Calculate KPI achievement for a user
*
* @param User $user
* @param int $year
* @param int $month
* @return KpiAchievement|null
*/
public function calculateKpiAchievement(User $user, $year = null, $month = null)
{
$year = $year ?? now()->year;
$month = $month ?? now()->month;
// Get current KPI target (no longer filtered by year/month)
$kpiTarget = $user->kpiTargets()
->where('is_active', true)
->first();
if (!$kpiTarget) {
Log::info("No KPI target found for user {$user->id}");
return null;
}
// Calculate actual value based on month
$actualValue = $this->getActualWorkCount($user, $year, $month);
// Calculate percentage
$achievementPercentage = $kpiTarget->target_value > 0
? ($actualValue / $kpiTarget->target_value) * 100
: 0;
// Save or update achievement with target value stored directly
return KpiAchievement::updateOrCreate(
[
'user_id' => $user->id,
'year' => $year,
'month' => $month
],
[
'kpi_target_id' => $kpiTarget->id,
'target_value' => $kpiTarget->target_value, // Store target value directly for historical tracking
'actual_value' => $actualValue,
'achievement_percentage' => $achievementPercentage
]
);
}
/**
* Get actual work count for a user in specific month
*
* @param User $user
* @param int $year
* @param int $month
* @return int
*/
private function getActualWorkCount(User $user, $year, $month)
{
return Transaction::where('user_id', $user->id)
->whereIn('status', [0, 1]) // pending (0) and completed (1)
->whereYear('date', $year)
->whereMonth('date', $month)
->sum('qty');
}
/**
* Generate KPI report for a user
*
* @param User $user
* @param int|null $year
* @param int|null $month
* @return array
*/
public function generateKpiReport(User $user, $year = null, $month = null)
{
$year = $year ?? now()->year;
$month = $month ?? now()->month;
$achievements = $user->kpiAchievements()
->where('year', $year)
->where('month', $month)
->orderBy('month')
->get();
$target = $user->kpiTargets()
->where('is_active', true)
->first();
return [
'user' => $user,
'target' => $target,
'achievements' => $achievements,
'summary' => $this->calculateSummary($achievements),
'period' => [
'year' => $year,
'month' => $month,
'period_name' => $this->getMonthName($month) . ' ' . $year
]
];
}
/**
* Calculate summary statistics for achievements
*
* @param \Illuminate\Database\Eloquent\Collection $achievements
* @return array
*/
private function calculateSummary($achievements)
{
if ($achievements->isEmpty()) {
return [
'total_target' => 0,
'total_actual' => 0,
'average_achievement' => 0,
'best_period' => null,
'worst_period' => null,
'total_periods' => 0,
'achievement_rate' => 0
];
}
$totalTarget = $achievements->sum('target_value');
$totalActual = $achievements->sum('actual_value');
$averageAchievement = $achievements->avg('achievement_percentage');
$totalPeriods = $achievements->count();
$achievementRate = $totalPeriods > 0 ? ($achievements->where('achievement_percentage', '>=', 100)->count() / $totalPeriods) * 100 : 0;
$bestPeriod = $achievements->sortByDesc('achievement_percentage')->first();
$worstPeriod = $achievements->sortBy('achievement_percentage')->first();
return [
'total_target' => $totalTarget,
'total_actual' => $totalActual,
'average_achievement' => round($averageAchievement, 2),
'best_period' => $bestPeriod,
'worst_period' => $worstPeriod,
'total_periods' => $totalPeriods,
'achievement_rate' => round($achievementRate, 2)
];
}
/**
* Get KPI statistics for all mechanics
*
* @param int|null $year
* @param int|null $month
* @return array
*/
public function getMechanicsKpiStats($year = null, $month = null)
{
$year = $year ?? now()->year;
$month = $month ?? now()->month;
$mechanics = User::whereHas('role', function($query) {
$query->where('name', 'mechanic');
})->get();
$stats = [];
foreach ($mechanics as $mechanic) {
$report = $this->generateKpiReport($mechanic, $year, $month);
$stats[] = [
'user' => $mechanic,
'summary' => $report['summary'],
'target' => $report['target']
];
}
return $stats;
}
/**
* Auto-calculate KPI achievements for all mechanics
*
* @param int|null $year
* @param int|null $month
* @return array
*/
public function autoCalculateAllMechanics($year = null, $month = null)
{
$year = $year ?? now()->year;
$month = $month ?? now()->month;
$mechanics = User::whereHas('role', function($query) {
$query->where('name', 'mechanic');
})->get();
$results = [];
foreach ($mechanics as $mechanic) {
try {
$achievement = $this->calculateKpiAchievement($mechanic, $year, $month);
$results[] = [
'user_id' => $mechanic->id,
'user_name' => $mechanic->name,
'success' => true,
'achievement' => $achievement
];
} catch (\Exception $e) {
Log::error("Failed to calculate KPI for user {$mechanic->id}: " . $e->getMessage());
$results[] = [
'user_id' => $mechanic->id,
'user_name' => $mechanic->name,
'success' => false,
'error' => $e->getMessage()
];
}
}
return $results;
}
/**
* Get KPI trend data for chart
*
* @param User $user
* @param int $months
* @return array
*/
public function getKpiTrendData(User $user, $months = 12)
{
$endDate = now();
$startDate = $endDate->copy()->subMonths($months);
$achievements = $user->kpiAchievements()
->where(function($query) use ($startDate, $endDate) {
$query->where(function($q) use ($startDate, $endDate) {
$q->where('year', '>', $startDate->year)
->orWhere(function($subQ) use ($startDate, $endDate) {
$subQ->where('year', $startDate->year)
->where('month', '>=', $startDate->month);
});
})
->where(function($q) use ($endDate) {
$q->where('year', '<', $endDate->year)
->orWhere(function($subQ) use ($endDate) {
$subQ->where('year', $endDate->year)
->where('month', '<=', $endDate->month);
});
});
})
->orderBy('year')
->orderBy('month')
->get();
$trendData = [];
foreach ($achievements as $achievement) {
$trendData[] = [
'period' => $achievement->getPeriodDisplayName(),
'target' => $achievement->target_value,
'actual' => $achievement->actual_value,
'percentage' => $achievement->achievement_percentage,
'status' => $achievement->status
];
}
return $trendData;
}
/**
* Get month name in Indonesian
*
* @param int $month
* @return string
*/
private function getMonthName($month)
{
$monthNames = [
1 => 'Januari', 2 => 'Februari', 3 => 'Maret', 4 => 'April',
5 => 'Mei', 6 => 'Juni', 7 => 'Juli', 8 => 'Agustus',
9 => 'September', 10 => 'Oktober', 11 => 'November', 12 => 'Desember'
];
return $monthNames[$month] ?? 'Unknown';
}
/**
* Get KPI summary for dashboard
*
* @param User $user
* @return array
*/
public function getKpiSummary(User $user)
{
$currentYear = now()->year;
$currentMonth = now()->month;
// Get current month achievement
$currentAchievement = $user->kpiAchievements()
->where('year', $currentYear)
->where('month', $currentMonth)
->first();
// Get current month target (no longer filtered by year/month)
$currentTarget = $user->kpiTargets()
->where('is_active', true)
->first();
// Get last 6 months achievements
$recentAchievements = $user->kpiAchievements()
->where(function($query) use ($currentYear, $currentMonth) {
$query->where('year', '>', $currentYear - 1)
->orWhere(function($q) use ($currentYear, $currentMonth) {
$q->where('year', $currentYear)
->where('month', '>=', max(1, $currentMonth - 5));
});
})
->orderBy('year', 'desc')
->orderBy('month', 'desc')
->limit(6)
->get();
return [
'current_achievement' => $currentAchievement,
'current_target' => $currentTarget,
'recent_achievements' => $recentAchievements,
'current_percentage' => $currentAchievement ? $currentAchievement->achievement_percentage : 0,
'is_on_track' => $currentAchievement ? $currentAchievement->achievement_percentage >= 100 : false
];
}
/**
* Get claimed transactions count for a mechanic
*
* @param User $user
* @param int|null $year
* @param int|null $month
* @return int
*/
public function getClaimedTransactionsCount(User $user, $year = null, $month = null)
{
$year = $year ?? now()->year;
$month = $month ?? now()->month;
return Transaction::where('claimed_by', $user->id)
->whereNotNull('claimed_at')
->whereYear('claimed_at', $year)
->whereMonth('claimed_at', $month)
->sum('qty');
}
/**
* Calculate KPI achievement including claimed transactions
*
* @param User $user
* @param int $year
* @param int $month
* @return KpiAchievement|null
*/
public function calculateKpiAchievementWithClaims(User $user, $year = null, $month = null)
{
$year = $year ?? now()->year;
$month = $month ?? now()->month;
// Get current KPI target
$kpiTarget = $user->kpiTargets()
->where('is_active', true)
->first();
if (!$kpiTarget) {
Log::info("No KPI target found for user {$user->id}");
return null;
}
// Calculate actual value including claimed transactions
$actualValue = $this->getActualWorkCountWithClaims($user, $year, $month);
// Calculate percentage
$achievementPercentage = $kpiTarget->target_value > 0
? ($actualValue / $kpiTarget->target_value) * 100
: 0;
// Save or update achievement
return KpiAchievement::updateOrCreate(
[
'user_id' => $user->id,
'year' => $year,
'month' => $month
],
[
'kpi_target_id' => $kpiTarget->id,
'target_value' => $kpiTarget->target_value,
'actual_value' => $actualValue,
'achievement_percentage' => $achievementPercentage
]
);
}
/**
* Get actual work count including claimed transactions
*
* @param User $user
* @param int $year
* @param int $month
* @return int
*/
private function getActualWorkCountWithClaims(User $user, $year, $month)
{
// Get transactions created by the user (including pending and completed)
$createdTransactions = Transaction::where('user_id', $user->id)
->whereIn('status', [0, 1]) // pending (0) and completed (1)
->whereYear('date', $year)
->whereMonth('date', $month)
->sum('qty');
// Get transactions claimed by the user (excluding those created by the same user to avoid double counting)
$claimedTransactions = Transaction::where('claimed_by', $user->id)
->whereNotNull('claimed_at')
->where('user_id', '!=', $user->id) // Exclude transactions created by the same user
->whereYear('claimed_at', $year)
->whereMonth('claimed_at', $month)
->sum('qty');
return $createdTransactions + $claimedTransactions;
}
/**
* Get KPI summary including claimed transactions
*
* @param User $user
* @return array
*/
public function getKpiSummaryWithClaims(User $user)
{
$currentYear = now()->year;
$currentMonth = now()->month;
// Calculate current month achievement including claims
$currentAchievement = $this->calculateKpiAchievementWithClaims($user, $currentYear, $currentMonth);
// Get current month target
$currentTarget = $user->kpiTargets()
->where('is_active', true)
->first();
return [
'current_achievement' => $currentAchievement,
'current_target' => $currentTarget,
'current_percentage' => $currentAchievement ? $currentAchievement->achievement_percentage : 0,
'is_on_track' => $currentAchievement ? $currentAchievement->achievement_percentage >= 100 : false
];
}
}

View File

@@ -0,0 +1,292 @@
<?php
namespace App\Services;
use App\Models\Product;
use App\Models\Dealer;
use App\Models\Stock;
use App\Models\StockLog;
use App\Models\Role;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Auth;
class StockReportService
{
/**
* Get stock report data for all products and dealers on a specific date
*/
public function getStockReportData($targetDate = null)
{
$targetDate = $targetDate ? Carbon::parse($targetDate) : now();
// Get dealers based on user role
$dealers = $this->getDealersBasedOnUserRole();
// Get all active products
$products = Product::where('active', true)
->with(['category'])
->orderBy('name')
->get();
$data = [];
foreach ($products as $product) {
$row = [
'product_id' => $product->id,
'product_code' => $product->code,
'product_name' => $product->name,
'category_name' => $product->category ? $product->category->name : '-',
'unit' => $product->unit ?? '-',
'total_stock' => 0
];
// Calculate stock for each dealer on the target date
foreach ($dealers as $dealer) {
$stockOnDate = $this->getStockOnDate($product->id, $dealer->id, $targetDate);
$row["dealer_{$dealer->id}"] = $stockOnDate;
$row['total_stock'] += $stockOnDate;
}
$data[] = $row;
}
return [
'data' => $data,
'dealers' => $dealers
];
}
/**
* Get stock quantity for a specific product and dealer on a given date
*/
public function getStockOnDate($productId, $dealerId, $targetDate)
{
// Get the latest stock log entry before or on the target date
$latestStockLog = StockLog::whereHas('stock', function($query) use ($productId, $dealerId) {
$query->where('product_id', $productId)
->where('dealer_id', $dealerId);
})
->where('created_at', '<=', $targetDate->endOfDay())
->orderBy('created_at', 'desc')
->first();
if ($latestStockLog) {
// Return the new_quantity from the latest log entry
return $latestStockLog->new_quantity;
}
// If no stock log found, check if there's a current stock record
$currentStock = Stock::where('product_id', $productId)
->where('dealer_id', $dealerId)
->first();
if ($currentStock) {
// Check if the stock was created before or on the target date
if ($currentStock->created_at <= $targetDate) {
return $currentStock->quantity;
}
}
// No stock data available for this date
return 0;
}
/**
* Get optimized stock data using a single query approach
*/
public function getOptimizedStockReportData($targetDate = null)
{
$targetDate = $targetDate ? Carbon::parse($targetDate) : now();
// Get dealers based on user role
$dealers = $this->getDealersBasedOnUserRole();
// Get all active products with their stock data
$products = Product::where('active', true)
->with(['category', 'stocks.dealer'])
->orderBy('name')
->get();
$data = [];
foreach ($products as $product) {
$row = [
'product_id' => $product->id,
'product_code' => $product->code,
'product_name' => $product->name,
'category_name' => $product->category ? $product->category->name : '-',
'unit' => $product->unit ?? '-',
'total_stock' => 0
];
// Calculate stock for each dealer on the target date
foreach ($dealers as $dealer) {
$stockOnDate = $this->getOptimizedStockOnDate($product->id, $dealer->id, $targetDate);
$row["dealer_{$dealer->id}"] = $stockOnDate;
$row['total_stock'] += $stockOnDate;
}
$data[] = $row;
}
return [
'data' => $data,
'dealers' => $dealers
];
}
/**
* Get dealers based on logged-in user's role
*/
public function getDealersBasedOnUserRole()
{
// Get current authenticated user
$user = Auth::user();
if (!$user) {
Log::warning('No authenticated user found, returning all dealers');
return Dealer::whereNull('deleted_at')->orderBy('name')->get();
}
Log::info('Getting dealers for user:', [
'user_id' => $user->id,
'user_role_id' => $user->role_id,
'user_dealer_id' => $user->dealer_id
]);
// If user has role, check role type and dealer access
if ($user->role_id) {
$role = Role::with(['dealers' => function($query) {
$query->whereNull('dealers.deleted_at'); // Only active dealers
}])->find($user->role_id);
if ($role) {
// Check if role is admin type
if ($this->isAdminRole($role)) {
// Admin role - check if has pivot dealers
if ($role->dealers->count() > 0) {
// Admin with pivot dealers - return pivot dealers only
Log::info('Admin role with pivot dealers, returning pivot dealers only');
$dealers = $role->dealers()->whereNull('dealers.deleted_at')->orderBy('name')->get();
Log::info('Returning pivot dealers for admin:', $dealers->pluck('name')->toArray());
return $dealers;
} else {
// Admin without pivot dealers - return all dealers
Log::info('Admin role without pivot dealers, returning all dealers');
$allDealers = Dealer::whereNull('deleted_at')->orderBy('name')->get();
Log::info('Returning all dealers for admin:', $allDealers->pluck('name')->toArray());
return $allDealers;
}
}
// Non-admin role - return dealers from role pivot
if ($role->dealers->count() > 0) {
Log::info('Non-admin role with dealers, returning role dealers');
$dealers = $role->dealers()->whereNull('dealers.deleted_at')->orderBy('name')->get();
Log::info('Returning dealers from role:', $dealers->pluck('name')->toArray());
return $dealers;
}
}
}
// If user has specific dealer_id but no role dealers, check if they can access their dealer_id
if ($user->dealer_id) {
Log::info('User has specific dealer_id:', ['dealer_id' => $user->dealer_id]);
if ($user->role_id) {
$role = Role::with(['dealers' => function($query) {
$query->whereNull('dealers.deleted_at'); // Only active dealers
}])->find($user->role_id);
if ($role && $role->hasDealer($user->dealer_id)) {
Log::info('User can access their dealer_id, returning single dealer');
$dealer = Dealer::where('id', $user->dealer_id)->whereNull('deleted_at')->orderBy('name')->get();
Log::info('Returning dealer:', $dealer->pluck('name')->toArray());
return $dealer;
} else {
Log::info('User cannot access their dealer_id');
}
}
Log::info('User has dealer_id but no role or no access, returning empty');
return collect();
}
// Fallback: return all dealers if no restrictions
Log::info('No restrictions found, returning all dealers');
$allDealers = Dealer::whereNull('deleted_at')->orderBy('name')->get();
Log::info('Returning all dealers:', $allDealers->pluck('name')->toArray());
return $allDealers;
}
/**
* Check if role is admin type (should show all dealers if no pivot)
*/
private function isAdminRole($role)
{
// Define admin role names that should have access to all dealers
$adminRoleNames = [
'admin'
];
// Check if role name contains admin keywords (but not "area")
$roleName = strtolower(trim($role->name));
foreach ($adminRoleNames as $adminName) {
if (strpos($roleName, $adminName) !== false && strpos($roleName, 'area') === false) {
Log::info('Role identified as admin type:', ['role_name' => $role->name]);
return true;
}
}
// Role with "area" in name should use pivot dealers, not all dealers
if (strpos($roleName, 'area') !== false) {
Log::info('Role contains "area", treating as area role (use pivot dealers):', ['role_name' => $role->name]);
return false;
}
Log::info('Role is not admin type:', ['role_name' => $role->name]);
return false;
}
/**
* Optimized method to get stock on date using subquery
*/
private function getOptimizedStockOnDate($productId, $dealerId, $targetDate)
{
try {
// Use a subquery to get the latest stock log entry efficiently
$latestStockLog = DB::table('stock_logs')
->join('stocks', 'stock_logs.stock_id', '=', 'stocks.id')
->where('stocks.product_id', $productId)
->where('stocks.dealer_id', $dealerId)
->where('stock_logs.created_at', '<=', $targetDate->endOfDay())
->orderBy('stock_logs.created_at', 'desc')
->select('stock_logs.new_quantity')
->first();
if ($latestStockLog) {
return $latestStockLog->new_quantity;
}
// If no stock log found, check current stock
$currentStock = Stock::where('product_id', $productId)
->where('dealer_id', $dealerId)
->first();
if ($currentStock && $currentStock->created_at <= $targetDate) {
return $currentStock->quantity;
}
return 0;
} catch (\Exception $e) {
// Log error and return 0
Log::error('Error getting stock on date: ' . $e->getMessage(), [
'product_id' => $productId,
'dealer_id' => $dealerId,
'target_date' => $targetDate
]);
return 0;
}
}
}

View File

@@ -0,0 +1,288 @@
<?php
namespace App\Services;
use App\Models\Stock;
use App\Models\Work;
use App\Models\Transaction;
use App\Models\StockLog;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Exception;
class StockService
{
/**
* Check if dealer has sufficient stock for work
* Modified to always return available = true (allow negative stock)
*
* @param int $workId
* @param int $dealerId
* @param int $workQuantity
* @return array
*/
public function checkStockAvailability($workId, $dealerId, $workQuantity = 1)
{
$work = Work::with('products')->find($workId);
if (!$work) {
return [
'available' => true,
'message' => 'Pekerjaan tidak ditemukan, tapi transaksi diizinkan',
'details' => []
];
}
$stockDetails = [];
foreach ($work->products as $product) {
$requiredQuantity = $product->pivot->quantity_required * $workQuantity;
$availableStock = $product->getStockByDealer($dealerId);
$stockDetails[] = [
'product_id' => $product->id,
'product_name' => $product->name,
'required_quantity' => $requiredQuantity,
'available_stock' => $availableStock,
'is_available' => true // Always true - allow negative stock
];
}
return [
'available' => true, // Always return true - allow negative stock
'message' => 'Stock tersedia (negative stock allowed)',
'details' => $stockDetails
];
}
/**
* Reduce stock when work transaction is completed
*
* @param Transaction $transaction
* @return bool
* @throws Exception
*/
public function reduceStockForTransaction(Transaction $transaction)
{
try {
return DB::transaction(function () use ($transaction) {
$work = $transaction->work;
if (!$work) {
// If work not found, just return true to allow transaction to proceed
return true;
}
$work->load('products');
if ($work->products->isEmpty()) {
// No products required for this work, return true
return true;
}
foreach ($work->products as $product) {
$requiredQuantity = $product->pivot->quantity_required * $transaction->qty;
Log::info('Processing stock reduction', [
'transaction_id' => $transaction->id,
'product_id' => $product->id,
'product_name' => $product->name,
'dealer_id' => $transaction->dealer_id,
'required_quantity' => $requiredQuantity,
'transaction_qty' => $transaction->qty
]);
$stock = Stock::where('product_id', $product->id)
->where('dealer_id', $transaction->dealer_id)
->first();
if (!$stock) {
Log::info('Stock not found, creating new stock record', [
'product_id' => $product->id,
'dealer_id' => $transaction->dealer_id
]);
try {
// Create new stock record with 0 quantity if doesn't exist
$stock = Stock::create([
'product_id' => $product->id,
'dealer_id' => $transaction->dealer_id,
'quantity' => 0
]);
Log::info('New stock record created', [
'stock_id' => $stock->id,
'initial_quantity' => $stock->quantity
]);
} catch (\Exception $createException) {
Log::warning('Failed to create stock, using firstOrCreate', [
'error' => $createException->getMessage()
]);
// If creating stock fails, try to use firstOrCreate instead
$stock = Stock::firstOrCreate([
'product_id' => $product->id,
'dealer_id' => $transaction->dealer_id
], [
'quantity' => 0
]);
}
} else {
Log::info('Existing stock found', [
'stock_id' => $stock->id,
'current_quantity' => $stock->quantity
]);
}
// Allow negative stock - reduce regardless of current quantity
$newQuantity = $stock->quantity - $requiredQuantity;
Log::info('Updating stock quantity', [
'stock_id' => $stock->id,
'previous_quantity' => $stock->quantity,
'required_quantity' => $requiredQuantity,
'new_quantity' => $newQuantity
]);
try {
$stock->updateStock(
$newQuantity,
$transaction,
"Stock reduced for work: {$work->name} (Transaction #{$transaction->id}) - Allow negative stock"
);
Log::info('Stock update successful via updateStock method');
} catch (\Exception $updateException) {
Log::warning('updateStock method failed, using fallback', [
'error' => $updateException->getMessage()
]);
// If updateStock fails, try direct update but still create stock log
$previousQuantity = $stock->quantity;
$stock->quantity = $newQuantity;
$stock->save();
// Manually create stock log since updateStock failed
try {
$stockLog = \App\Models\StockLog::create([
'stock_id' => $stock->id,
'source_type' => get_class($transaction),
'source_id' => $transaction->id,
'previous_quantity' => $previousQuantity,
'new_quantity' => $newQuantity,
'quantity_change' => $newQuantity - $previousQuantity,
'description' => "Stock reduced for work: {$work->name} (Transaction #{$transaction->id}) - Allow negative stock (manual log)",
'user_id' => auth()->id()
]);
Log::info('Manual stock log created successfully', [
'stock_log_id' => $stockLog->id,
'previous_quantity' => $previousQuantity,
'new_quantity' => $newQuantity
]);
} catch (\Exception $logException) {
// Log the error but don't fail the transaction
Log::warning('Failed to create stock log: ' . $logException->getMessage());
}
}
}
return true;
});
} catch (\Exception $e) {
// Log the error but don't throw it - allow transaction to proceed
Log::error('StockService::reduceStockForTransaction error: ' . $e->getMessage(), [
'transaction_id' => $transaction->id,
'work_id' => $transaction->work_id,
'dealer_id' => $transaction->dealer_id
]);
// Return true to allow transaction to proceed even if stock reduction fails
return true;
}
}
/**
* Restore stock when work transaction is cancelled/reversed
*
* @param Transaction $transaction
* @return bool
* @throws Exception
*/
public function restoreStockForTransaction(Transaction $transaction)
{
return DB::transaction(function () use ($transaction) {
$work = $transaction->work;
if (!$work) {
throw new Exception('Work not found for transaction');
}
$work->load('products');
if ($work->products->isEmpty()) {
return true;
}
foreach ($work->products as $product) {
$restoreQuantity = $product->pivot->quantity_required * $transaction->qty;
$stock = Stock::where('product_id', $product->id)
->where('dealer_id', $transaction->dealer_id)
->first();
if (!$stock) {
// Create new stock record if doesn't exist
$stock = Stock::create([
'product_id' => $product->id,
'dealer_id' => $transaction->dealer_id,
'quantity' => 0
]);
}
// Restore stock
$newQuantity = $stock->quantity + $restoreQuantity;
$stock->updateStock(
$newQuantity,
$transaction,
"Stock restored from cancelled work: {$work->name} (Transaction #{$transaction->id})"
);
}
return true;
});
}
/**
* Get stock usage prediction for a work
*
* @param int $workId
* @param int $quantity
* @return array
*/
public function getStockUsagePrediction($workId, $quantity = 1)
{
$work = Work::with('products')->find($workId);
if (!$work) {
return [];
}
$predictions = [];
foreach ($work->products as $product) {
$totalRequired = $product->pivot->quantity_required * $quantity;
$predictions[] = [
'product_id' => $product->id,
'product_name' => $product->name,
'product_code' => $product->code,
'unit' => $product->unit,
'quantity_per_work' => $product->pivot->quantity_required,
'total_quantity_needed' => $totalRequired,
'notes' => $product->pivot->notes
];
}
return $predictions;
}
}

View File

@@ -0,0 +1,798 @@
<?php
namespace App\Services;
use App\Models\Work;
use App\Models\User;
use App\Models\Transaction;
use App\Models\Dealer;
use App\Models\Role;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class TechnicianReportService
{
/**
* Get technician report data for all works and mechanics on a specific date range
*/
public function getTechnicianReportData($dealerId = null, $startDate = null, $endDate = null)
{
try {
// Get current authenticated user
$user = auth()->user();
if (!$user) {
return [
'data' => [],
'mechanics' => collect(),
'works' => collect()
];
}
// Validate dealer access
if ($dealerId) {
if ($user->dealer_id) {
// User has specific dealer_id, check if they can access the requested dealer
if ($user->dealer_id != $dealerId) {
if ($user->role_id) {
$role = Role::with('dealers')->find($user->role_id);
if (!$role || !$role->hasDealer($dealerId)) {
// User doesn't have access to this dealer
return [
'data' => [],
'mechanics' => collect(),
'works' => collect()
];
}
} else {
// User has dealer_id but no role, can only access their dealer
return [
'data' => [],
'mechanics' => collect(),
'works' => collect()
];
}
}
} else if ($user->role_id) {
// User has role, check if they can access the requested dealer
$role = Role::with('dealers')->find($user->role_id);
if (!$role || !$role->hasDealer($dealerId)) {
// User doesn't have access to this dealer
return [
'data' => [],
'mechanics' => collect(),
'works' => collect()
];
}
}
}
Log::info('Getting technician report data', [
'user_id' => $user->id,
'dealer_id' => $dealerId,
'start_date' => $startDate,
'end_date' => $endDate
]);
// Get all works with category in single query
$works = Work::with(['category'])
->orderBy('name')
->get();
// Get mechanics based on dealer and role access
$mechanics = $this->getMechanicsByDealer($dealerId);
Log::info('Mechanics found for report:', [
'count' => $mechanics->count(),
'dealer_id_filter' => $dealerId,
'mechanics' => $mechanics->map(function($mechanic) {
$roleName = 'Unknown';
if ($mechanic->role) {
$roleName = is_string($mechanic->role) ? $mechanic->role : $mechanic->role->name;
}
return [
'id' => $mechanic->id,
'name' => $mechanic->name,
'role_id' => $mechanic->role_id,
'role_name' => $roleName,
'dealer_id' => $mechanic->dealer_id
];
})
]);
// Get all transaction data in single optimized query
$transactions = $this->getOptimizedTransactionData($dealerId, $startDate, $endDate, $mechanics->pluck('id'), $works->pluck('id'));
Log::info('Transaction data:', [
'transaction_count' => count($transactions),
'sample_transactions' => array_slice($transactions, 0, 5, true),
'dealer_id_filter' => $dealerId,
'is_admin_with_pivot' => $user->role_id ? (function() use ($user) {
$role = Role::with('dealers')->find($user->role_id);
return $role && $this->isAdminRole($role) && $role->dealers->count() > 0;
})() : false
]);
$data = [];
foreach ($works as $work) {
$row = [
'work_id' => $work->id,
'work_name' => $work->name,
'work_code' => $work->shortname,
'category_name' => $work->category ? $work->category->name : '-',
'total_tickets' => 0
];
// Calculate totals for each mechanic
foreach ($mechanics as $mechanic) {
$key = $work->id . '_' . $mechanic->id;
$mechanicData = $transactions[$key] ?? ['total' => 0, 'completed' => 0, 'pending' => 0];
$row["mechanic_{$mechanic->id}_total"] = $mechanicData['total'];
// Add to totals
$row['total_tickets'] += $mechanicData['total'];
}
$data[] = $row;
}
Log::info('Final data prepared:', [
'data_count' => count($data),
'sample_data' => array_slice($data, 0, 2)
]);
return [
'data' => $data,
'mechanics' => $mechanics,
'works' => $works
];
} catch (\Exception $e) {
Log::error('Error in getTechnicianReportData: ' . $e->getMessage(), [
'dealer_id' => $dealerId,
'start_date' => $startDate,
'end_date' => $endDate,
'trace' => $e->getTraceAsString()
]);
// Return empty data structure but with proper format
return [
'data' => [],
'mechanics' => collect(),
'works' => collect()
];
}
}
/**
* Get optimized transaction data in single query
*/
private function getOptimizedTransactionData($dealerId = null, $startDate = null, $endDate = null, $mechanicIds = null, $workIds = null)
{
// Get current authenticated user
$user = auth()->user();
if (!$user) {
return [];
}
// Validate dealer access
if ($dealerId) {
if ($user->dealer_id) {
// User has specific dealer_id, check if they can access the requested dealer
if ($user->dealer_id != $dealerId) {
if ($user->role_id) {
$role = Role::with('dealers')->find($user->role_id);
if (!$role || !$role->hasDealer($dealerId)) {
// User doesn't have access to this dealer
return [];
}
} else {
// User has dealer_id but no role, can only access their dealer
return [];
}
}
} else if ($user->role_id) {
// User has role, check if they can access the requested dealer
$role = Role::with('dealers')->find($user->role_id);
if (!$role || !$role->hasDealer($dealerId)) {
// User doesn't have access to this dealer
return [];
}
}
}
Log::info('Getting optimized transaction data', [
'user_id' => $user->id,
'dealer_id' => $dealerId,
'start_date' => $startDate,
'end_date' => $endDate
]);
$query = Transaction::select(
'work_id',
'user_id',
'status',
DB::raw('COUNT(*) as count')
)
->groupBy('work_id', 'user_id', 'status');
if ($dealerId) {
$query->where('dealer_id', $dealerId);
} else if ($user->role_id) {
// Check if admin with pivot dealers and "Semua Dealer" selected
$role = Role::with('dealers')->find($user->role_id);
if ($role && $this->isAdminRole($role) && $role->dealers->count() > 0) {
// Admin with pivot dealers and "Semua Dealer" selected - filter by pivot dealers
$accessibleDealerIds = $role->dealers->pluck('id');
$query->whereIn('dealer_id', $accessibleDealerIds);
Log::info('Admin with pivot dealers, filtering transactions by pivot dealer IDs:', $accessibleDealerIds->toArray());
}
}
if ($startDate) {
$query->where('date', '>=', $startDate);
}
if ($endDate) {
$query->where('date', '<=', $endDate);
}
if ($mechanicIds && $mechanicIds->count() > 0) {
$query->whereIn('user_id', $mechanicIds);
}
if ($workIds && $workIds->count() > 0) {
$query->whereIn('work_id', $workIds);
}
// Remove index hint that doesn't exist
$results = $query->get();
Log::info('Transaction query results', [
'results_count' => $results->count()
]);
// Organize data by work_id_user_id key
$organizedData = [];
foreach ($results as $result) {
$key = $result->work_id . '_' . $result->user_id;
if (!isset($organizedData[$key])) {
$organizedData[$key] = [
'total' => 0,
'completed' => 0,
'pending' => 0
];
}
$organizedData[$key]['total'] += $result->count;
if ($result->status == 1) {
$organizedData[$key]['completed'] += $result->count;
} else {
$organizedData[$key]['pending'] += $result->count;
}
}
return $organizedData;
}
/**
* Get total ticket count for a specific work and mechanic (legacy method for backward compatibility)
*/
private function getTicketCount($workId, $mechanicId, $dealerId = null, $startDate = null, $endDate = null)
{
$query = Transaction::where('work_id', $workId)
->where('user_id', $mechanicId);
if ($dealerId) {
$query->where('dealer_id', $dealerId);
}
if ($startDate) {
$query->where('date', '>=', $startDate);
}
if ($endDate) {
$query->where('date', '<=', $endDate);
}
return $query->count();
}
/**
* Get completed ticket count for a specific work and mechanic (legacy method for backward compatibility)
*/
private function getCompletedTicketCount($workId, $mechanicId, $dealerId = null, $startDate = null, $endDate = null)
{
$query = Transaction::where('work_id', $workId)
->where('user_id', $mechanicId)
->where('status', 1); // Assuming status 1 is completed
if ($dealerId) {
$query->where('dealer_id', $dealerId);
}
if ($startDate) {
$query->where('date', '>=', $startDate);
}
if ($endDate) {
$query->where('date', '<=', $endDate);
}
return $query->count();
}
/**
* Get pending ticket count for a specific work and mechanic (legacy method for backward compatibility)
*/
private function getPendingTicketCount($workId, $mechanicId, $dealerId = null, $startDate = null, $endDate = null)
{
$query = Transaction::where('work_id', $workId)
->where('user_id', $mechanicId)
->where('status', 0); // Assuming status 0 is pending
if ($dealerId) {
$query->where('dealer_id', $dealerId);
}
if ($startDate) {
$query->where('date', '>=', $startDate);
}
if ($endDate) {
$query->where('date', '<=', $endDate);
}
return $query->count();
}
/**
* Get all dealers for filter
*/
public function getDealers()
{
// Get current authenticated user
$user = auth()->user();
if (!$user) {
Log::info('No authenticated user found');
return collect();
}
Log::info('Getting dealers for user:', [
'user_id' => $user->id,
'user_name' => $user->name,
'user_role_id' => $user->role_id,
'user_dealer_id' => $user->dealer_id
]);
// If user has role, check role type and dealer access
if ($user->role_id) {
$role = Role::with(['dealers' => function($query) {
$query->whereNull('dealers.deleted_at'); // Only active dealers
}])->find($user->role_id);
Log::info('Role details:', [
'role_id' => $role ? $role->id : null,
'role_name' => $role ? $role->name : null,
'role_dealers_count' => $role ? $role->dealers->count() : 0,
'role_dealers' => $role ? $role->dealers->pluck('id', 'name')->toArray() : []
]);
if ($role) {
// Check if role is admin type
if ($this->isAdminRole($role)) {
// Admin role - check if has pivot dealers
if ($role->dealers->count() > 0) {
// Admin with pivot dealers - return pivot dealers (for "Semua Dealer" option)
Log::info('Admin role with pivot dealers, returning pivot dealers');
$dealers = $role->dealers()->whereNull('dealers.deleted_at')->orderBy('name')->get(['dealers.id', 'dealers.name', 'dealers.dealer_code']);
Log::info('Returning pivot dealers for admin:', $dealers->toArray());
return $dealers;
} else {
// Admin without pivot dealers - return all dealers
Log::info('Admin role without pivot dealers, returning all dealers');
$allDealers = Dealer::whereNull('deleted_at')->orderBy('name')->get(['id', 'name', 'dealer_code']);
Log::info('Returning all dealers for admin:', $allDealers->toArray());
return $allDealers;
}
}
// Role has dealer relationship (tampilkan dealer berdasarkan pivot)
if ($role->dealers->count() > 0) {
Log::info('Role has dealers relationship, returning role dealers');
$dealers = $role->dealers()->whereNull('dealers.deleted_at')->orderBy('name')->get(['dealers.id', 'dealers.name', 'dealers.dealer_code']);
Log::info('Returning dealers from role:', $dealers->toArray());
return $dealers;
}
}
}
// If user has specific dealer_id but no role dealers, check if they can access their dealer_id
if ($user->dealer_id) {
Log::info('User has specific dealer_id:', ['dealer_id' => $user->dealer_id]);
if ($user->role_id) {
$role = Role::with(['dealers' => function($query) {
$query->whereNull('dealers.deleted_at'); // Only active dealers
}])->find($user->role_id);
if ($role && $role->hasDealer($user->dealer_id)) {
Log::info('User can access their dealer_id, returning single dealer');
$dealer = Dealer::where('id', $user->dealer_id)->whereNull('deleted_at')->orderBy('name')->get(['id', 'name', 'dealer_code']);
Log::info('Returning dealer:', $dealer->toArray());
return $dealer;
} else {
Log::info('User cannot access their dealer_id');
}
}
Log::info('User has dealer_id but no role or no access, returning empty');
return collect();
}
// Fallback: return all dealers if no restrictions
Log::info('No restrictions found, returning all dealers');
$allDealers = Dealer::whereNull('deleted_at')->orderBy('name')->get(['id', 'name', 'dealer_code']);
Log::info('Returning all dealers:', $allDealers->toArray());
return $allDealers;
}
/**
* Check if role is admin type (should show all dealers)
*/
public function isAdminRole($role)
{
// Define admin role names that should have access to all dealers
$adminRoleNames = [
'admin'
];
// Check if role name contains admin keywords (but not "area")
$roleName = strtolower(trim($role->name));
foreach ($adminRoleNames as $adminName) {
if (strpos($roleName, $adminName) !== false && strpos($roleName, 'area') === false) {
Log::info('Role identified as admin type:', ['role_name' => $role->name]);
return true;
}
}
// Check if role has no dealer restrictions (no pivot relationships)
// This means role can access all dealers
if ($role->dealers->count() === 0) {
Log::info('Role has no dealer restrictions, treating as admin type:', ['role_name' => $role->name]);
return true;
}
// Role with "area" in name should use pivot dealers, not all dealers
if (strpos($roleName, 'area') !== false) {
Log::info('Role contains "area", treating as area role (use pivot dealers):', ['role_name' => $role->name]);
return false;
}
Log::info('Role is not admin type:', ['role_name' => $role->name]);
return false;
}
/**
* Get default dealer for filter (berbasis user role)
*/
public function getDefaultDealer()
{
// Get current authenticated user
$user = auth()->user();
if (!$user) {
return null;
}
Log::info('Getting default dealer for user:', [
'user_id' => $user->id,
'user_role_id' => $user->role_id,
'user_dealer_id' => $user->dealer_id
]);
// If user has role, check role type and dealer access
if ($user->role_id) {
$role = Role::with(['dealers' => function($query) {
$query->whereNull('dealers.deleted_at'); // Only active dealers
}])->find($user->role_id);
if ($role) {
// Check if role is admin type
if ($this->isAdminRole($role)) {
// Admin role - check if has pivot dealers
if ($role->dealers->count() > 0) {
// Admin with pivot dealers - return first dealer from pivot
Log::info('Admin role with pivot dealers, returning first dealer from pivot');
$defaultDealer = $role->dealers()->whereNull('dealers.deleted_at')->orderBy('name')->first();
Log::info('Default dealer for admin with pivot:', $defaultDealer ? $defaultDealer->toArray() : null);
return $defaultDealer;
} else {
// Admin without pivot dealers - no default dealer (show all dealers without selection)
Log::info('Admin role without pivot dealers, no default dealer');
return null;
}
}
// Role has dealer relationship (return first dealer from role dealers)
if ($role->dealers->count() > 0) {
Log::info('Role has dealers relationship, returning first dealer from role dealers');
$defaultDealer = $role->dealers()->whereNull('dealers.deleted_at')->orderBy('name')->first();
Log::info('Default dealer from role dealers:', $defaultDealer ? $defaultDealer->toArray() : null);
return $defaultDealer;
}
}
}
// If user has specific dealer_id, check if they can access it
if ($user->dealer_id) {
if ($user->role_id) {
$role = Role::with(['dealers' => function($query) {
$query->whereNull('dealers.deleted_at'); // Only active dealers
}])->find($user->role_id);
if ($role && $role->hasDealer($user->dealer_id)) {
$defaultDealer = Dealer::where('id', $user->dealer_id)->whereNull('deleted_at')->first();
Log::info('User dealer found:', $defaultDealer ? $defaultDealer->toArray() : null);
return $defaultDealer;
}
}
return null;
}
// Fallback: no default dealer
Log::info('No default dealer found');
return null;
}
/**
* Get mechanics for a specific dealer
*/
public function getMechanicsByDealer($dealerId = null)
{
// Get current authenticated user
$user = auth()->user();
if (!$user) {
return collect();
}
Log::info('Getting mechanics by dealer:', [
'user_id' => $user->id,
'user_role_id' => $user->role_id,
'user_dealer_id' => $user->dealer_id,
'requested_dealer_id' => $dealerId
]);
$query = User::with('role')->whereHas('role', function($query) {
$query->where('name', 'mechanic');
});
// If user has role, check role type and dealer access
if ($user->role_id) {
$role = Role::with(['dealers' => function($query) {
$query->whereNull('dealers.deleted_at'); // Only active dealers
}])->find($user->role_id);
if ($role) {
// Check if role is admin type
if ($this->isAdminRole($role)) {
// Admin role - check if has pivot dealers
if ($role->dealers->count() > 0) {
// Admin with pivot dealers
if ($dealerId) {
// Specific dealer selected - get mechanics from that dealer
Log::info('Admin with pivot dealers, specific dealer selected:', ['dealer_id' => $dealerId]);
$query->where('dealer_id', $dealerId);
} else {
// "Semua Dealer" selected - get mechanics from all pivot dealers
Log::info('Admin with pivot dealers, "Semua Dealer" selected, getting mechanics from all pivot dealers');
$accessibleDealerIds = $role->dealers->pluck('id');
$query->whereIn('dealer_id', $accessibleDealerIds);
Log::info('Accessible dealer IDs for admin:', $accessibleDealerIds->toArray());
}
} else {
// Admin without pivot dealers - can access all dealers
Log::info('Admin without pivot dealers, can access mechanics from all dealers');
if ($dealerId) {
$query->where('dealer_id', $dealerId);
}
// If no dealer_id, show all mechanics (no additional filtering)
}
} else {
// Role has dealer relationship (filter by accessible dealers)
if ($role->dealers->count() > 0) {
Log::info('Role has dealers relationship, filtering mechanics by accessible dealers');
$accessibleDealerIds = $role->dealers->pluck('id');
$query->whereIn('dealer_id', $accessibleDealerIds);
Log::info('Accessible dealer IDs:', $accessibleDealerIds->toArray());
} else {
Log::info('Role has no dealers, returning empty');
return collect();
}
}
}
} else if ($user->dealer_id) {
// User has specific dealer_id but no role, can only access their dealer
Log::info('User has dealer_id but no role, can only access their dealer');
$query->where('dealer_id', $user->dealer_id);
}
// Apply dealer filter if provided (for non-admin roles)
if ($dealerId && !$this->isAdminRole($role ?? null)) {
Log::info('Applying dealer filter for non-admin role:', ['dealer_id' => $dealerId]);
$query->where('dealer_id', $dealerId);
}
$mechanics = $query->orderBy('name')->get(['id', 'name', 'dealer_id']);
Log::info('Mechanics found:', [
'count' => $mechanics->count(),
'mechanics' => $mechanics->map(function($mechanic) {
return [
'id' => $mechanic->id,
'name' => $mechanic->name,
'dealer_id' => $mechanic->dealer_id
];
})->toArray()
]);
return $mechanics;
}
/**
* Get technician report data for Yajra DataTable
*/
public function getTechnicianReportDataForDataTable($dealerId = null, $startDate = null, $endDate = null)
{
try {
// Get current authenticated user
$user = auth()->user();
if (!$user) {
return response()->json([
'draw' => request()->input('draw', 1),
'recordsTotal' => 0,
'recordsFiltered' => 0,
'data' => [],
'mechanics' => collect(),
'works' => collect()
]);
}
// Validate dealer access
if ($dealerId) {
if ($user->dealer_id) {
// User has specific dealer_id, check if they can access the requested dealer
if ($user->dealer_id != $dealerId) {
if ($user->role_id) {
$role = Role::with('dealers')->find($user->role_id);
if (!$role || !$role->hasDealer($dealerId)) {
// User doesn't have access to this dealer
return response()->json([
'draw' => request()->input('draw', 1),
'recordsTotal' => 0,
'recordsFiltered' => 0,
'data' => [],
'mechanics' => collect(),
'works' => collect()
]);
}
} else {
// User has dealer_id but no role, can only access their dealer
return response()->json([
'draw' => request()->input('draw', 1),
'recordsTotal' => 0,
'recordsFiltered' => 0,
'data' => [],
'mechanics' => collect(),
'works' => collect()
]);
}
}
} else if ($user->role_id) {
// User has role, check if they can access the requested dealer
$role = Role::with('dealers')->find($user->role_id);
if (!$role || !$role->hasDealer($dealerId)) {
// User doesn't have access to this dealer
return response()->json([
'draw' => request()->input('draw', 1),
'recordsTotal' => 0,
'recordsFiltered' => 0,
'data' => [],
'mechanics' => collect(),
'works' => collect()
]);
}
}
}
Log::info('Getting technician report data for DataTable', [
'user_id' => $user->id,
'dealer_id' => $dealerId,
'start_date' => $startDate,
'end_date' => $endDate
]);
// Get all works with category
$works = Work::with(['category'])
->orderBy('name')
->get();
// Get mechanics based on dealer and role access
$mechanics = $this->getMechanicsByDealer($dealerId);
// Get transaction data
$transactions = $this->getOptimizedTransactionData($dealerId, $startDate, $endDate, $mechanics->pluck('id'), $works->pluck('id'));
Log::info('Transaction data for DataTable:', [
'transaction_count' => count($transactions),
'dealer_id_filter' => $dealerId,
'is_admin_with_pivot' => $user->role_id ? (function() use ($user) {
$role = Role::with('dealers')->find($user->role_id);
return $role && $this->isAdminRole($role) && $role->dealers->count() > 0;
})() : false
]);
$data = [];
foreach ($works as $work) {
$row = [
'DT_RowIndex' => count($data) + 1,
'work_name' => $work->name,
'work_code' => $work->shortname,
'category_name' => $work->category ? $work->category->name : '-'
];
// Add mechanic columns
foreach ($mechanics as $mechanic) {
$key = $work->id . '_' . $mechanic->id;
$mechanicData = $transactions[$key] ?? ['total' => 0, 'completed' => 0, 'pending' => 0];
$row["mechanic_{$mechanic->id}_total"] = $mechanicData['total'];
}
$data[] = $row;
}
Log::info('DataTable response prepared', [
'data_count' => count($data),
'mechanics_count' => $mechanics->count(),
'works_count' => $works->count()
]);
// Create DataTable response
return response()->json([
'draw' => request()->input('draw', 1),
'recordsTotal' => count($data),
'recordsFiltered' => count($data),
'data' => $data,
'mechanics' => $mechanics,
'works' => $works
]);
} catch (\Exception $e) {
Log::error('Error in getTechnicianReportDataForDataTable: ' . $e->getMessage(), [
'dealer_id' => $dealerId,
'start_date' => $startDate,
'end_date' => $endDate,
'trace' => $e->getTraceAsString()
]);
return response()->json([
'draw' => request()->input('draw', 1),
'recordsTotal' => 0,
'recordsFiltered' => 0,
'data' => [],
'mechanics' => collect(),
'works' => collect()
]);
}
}
}

0
artisan Normal file → Executable file
View File

0
bengkell.zip Normal file → Executable file
View File

Some files were not shown because too many files have changed in this diff Show More