Compare commits

..

34 Commits

Author SHA1 Message Date
Yoga Pangestu
485ae8e5ce feat: add getNames method to ProductService for retrieving product names 2026-08-09 16:31:03 +07:00
Yoga Pangestu
0cce4ee73d feat: enhance StatCard component with description prop and update chart tooltips for better localization
- Added optional description prop to StatCard for additional context.
- Updated ChartTooltipContent to format numbers according to Indonesian locale.
- Refactored revenue and expense charts in the Analysis page to use new chart configurations and improved tooltip content.
- Introduced DashboardPieChart component for displaying order statistics by channel, payment type, marketing, and status.
- Adjusted revenue summary calculations in the Dashboard page to reflect changes in data structure.
2026-08-09 16:30:16 +07:00
Yoga Pangestu
3fab97e7e5 fix: correct array conversion in getForCutting method of RawMaterialVariantService 2026-08-09 15:08:00 +07:00
Yoga Pangestu
1918f35ab2 fix: update dashboard link activation logic in AppSidebar component 2026-08-09 13:16:33 +07:00
Yoga Pangestu
2bb814702a feat: add ChartContainer and related components for enhanced charting capabilities in the dashboard
- Introduced ChartContainer, ChartTooltip, ChartTooltipContent, ChartLegend, and ChartLegendContent components to streamline chart rendering and tooltip management.
- Replaced existing donut chart implementation with a new pie chart component utilizing the new charting system.
- Updated dashboard to utilize the new chart components, improving maintainability and visual consistency.
- Refactored chart data handling and configuration for better integration with the new charting components.
2026-08-09 12:52:12 +07:00
Yoga Pangestu
a1ba9564c7 fix: correct total calculation by adjusting discount and negotiation price handling in transaction create and edit components 2026-08-09 12:31:01 +07:00
Yoga Pangestu
eba21d3d3a refactor: clean up code by removing unused imports and improving formatting across multiple files 2026-08-09 11:50:38 +07:00
Yoga Pangestu
0023309a8f Refactor services to improve role checks and streamline data retrieval
- Updated CustomerService to simplify getAll method.
- Refactored ProductService to utilize HasRoleChecks trait and improved role verification logic.
- Enhanced ProductVariantService with new methods for fetching data for restocking and transactions.
- Cleaned up RawMaterialService by removing unused methods and improving data retrieval.
- Adjusted SupplierService to streamline getAll method.
- Refactored RoleService to use Spatie's Role model and improved role filtering logic.
- Updated NotificationService to handle role labels more effectively.
- Improved StockMutationService by removing redundant paginated method.
- Cleaned up various frontend components to directly accept necessary props instead of nested data objects.
- Updated tests to reflect changes in service method names and ensure proper notification handling.
2026-08-09 11:33:25 +07:00
Yoga Pangestu
46af27e537 refactor: rename create/delete methods to store/destroy across multiple services; add transaction handling for cash and stock adjustments 2026-08-08 14:31:58 +07:00
Yoga Pangestu
1c19e6c690 refactor: update service method names from create/delete to store/destroy across multiple controllers 2026-08-08 14:31:44 +07:00
Yoga Pangestu
48c031a86f feat: update various controllers to return back instead of redirecting to index routes; add payroll adjustment and payroll controllers 2026-08-08 14:18:04 +07:00
Yoga Pangestu
6627422655 feat: enhance analysis page with additional revenue metrics and improve data handling 2026-08-08 13:40:18 +07:00
Yoga Pangestu
f7c13826eb feat: update dashboard titles to reflect today's data for attendance, revenue, and expenses 2026-08-08 12:41:52 +07:00
Yoga Pangestu
515b2e9848 refactor: reorganize admin routes for better structure and readability 2026-08-08 12:25:59 +07:00
Yoga Pangestu
f7ce2f1620 feat: enhance validation rules for various requests and update attribute names 2026-08-08 01:52:16 +07:00
Yoga Pangestu
cd0995f04e feat: add formatted accessors for various models and enhance date formatting 2026-08-07 23:04:28 +07:00
Yoga Pangestu
4733df5827 Add homepage types and update routing to use HomepageController
- Created a new TypeScript file for homepage-related types including Product, ProductVariant, ProductCategory, and HomepageData.
- Updated the index.ts to export the new homepage types.
- Refactored the web routes to replace the inline closure for the home route with the HomepageController.
2026-08-07 17:29:59 +07:00
Yoga Pangestu
3c945a1372 fix: update error messages for form submissions to improve clarity 2026-08-07 15:43:52 +07:00
Yoga Pangestu
4b70da6715 feat: enhance leave request validation and fix timezone issues 2026-08-07 15:36:44 +07:00
Yoga Pangestu
14140c39cc feat: enhance attendance index with leave tracking and employee details 2026-08-07 15:27:48 +07:00
Yoga Pangestu
e4d956843c feat: update TodayAttendanceAlert component and enhance card styling in attendance and dashboard pages 2026-08-07 14:32:45 +07:00
Yoga Pangestu
166419134f Refactor component imports for consistency and organization
- Updated import paths for various components to align with new directory structure.
- Changed imports from 'row-actions', 'confirm-dialog', 'image-preview-button', and 'file-upload' to their respective new locations in 'data-display', 'dialogs', and 'inputs'.
- Adjusted imports in multiple pages including purchase, restock, transaction, category, customer, product, raw-material, supplier, roles, and settings.
- Ensured all relevant components are imported from their new locations to maintain functionality.
2026-08-07 13:48:46 +07:00
Yoga Pangestu
d648d48632 feat: implement TodayAttendanceAlert component for improved attendance display and interaction 2026-08-07 13:25:41 +07:00
Yoga Pangestu
28f7ea0006 feat: add RETAIL case to ProductStockQuality enum and update label method 2026-08-07 13:02:07 +07:00
Yoga Pangestu
d10f731839 feat: enhance documentation with workflow tracing and session notes guidelines 2026-08-07 11:40:09 +07:00
Yoga Pangestu
85556f8779 feat: enhance product management with approval and rejection workflows
- Added approval and rejection functionality for products, including new routes and methods in the ProductController.
- Implemented UI changes to display product status (pending, rejected) with appropriate badges and actions.
- Introduced a RejectDialog component for providing rejection reasons.
- Updated product columns to handle new actions for approving and rejecting products.
- Enhanced variant management to restrict actions based on product status.
- Refactored various components to improve code organization and readability.
2026-08-07 11:23:41 +07:00
Yoga Pangestu
f7a91739f9 feat: integrate thermal printer functionality with receipt encoding
- Added @point-of-sale/receipt-printer-encoder dependency to package.json.
- Implemented useThermalPrinter hook for managing Bluetooth and USB printer connections.
- Created encodeOrderReceipt function to format receipt data for printing.
- Enhanced TransactionIndex component to include printer connection options and receipt printing functionality.
- Added dropdown menu for selecting printer connection type (Bluetooth/USB) and printing receipt options (58mm/80mm).
- Updated TransactionCardRow to include print receipt actions.
- Defined TypeScript types for receipt printer encoder and web Bluetooth/USB APIs.
- Configured Vite to optimize dependencies for receipt printer encoder.
2026-08-07 09:34:11 +07:00
Yoga Pangestu
add157183b feat: replace useMemo with useState for selectedProductId and update product selection UI 2026-08-07 09:11:47 +07:00
Yoga Pangestu
9e60a1cf39 feat: add tiktok and shopee order IDs to transaction model and UI components 2026-08-07 09:04:16 +07:00
Yoga Pangestu
74c374e126 feat: update search placeholders to Indonesian language across various components 2026-08-07 08:55:59 +07:00
Yoga Pangestu
50b2be68ac Add error handling with toast notifications for form submissions
- Integrated toast notifications to display error messages when form submissions fail across various components, enhancing user feedback and experience.
- Updated components including DeleteUser, FormDialog, ManageTwoFactor, TwoFactorRecoveryCodes, TwoFactorSetupModal, PayrollPeriodShow, EmployeeCreate, EmployeeEdit, CuttingCreate, CuttingEdit, PurchaseCreate, PurchaseEdit, RestockCreate, RestockEdit, TransactionCreate, TransactionEdit, Category management, Product management, Raw Material management, Role management, Settings, and Authentication pages.
2026-08-07 08:50:11 +07:00
Yoga Pangestu
de45ae3bb7 feat: add search parameter cleanup in useServerTable hook 2026-08-07 08:44:30 +07:00
Yoga Pangestu
c6a87a64c9 feat: enhance dashboard with detailed statistics and charts
- Refactored the dashboard component to include attendance, revenue, and expense summaries.
- Added donut charts for order statistics by channel, payment type, marketing, and status.
- Implemented a greeting message based on the current time and user information.
- Updated routing to use DashboardController for the dashboard view.
- Introduced a new AnalysisController for future analysis features.
2026-08-07 08:35:01 +07:00
Yoga Pangestu
7a1b107ce5 Refactor AGENTS.md to create a comprehensive module/feature development checklist, consolidating session notes, model relationships, casting, scopes, reorganized model structures, and conventions for backend and frontend development. Added mandatory reading section for project context and conventions. 2026-08-07 01:37:36 +07:00
273 changed files with 14092 additions and 4142 deletions

88
.ai/CONTEXT.md Normal file
View File

@ -0,0 +1,88 @@
# DST Collection — Project Context
## Tentang Project
**DST Collection** adalah aplikasi ERP untuk brand pakaian wanita (daster, dress, dll). Aplikasi ini mengelola dua area bisnis utama:
1. **Toko** — penjualan, produk, keuangan, HR
2. **Konveksi** — bahan baku, cutting, pembelian bahan
## Tech Stack
| Layer | Technology |
|-------|------------|
| Backend | Laravel 13, PHP 8.3 |
| Frontend | React 19, Inertia.js 3, TypeScript |
| UI | shadcn/ui, Radix UI, Tailwind CSS 4 |
| Auth | Laravel Fortify, Spatie Permission |
| DB | MySQL (SoftDeletes di semua model utama) |
## Roles & Akses
### role: `developer`, `owner`
- **Akses**: Semua module, semua aksi
- **Catatan**: Selalu muncul di semua sidebar
### role: `admin-toko`
- **Akses**: Full CRUD semua module toko
- **Modules**: Kategori, Produk, Customer, Transaksi, Restock, Kas Toko, Pengeluaran, Kasbon, Gaji, Pegawai, Presensi, Cuti, Settings
- **Catatan**: TIDAK akses Supplier, Bahan Baku, Belanja, Cutting
### role: `direktur`
- **Akses**: Readonly semua data toko + bisa ajukan kasbon
- **Modules**: Semua module toko (readonly), kecuali Supplier, Bahan Baku, Belanja, Cutting
- **Catatan**: Bisa lihat semua data pegawai toko, tapi bukan admin bahan baku
### role: `admin-bahan-baku`
- **Akses**: Full CRUD untuk konveksi
- **Modules**: Bahan Baku, Supplier, Belanja, Cutting, Pegawai (hanya role admin-bahan-baku), Kasbon (lihat semua + ACC/bayar)
- **Catatan**: TIDAK akses module toko lainnya
### role: `cashier`
- **Akses**: Transaksi (full CRUD), Customer (full CRUD), Produk (readonly), Kasbon (CRUD own data)
- **Modules**: Transaksi, Customer, Produk (view), Kasbon, Gaji (view), Presensi, Cuti
### role: `marketing-offline`, `marketing-online`
- **Akses**: Transaksi (readonly yang punya dia), Produk (readonly), Customer (full CRUD)
- **Modules**: Transaksi (view only own), Produk (view), Customer, Kasbon (CRUD own data), Presensi, Cuti
### role: `stok-opname`
- **Akses**: Stok Opname (full CRUD + submit)
- **Modules**: Stok Opname, Produk (view), Presensi, Cuti
### role: `non-operator`
- **Akses**: Basic (dashboard, presensi, cuti, kasbon)
- **Modules**: Presensi, Cuti, Kasbon (CRUD own data)
## Module Overview
| # | Module | Route Group | Controller Path | Service Path |
|---|--------|-------------|-----------------|--------------|
| 1 | Kategori | `admin/master/categories` | `Admin/Master/CategoryController` | `Admin/Master/CategoryService` |
| 2 | Produk | `admin/master/products` | `Admin/Master/Product/ProductController` | `Admin/Master/Product/ProductService` |
| 3 | Bahan Baku | `admin/master/raw-materials` | `Admin/Master/RawMaterial/RawMaterialController` | `Admin/Master/RawMaterial/RawMaterialService` |
| 4 | Supplier | `admin/master/suppliers` | `Admin/Master/SupplierController` | `Admin/Master/SupplierService` |
| 5 | Customer | `admin/master/customers` | `Admin/Master/CustomerController` | `Admin/Master/CustomerService` |
| 6 | Belanja | `admin/manage/purchases` | `Admin/Manage/PurchaseController` | `Admin/Manage/PurchaseService` |
| 7 | Cutting | `admin/manage/cuttings` | `Admin/Manage/CuttingController` | `Admin/Manage/CuttingService` |
| 8 | Transaksi | `admin/manage/transactions` | `Admin/Manage/TransactionController` | `Admin/Manage/TransactionService` |
| 9 | Restock | `admin/manage/restocks` | `Admin/Manage/RestockController` | `Admin/Manage/RestockService` |
| 10 | Stok Opname | `admin/manage/stok-opnames` | (via StockMutationController) | — |
| 11 | Kas Toko | `admin/finance/cash-accounts` | `Admin/Finance/CashAccountController` | `Admin/Finance/Cash/CashAccountService`, `Admin/Finance/Cash/CashTransactionService` |
| 12 | Pengeluaran | `admin/finance/expenses` | `Admin/Finance/ExpenseController` | `Admin/Finance/ExpenseService` |
| 13 | Kasbon | `admin/finance/employee-advances` | `Admin/Finance/EmployeeAdvanceController` | `Admin/Finance/EmployeeAdvanceService` |
| 14 | Gaji | `admin/finance/payroll-periods` | `Admin/Finance/PayrollController` | `Admin/Finance/Payroll/PayrollPeriodService`, `Admin/Finance/Payroll/PayrollAdjustmentService` |
| 15 | Pegawai | `admin/hr/employees` | `Admin/HR/EmployeeController` | `Admin/HR/EmployeeService` |
| 16 | Presensi | `admin/hr/attendances` | `Admin/HR/AttendanceController` | `Admin/HR/AttendanceService` |
| 17 | Cuti | `admin/hr/leave-requests` | `Admin/HR/LeaveRequestController` | `Admin/HR/LeaveRequestService` |
| 18 | Role & Permission | `admin/settings/roles` | `Admin/RoleController` | `Admin/Settings/RoleService` |
| 19 | Pengaturan | `admin/settings` | `Admin/AdminSettingsController` | `Admin/AdminSettingsService` |
## Statistics
| Metric | Count |
|--------|-------|
| Tables | 48 |
| Models | 43 |
| Enums | 21 |
| Services | 30 |
| Controllers | 32 |
| Roles | 9 |
| Permissions | ~120 |

1712
.ai/CONVENTIONS.md Normal file

File diff suppressed because it is too large Load Diff

400
.ai/REFERENCE.md Normal file
View File

@ -0,0 +1,400 @@
# Database Reference
> Format: `table_name``model_name` | columns | relationships
---
## Auth & User
### `users` → User
`id` `email`(unique) `username`(unique) `password` `is_active`(bool) `last_login_at`(datetime) `created_at` `updated_at` `deleted_at`
- Casts: email_verified_at(datetime), is_active(bool), last_login_at(datetime), password(hashed), two_factor_confirmed_at(datetime)
- Scopes: active()
- Relations: userProfile(HasOne→UserProfile), employee(HasOne→Employee), attendances(HasMany→Attendance), cashAccounts(HasMany→CashAccount,created_by_id), cashTransactions(HasMany→CashTransaction,created_by_id), createdCuttings(HasMany→Cutting,created_by_id), submittedCuttings(HasMany→Cutting,submitted_by_id), createdExpenses(HasMany→Expense,created_by_id), createdOrders(HasMany→Order,created_by_id), marketingOrders(HasMany→Order,marketing_id), orderItems(HasMany→OrderItem), createdPurchases(HasMany→Purchase,created_by_id), createdRestocks(HasMany→Restock,created_by_id), stokOpnamesCreated(HasMany→StokOpname,created_by_id), stokOpnamesVerified(HasMany→StokOpname,verified_by_id), employeeAdvancesPaid(HasMany→EmployeeAdvance,paid_by_id), employeeAdvancesVerified(HasMany→EmployeeAdvance,verified_by_id), paidPayrolls(HasMany→Payroll,paid_by_id), payrollPeriodsClosed(HasMany→PayrollPeriod,closed_by_id), notifications(HasMany→AppNotification), rejections(HasMany→Rejection,rejected_by_id), pushSubscriptions(HasMany→PushSubscription,morph)
### `user_profiles` → UserProfile
`id` `user_id`(FK→users,unique) `full_name`(200) `phone_number`(20,null) `gender`(enum,null) `birth_date`(date,null) `address`(text,null) `created_at` `updated_at` `deleted_at`
- Casts: gender(Gender), birth_date(date:Y-m-d)
- Scopes: female(), male(), hasPhoneNumber()
- Relations: user(BelongsTo→User)
### `push_subscriptions` → PushSubscription
`id` `subscribable_type` `subscribable_id` `endpoint`(500,unique) `public_key`(null) `auth_token`(null) `content_encoding`(null) `created_at` `updated_at`
- Relations: user(MorphTo)
### `notifications` → AppNotification
`id` `user_id`(FK→users) `title` `body`(text,null) `url`(null) `is_read`(bool,default:false) `read_at`(datetime,null) `created_at` `updated_at`
- Casts: is_read(bool), read_at(datetime)
- Accessor: formatted_read_at → 'l, d F Y H:i'
- Relations: user(BelongsTo→User)
---
## Master Data
### `categories` → Category
`id` `name`(50) `slug`(50,unique) `created_at` `updated_at` `deleted_at`
- Relations: products(BelongsToMany→Product via product_categories)
- Accessor: formattedName → ucfirst(name)
### `product_categories` → Category (Pivot)
`product_id`(FK→products) `category_id`(FK→categories)
- No id, no timestamps
- Relations: category(BelongsTo→Category), product(BelongsTo→Product)
### `products` → Product
`id` `name`(200) `slug`(200,unique) `description`(text,null) `status`(enum,default:active) `rejection_reason`(text,null) `created_at` `updated_at` `deleted_at`
- Casts: status(ProductStatus)
- Scopes: active(), draft(), inactive(), pending(), rejected()
- Relations: categories(BelongsToMany→Category), productVariants(HasMany→ProductVariant)
### `product_variants` → ProductVariant
`id` `product_id`(FK→products) `name`(200) `stock`(uint,default:0) `reject_stock`(uint,default:0) `retail_stock`(uint,default:0) `created_at` `updated_at` `deleted_at`
- Casts: stock(int), reject_stock(int), retail_stock(int)
- GlobalScope: orderBy(name)
- Relations: product(BelongsTo→Product), productPrices(HasMany→ProductPrice,variant_id), orderItems(HasMany→OrderItem), restockItems(HasMany→RestockItem), retailStockHistories(HasMany→RetailStockHistory), stokOpnameItems(HasMany→StokOpnameItem), stockMutations(HasMany→StockMutation,morph)
### `product_prices` → ProductPrice
`id` `variant_id`(FK→product_variants) `type`(enum) `price`(uint) `created_at` `updated_at`
- UNIQUE(variant_id, type)
- Casts: type(PriceType), price(int)
- Scopes: retail(), wholesale()
- Relations: variant(BelongsTo→ProductVariant)
### `customers` → Customer
`id` `name`(200) `phone_number`(20,null) `address`(text,null) `created_at` `updated_at` `deleted_at`
- Relations: orders(HasMany→Order)
- Accessor: formattedPhoneNumber (get: "0821 2121 2121", set: strip whitespace)
### `suppliers` → Supplier
`id` `name`(200) `phone_number`(20,null) `address`(text,null) `created_at` `updated_at` `deleted_at`
- Relations: purchases(HasMany→Purchase)
- Accessor: formattedPhoneNumber (get: "0821 2121 2121", set: strip whitespace)
### `raw_materials` → RawMaterial
`id` `name`(200) `unit`(enum) `is_active`(bool,default:true) `created_at` `updated_at` `deleted_at`
- Casts: unit(RawMaterialUnit), is_active(bool)
- Scopes: active(), nonactive(), kg(), meter(), yard()
- Relations: rawMaterialPrices(HasMany→RawMaterialPrice)
### `raw_material_prices` → RawMaterialPrice
`id` `raw_material_id`(FK→raw_materials) `variant`(200) `price`(uint) `stock`(uint,default:0) `created_at` `updated_at` `deleted_at`
- Casts: price(int), stock(int)
- GlobalScope: orderBy(variant)
- Relations: rawMaterial(BelongsTo→RawMaterial,withTrashed), cuttingMaterials(HasMany→CuttingMaterial), purchaseItems(HasMany→PurchaseItem)
- Accessor: photo_url → first media presigned S3 URL
---
## Finance
### `cash_accounts` → CashAccount
`id` `created_by_id`(FK→users) `name`(200) `balance`(ubig,default:0) `created_at` `updated_at` `deleted_at`
- Casts: balance(int)
- Relations: cashTransactions(HasMany→CashTransaction), createdBy(BelongsTo→User)
### `cash_transactions` → CashTransaction
`id` `cash_account_id`(FK→cash_accounts) `created_by_id`(FK→users) `reference_id`(ubig,null,morph) `reference_type`(string,null,morph) `amount`(ubig) `balance_after`(ubig) `type`(enum,default:deposit) `description`(100) `created_at` `updated_at` `deleted_at`
- Casts: type(CashTransactionType), amount(int), balance_after(int)
- Scopes: deposit(), expenseType(), transfer(), withdrawal()
- Relations: cashAccount(BelongsTo→CashAccount), createdBy(BelongsTo→User), reference(MorphTo), expense(HasOne→Expense), order(HasOne→Order), employeeAdvances(HasMany→EmployeeAdvance), payrolls(HasMany→Payroll)
### `expenses` → Expense
`id` `cash_transaction_id`(FK→cash_transactions,unique,null) `created_by_id`(FK→users) `amount`(ubig) `description`(100) `created_at` `updated_at` `deleted_at`
- Casts: amount(int), date(date:Y-m-d)
- Relations: cashTransaction(BelongsTo→CashTransaction), createdBy(BelongsTo→User)
### `employee_advances` → EmployeeAdvance
`id` `paid_by_id`(FK→users,null) `employee_id`(FK→employees) `verified_by_id`(FK→users,null) `repayment_cash_transaction_id`(FK→cash_transactions,unique,null) `cash_transaction_id`(FK→cash_transactions,unique,null) `amount`(ubig) `paid_amount`(ubig,default:0) `description`(100) `due_date`(date) `status`(enum,default:pending) `verified_at`(datetime,null) `paid_at`(datetime,null) `created_at` `updated_at` `deleted_at`
- Casts: status(EmployeeAdvanceStatus), amount(int), paid_amount(int), due_date(date:Y-m-d), verified_at(datetime), paid_at(datetime)
- Scopes: approved(), cancelled(), paid(), pending(), rejected()
- Relations: employee(BelongsTo→Employee), cashTransaction(BelongsTo→CashTransaction), repaymentCashTransaction(BelongsTo→CashTransaction), paidBy(BelongsTo→User), verifiedBy(BelongsTo→User), payments(HasMany→EmployeeAdvancePayment)
### `employee_advance_payments` → EmployeeAdvancePayment
`id` `employee_advance_id`(FK→employee_advances) `paid_by_id`(FK→users,null) `cash_transaction_id`(FK→cash_transactions,unique,null) `amount`(ubig) `description`(100,null) `paid_at`(datetime) `created_at` `updated_at`
- No soft deletes
- Casts: amount(int), paid_at(datetime)
- Accessor: formatted_amount → 'Rp X.XXX', formatted_paid_at → 'l, d F Y H:i'
- Relations: employeeAdvance(BelongsTo→EmployeeAdvance), paidBy(BelongsTo→User), cashTransaction(BelongsTo→CashTransaction)
---
## HR
### `employees` → Employee
`id` `user_id`(FK→users,unique) `join_date`(date) `resign_date`(date,null) `employment_status`(enum,default:full_time) `base_salary`(uint) `created_at` `updated_at` `deleted_at`
- Casts: employment_status(EmploymentStatus), base_salary(int), join_date(date:Y-m-d), resign_date(date:Y-m-d)
- Scopes: contract(), fullTime(), internship(), partTime(), resigned()
- Relations: user(BelongsTo→User), attendances(HasMany→Attendance), employeeAdvances(HasMany→EmployeeAdvance), leaveRequests(HasMany→LeaveRequest), payrolls(HasMany→Payroll)
### `attendances` → Attendance
`id` `employee_id`(FK→employees) `attendance_date`(date) `check_in_at`(datetime) `check_out_at`(datetime,null) `check_in_latitude`(decimal(10,7),null) `check_in_longitude`(decimal(10,7),null) `check_out_latitude`(decimal(10,7),null) `check_out_longitude`(decimal(10,7),null) `work_duration_minutes`(uint,null) `created_at` `updated_at`
- No soft deletes
- Casts: attendance_date(date:Y-m-d), check_in_at(datetime), check_out_at(datetime), check_in/out_latitude/longitude(decimal:7), work_duration_minutes(int)
- Accessor: formatted_attendance_date → 'l, d F Y', formatted_check_in_at → 'l, d F Y H:i', formatted_check_out_at → 'l, d F Y H:i'
- Relations: employee(BelongsTo→Employee), payrollAdjustments(HasMany→PayrollAdjustment)
### `payroll_periods` → PayrollPeriod
`id` `closed_by_id`(FK→users,null) `year`(usmall) `month`(utiny) `status`(enum,default:open) `closed_at`(datetime,null) `created_at` `updated_at` `deleted_at`
- Casts: status(PayrollPeriodStatus), year(int), month(int), closed_at(datetime)
- Accessor: formatted_closed_at → 'l, d F Y H:i', month_name → 'Januari', status_label
- Scopes: closed(), open()
- Relations: closedBy(BelongsTo→User), payrolls(HasMany→Payroll)
### `payrolls` → Payroll
`id` `payroll_period_id`(FK→payroll_periods) `employee_id`(FK→employees) `cash_transaction_id`(FK→cash_transactions,unique,null) `paid_by_id`(FK→users,null) `base_salary`(uint) `bonus_amount`(ubig,default:0) `deduction_amount`(ubig,default:0) `total_amount`(ubig) `status`(enum,default:unpaid) `paid_at`(datetime,null) `created_at` `updated_at` `deleted_at`
- Casts: status(PayrollStatus), base_salary(int), bonus_amount(int), deduction_amount(int), total_amount(int), paid_at(datetime)
- Scopes: cancelled(), paid(), unpaid()
- Relations: payrollPeriod(BelongsTo→PayrollPeriod), employee(BelongsTo→Employee), cashTransaction(BelongsTo→CashTransaction), paidBy(BelongsTo→User), payrollAdjustments(HasMany→PayrollAdjustment)
### `payroll_adjustments` → PayrollAdjustment
`id` `payroll_id`(FK→payrolls) `attendance_id`(FK→attendances,null) `created_by_id`(FK→users) `type`(enum) `amount`(ubig) `description`(100) `created_at` `updated_at` `deleted_at`
- Casts: type(PayrollAdjustmentType), amount(int)
- Scopes: bonus(), deduction()
- Relations: payroll(BelongsTo→Payroll), attendance(BelongsTo→Attendance), createdBy(BelongsTo→User)
### `leave_requests` → LeaveRequest
`id` `employee_id`(FK→employees) `verified_by_id`(FK→users,null) `start_date`(date) `end_date`(date) `total_days`(uint) `status`(enum,default:pending) `verified_at`(datetime,null) `created_at` `updated_at` `deleted_at`
- Casts: status(LeaveRequestStatus), start_date(date:Y-m-d), end_date(date:Y-m-d), total_days(int), verified_at(datetime)
- Scopes: approved(), cancelled(), pending(), rejected()
- Relations: employee(BelongsTo→Employee), verifiedBy(BelongsTo→User)
---
## Sales
### `orders` → Order
`id` `customer_id`(FK→customers,null) `marketing_id`(FK→users,null) `cash_transaction_id`(FK→cash_transactions,unique,null) `created_by_id`(FK→users) `order_number`(30,unique) `channel`(enum) `price_type`(enum) `status`(enum,default:pending) `payment_type`(enum,default:cash) `is_affiliate`(bool,default:false) `tiktok_order_id`(100,null) `shopee_order_id`(100,null) `subtotal`(ubig) `discount`(ubig,default:0) `nego_price`(ubig,null) `marketplace_settings_snapshot`(json,null) `total_amount`(ubig) `cogs`(ubig,default:0) `notes`(text,null) `created_at` `updated_at` `deleted_at`
- Casts: channel(OrderChannel), price_type(PriceType), status(OrderStatus), payment_type(PaymentType), is_affiliate(bool), subtotal(int), discount(int), nego_price(int), total_amount(int), cogs(int), marketplace_settings_snapshot(array)
- Scopes: cancelled(), cash(), completed(), pending(), processing(), qris(), refunded(), retail(), shopee(), store(), tiktok(), transfer(), wholesale()
- Relations: cashTransaction(BelongsTo→CashTransaction), createdBy(BelongsTo→User), customer(BelongsTo→Customer), marketing(BelongsTo→User), orderItems(HasMany→OrderItem)
### `order_items` → OrderItem
`id` `order_id`(FK→orders,null) `user_id`(FK→users,null) `product_variant_id`(FK→product_variants) `stock_quality`(enum,default:good) `quantity`(uint) `unit_price`(ubig) `subtotal`(ubig) `created_at` `updated_at` `deleted_at`
- Casts: stock_quality(ProductStockQuality), quantity(int), unit_price(int), subtotal(int)
- Scopes: good(), reject()
- Relations: order(BelongsTo→Order), productVariant(BelongsTo→ProductVariant), user(BelongsTo→User)
### `rejections` → Rejection
`id` `rejectable_type`(string,null,morph) `rejectable_id`(ubig,null,morph) `rejected_by_id`(FK→users) `reason`(500) `created_at` `updated_at` `deleted_at`
- Relations: rejectable(MorphTo), rejectedBy(BelongsTo→User)
---
## Production
### `cuttings` → Cutting
`id` `created_by_id`(FK→users) `submitted_by_id`(FK→users,null) `status`(enum,default:in_progress) `description`(100,null) `total_material_cost`(ubig,null) `sewing_cost`(ubig,default:0) `other_cost`(ubig,default:0) `cost_per_unit`(ubig,null) `created_at` `updated_at` `deleted_at`
- Casts: status(CuttingStatus), total_material_cost(int), cost_per_unit(int), sewing_cost(int), other_cost(int)
- Scopes: cancelled(), completed(), inProgress()
- Relations: createdBy(BelongsTo→User), submittedBy(BelongsTo→User), cuttingMaterialCombinations(HasMany→CuttingMaterialCombination), cuttingMaterials(HasMany→CuttingMaterial), cuttingResults(HasMany→CuttingResult)
### `cutting_material_combinations` → CuttingMaterialCombination
`id` `user_id`(FK→users,null) `cutting_id`(FK→cuttings,null) `material_result`(int,null) `created_at` `updated_at` `deleted_at`
- Casts: material_result(int)
- Accessor: formatted_material_result → 'X.XXX'
- Relations: cutting(BelongsTo→Cutting), cuttingMaterials(HasMany→CuttingMaterial,combination_id), user(BelongsTo→User)
### `cutting_materials` → CuttingMaterial
`id` `user_id`(FK→users,null) `cutting_id`(FK→cuttings,null) `raw_material_price_id`(FK→raw_material_prices) `combination_id`(FK→cutting_material_combinations,null) `material_usage`(int) `material_result`(int,null) `created_at` `updated_at` `deleted_at`
- Casts: material_usage(int), material_result(int)
- Accessor: formatted_material_result → 'X.XXX', formatted_material_usage → 'X.XXX'
- Relations: cutting(BelongsTo→Cutting), rawMaterialPrice(BelongsTo→RawMaterialPrice), combination(BelongsTo→CuttingMaterialCombination), user(BelongsTo→User)
### `cutting_results` → CuttingResult
`id` `user_id`(FK→users,null) `cutting_id`(FK→cuttings,null) `product_name`(255,null) `cutting_result`(uint,null) `sample`(uint,null) `original_outside_sample`(uint,null) `created_at` `updated_at` `deleted_at`
- Casts: cutting_result(int), sample(int), original_outside_sample(int)
- Accessor: formatted_cutting_result → 'X.XXX', formatted_original_outside_sample → 'X.XXX', formatted_sample → 'X.XXX'
- Relations: cutting(BelongsTo→Cutting), user(BelongsTo→User)
---
## Inventory
### `purchases` → Purchase
`id` `supplier_id`(FK→suppliers) `created_by_id`(FK→users) `subtotal`(ubig) `discount`(ubig,default:0) `shipping_cost`(ubig,default:0) `total`(ubig) `notes`(100,null) `created_at` `updated_at` `deleted_at`
- Casts: subtotal(int), discount(int), shipping_cost(int), total(int)
- Relations: createdBy(BelongsTo→User), supplier(BelongsTo→Supplier), purchaseItems(HasMany→PurchaseItem)
### `purchase_items` → PurchaseItem
`id` `purchase_id`(FK→purchases,null) `user_id`(FK→users,null) `raw_material_price_id`(FK→raw_material_prices) `quantity`(uint) `unit_price`(ubig) `subtotal`(ubig) `created_at` `updated_at` `deleted_at`
- Casts: quantity(int), unit_price(int), subtotal(int)
- Relations: purchase(BelongsTo→Purchase), rawMaterialPrice(BelongsTo→RawMaterialPrice,withTrashed), user(BelongsTo→User)
### `restocks` → Restock
`id` `created_by_id`(FK→users) `subtotal`(ubig) `total`(ubig) `notes`(100,null) `stock_type`(enum,default:good) `created_at` `updated_at` `deleted_at`
- Casts: stock_type(ProductStockQuality), subtotal(int), total(int)
- Scopes: good(), reject()
- Relations: createdBy(BelongsTo→User), restockItems(HasMany→RestockItem)
### `restock_items` → RestockItem
`id` `restock_id`(FK→restocks,null) `user_id`(FK→users,null) `product_variant_id`(FK→product_variants) `quantity`(int) `unit_price`(ubig) `subtotal`(ubig) `created_at` `updated_at` `deleted_at`
- Casts: quantity(int), unit_price(int), subtotal(int)
- Relations: restock(BelongsTo→Restock), productVariant(BelongsTo→ProductVariant), user(BelongsTo→User)
### `stok_opnames` → StokOpname
`id` `created_by_id`(FK→users) `verified_by_id`(FK→users,null) `opname_date`(date) `status`(enum,default:draft) `notes`(text,null) `verification_notes`(text,null) `created_at` `updated_at` `deleted_at`
- Casts: status(StokOpnameStatus), opname_date(date:Y-m-d)
- Scopes: cancelled(), completed(), draft(), inProgress(), verified()
- Relations: createdBy(BelongsTo→User), verifiedBy(BelongsTo→User), stokOpnameItems(HasMany→StokOpnameItem)
### `stok_opname_items` → StokOpnameItem
`id` `stok_opname_id`(FK→stok_opnames) `product_variant_id`(FK→product_variants) `stock_quality`(enum,default:good) `system_stock`(uint,default:0) `physical_stock`(uint,default:0) `difference`(int,default:0) `notes`(text,null) `created_at` `updated_at`
- No soft deletes
- Casts: stock_quality(ProductStockQuality), system_stock(int), physical_stock(int), difference(int)
- Scopes: good(), reject()
- Relations: stokOpname(BelongsTo→StokOpname), productVariant(BelongsTo→ProductVariant)
### `retail_stock_histories` → RetailStockHistory
`id` `product_variant_id`(FK→product_variants) `user_id`(FK→users) `quantity`(uint) `stock_before`(uint) `retail_stock_before`(uint) `stock_after`(uint) `retail_stock_after`(uint) `notes`(255,null) `created_at`
- No soft deletes, no updated_at
- $timestamps = false
- Casts: quantity(int), stock_before(int), retail_stock_before(int), stock_after(int), retail_stock_after(int)
- Accessor: formatted_quantity → 'X.XXX', formatted_retail_stock_before → 'X.XXX', formatted_retail_stock_after → 'X.XXX', formatted_stock_before → 'X.XXX', formatted_stock_after → 'X.XXX'
- Relations: productVariant(BelongsTo→ProductVariant), user(BelongsTo→User)
### `stock_mutations` → StockMutation
`id` `user_id`(FK→users) `stockable_type`(string) `stockable_id`(ubig) `type`(string) `source_type`(string,null) `source_id`(ubig,null) `quantity`(int) `stock_before`(int) `stock_after`(int) `stock_quality`(string,null) `description`(string,null) `created_at` `updated_at`
- No soft deletes
- Casts: quantity(int), stock_before(int), stock_after(int)
- Accessor: formatted_quantity → 'X.XXX', formatted_stock_after → 'X.XXX', formatted_stock_before → 'X.XXX'
- Relations: stockable(MorphTo), source(MorphTo), user(BelongsTo→User)
---
## Settings
### `system_configurations` → SystemConfiguration
`id` `created_at` `updated_at`
- Singleton table
### `homepage_configurations` → HomepageConfiguration
`id` `created_at` `updated_at`
- Singleton table
### `settings` (laravel-settings)
`id` `group`(string) `name`(string) `locked`(bool,default:false) `payload`(json) `created_at` `updated_at`
- UNIQUE(group, name)
---
## Enums
| Enum | Values | Used In |
|------|--------|---------|
| `CashTransactionType` | deposit, expense, transfer, withdrawal | cash_transactions.type |
| `CuttingStatus` | in_progress, completed, cancelled | cuttings.status |
| `EmployeeAdvanceStatus` | pending, approved, rejected, paid, cancelled | employee_advances.status |
| `EmploymentStatus` | full_time, part_time, contract, internship, resigned | employees.employment_status |
| `Gender` | male, female | user_profiles.gender |
| `LeaveRequestStatus` | pending, approved, rejected, cancelled | leave_requests.status |
| `Modules` | — | Permission module names |
| `OrderChannel` | store, shopee, tiktok | orders.channel |
| `OrderStatus` | pending, processing, completed, cancelled, refunded | orders.status |
| `PaymentType` | cash, transfer, qris | orders.payment_type |
| `PayrollAdjustmentType` | bonus, deduction | payroll_adjustments.type |
| `PayrollPeriodStatus` | open, closed | payroll_periods.status |
| `PayrollStatus` | unpaid, paid, cancelled | payrolls.status |
| `Permission` | — | Permission action names |
| `PriceType` | retail, wholesale, capital | product_prices.type, orders.price_type |
| `ProductStatus` | active, draft, inactive, pending, rejected | products.status |
| `ProductStockQuality` | good, reject | order_items.stock_quality, restocks.stock_type, stok_opname_items.stock_quality |
| `RawMaterialUnit` | kg, meter, yard | raw_materials.unit |
| `Role` | — | Role names |
| `StokOpnameStatus` | draft, in_progress, completed, verified, cancelled | stok_opnames.status |
## Service Concerns
| Trait | Methods | Used By |
|-------|---------|---------|
| `HandlesCashTransactions` | getCashAccount(), creditCash(), debitCash() | CashAccountService, ExpenseService, EmployeeAdvanceService, PayrollPeriodService |
| `HasStockAdjustment` | adjustStock(), adjustVariantStock(), applyStock(), reverseStock() | TransactionService, RestockService |
| `RegistersMedia` | registerMedia(), syncPhoto() | CashAccountService, CuttingService, ExpenseService, PurchaseService, RestockService, TransactionService |
---
## Form Request Validation Rules
> Validasi harus SESUAI dengan DB schema (type + max length).
### Master
| Request | Field | Rules | DB Match |
|---------|-------|-------|----------|
| `CategoryRequest` | `name` | `required, string, max:50, unique:categories,name` | ✅ varchar(50) |
| `CustomerRequest` | `name` | `required, string, max:200, unique:customers,name` | ✅ varchar(200) |
| `CustomerRequest` | `phone_number` | `nullable, string, max:20` | ✅ varchar(20) |
| `CustomerRequest` | `address` | `nullable, string` | ✅ text |
| `SupplierRequest` | `name` | `required, string, max:200, unique:suppliers,name` | ✅ varchar(200) |
| `SupplierRequest` | `phone_number` | `nullable, string, max:20` | ✅ varchar(20) |
| `RawMaterialRequest` | `name` | `required, string, max:200` | ✅ varchar(200) |
| `RawMaterialRequest` | `unit` | `required, in:kg,meter,yard` | ✅ enum |
| `RawMaterialVariantRequest` | `variant` | `required, string, max:200` | ✅ varchar(200) |
| `RawMaterialVariantRequest` | `price` | `required, integer, min:0` | ✅ uint |
| `ProductRequest` | `name` | `required, string, max:200` | ✅ varchar(200) |
| `ProductVariantRequest` | `name` | `required, string, max:200` | ✅ varchar(200) |
### Finance
| Request | Field | Rules | DB Match |
|---------|-------|-------|----------|
| `CashAccountRequest` | `name` | `required, string, max:200, unique:cash_accounts,name` | ✅ varchar(200) |
| `CashTransactionRequest` | `amount` | `required, integer, min:1` | ✅ ubig |
| `CashTransactionRequest` | `description` | `required, string, max:100` | ✅ varchar(100) |
| `ExpenseRequest` | `amount` | `required, integer, min:1` | ✅ ubig |
| `ExpenseRequest` | `description` | `required, string, max:100` | ✅ varchar(100) |
| `EmployeeAdvanceRequest` | `amount` | `required, integer, min:1` | ✅ ubig |
| `EmployeeAdvanceRequest` | `description` | `required, string, max:100` | ✅ varchar(100) |
| `EmployeeAdvanceRequest` | `due_date` | `required, date, after_or_equal:today` | ✅ date |
| `EmployeeAdvancePaymentRequest` | `amount` | `required, integer, min:1` | ✅ ubig |
| `EmployeeAdvancePaymentRequest` | `description` | `nullable, string, max:100` | ✅ varchar(100) nullable |
| `PayrollAdjustmentRequest` | `type` | `required, in:bonus,deduction` | ✅ enum |
| `PayrollAdjustmentRequest` | `amount` | `required, integer, min:1` | ✅ ubig |
| `PayrollAdjustmentRequest` | `description` | `required, string, max:100` | ✅ varchar(100) |
### HR
| Request | Field | Rules | DB Match |
|---------|-------|-------|----------|
| `EmployeeRequest` | `email` | `required, email, max:100, unique:users,email` | ✅ varchar(100) |
| `EmployeeRequest` | `username` | `required, string, max:20, alpha_dash, unique:users,username` | ✅ varchar(20) |
| `EmployeeRequest` | `full_name` | `required, string, max:200` | ✅ varchar(200) |
| `EmployeeRequest` | `phone_number` | `nullable, string, max:20` | ✅ varchar(20) |
| `EmployeeRequest` | `gender` | `nullable, in:male,female` | ✅ enum |
| `EmployeeRequest` | `base_salary` | `required, integer, min:0` | ✅ uint |
| `AttendanceRequest` | `latitude` | `required, numeric, min:-90, max:90` | ✅ decimal(10,7) |
| `AttendanceRequest` | `longitude` | `required, numeric, min:-180, max:180` | ✅ decimal(10,7) |
| `LeaveRequestRequest` | `start_date` | `required, date, after:today` | ✅ date |
| `LeaveRequestRequest` | `end_date` | `required, date, after_or_equal:start_date` | ✅ date |
### Sales
| Request | Field | Rules | DB Match |
|---------|-------|-------|----------|
| `TransactionRequest` | `channel` | `required, in:store,shopee,tiktok` | ✅ enum |
| `TransactionRequest` | `price_type` | `required, in:retail,wholesale` | ✅ enum |
| `TransactionRequest` | `payment_type` | `required, in:cash,transfer,qris` | ✅ enum |
| `TransactionRequest` | `tiktok_order_id` | `nullable, string, max:100` | ✅ varchar(100) |
| `TransactionRequest` | `shopee_order_id` | `nullable, string, max:100` | ✅ varchar(100) |
| `TransactionRequest` | `notes` | `nullable, string, max:100` | ✅ text (UI limit) |
### Production
| Request | Field | Rules | DB Match |
|---------|-------|-------|----------|
| `CuttingRequest` | `description` | `nullable, string, max:100` | ✅ varchar(100) |
| `CuttingRequest` | `product_name` | `required, string, max:255` | ✅ varchar(255) |
| `CuttingRequest` | `cutting_result` | `required, integer, min:1` | ✅ uint |
| `CuttingRequest` | `sample` | `required, integer` | ✅ uint |
| `CuttingRequest` | `original_outside_sample` | `required, integer` | ✅ uint |
### Inventory
| Request | Field | Rules | DB Match |
|---------|-------|-------|----------|
| `PurchaseRequest` | `supplier_id` | `required, integer, exists:suppliers,id` | ✅ FK |
| `PurchaseRequest` | `notes` | `nullable, string, max:100` | ✅ varchar(100) |
| `RestockRequest` | `stock_type` | `required, in:good,reject` | ✅ enum |
| `RestockRequest` | `notes` | `nullable, string, max:100` | ✅ varchar(100) |

1165
AGENTS.md

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,15 @@
<?php
namespace App\Concerns;
use App\Enums\Role;
trait HasRoleChecks
{
public static function hasAnyRole(array $roles): bool
{
return auth()->user()->hasAnyRole(
array_map(fn (Role $role) => $role->value, $roles)
);
}
}

View File

@ -121,11 +121,6 @@ enum Permission: string
case PAYROLL_CANCEL = 'payroll.cancel';
case PAYROLL_ADJUST = 'payroll.adjust';
// Owner Verifications
case OWNER_VERIFICATIONS_VIEW = 'owner_verifications.view';
case OWNER_VERIFICATIONS_VERIFY = 'owner_verifications.verify';
case OWNER_VERIFICATIONS_REJECT = 'owner_verifications.reject';
// Restocks
case RESTOCKS_VIEW = 'restocks.view';
case RESTOCKS_CREATE = 'restocks.create';

View File

@ -11,6 +11,8 @@ enum ProductStatus: string
case ACTIVE = 'active';
case INACTIVE = 'inactive';
case DRAFT = 'draft';
case PENDING = 'pending';
case REJECTED = 'rejected';
public function label(): string
{
@ -18,6 +20,8 @@ public function label(): string
self::ACTIVE => 'Aktif',
self::INACTIVE => 'Non Aktif',
self::DRAFT => 'Draft',
self::PENDING => 'Menunggu Verifikasi',
self::REJECTED => 'Ditolak',
};
}
}

View File

@ -10,12 +10,14 @@ enum ProductStockQuality: string
case GOOD = 'good';
case REJECT = 'reject';
case RETAIL = 'retail';
public function label(): string
{
return match ($this) {
self::GOOD => 'Bagus',
self::REJECT => 'Reject',
self::RETAIL => 'Ecer',
};
}
}

View File

@ -2,12 +2,8 @@
namespace App\Enums;
use App\Traits\ProvidesEnumOptions;
enum Role: string
{
use ProvidesEnumOptions;
case DEVELOPER = 'developer';
case OWNER = 'owner';
case ADMIN_TOKO = 'admin-toko';
@ -95,7 +91,6 @@ public function permissions(): array
Permission::CUSTOMERS_VIEW,
Permission::PRODUCTS_VIEW,
Permission::STOCKS_VIEW,
Permission::ORDERS_VIEW,
@ -139,8 +134,6 @@ public function permissions(): array
Permission::ANALYSIS_TOP_PRODUCTS,
Permission::ANALYSIS_MARKETING_SALES,
Permission::STOCKS_VIEW,
Permission::STOK_OPNAMES_VIEW,
Permission::ATTENDANCES_VIEW,
@ -167,9 +160,6 @@ public function permissions(): array
Permission::PRODUCTS_UPDATE,
Permission::PRODUCTS_DELETE,
Permission::PRODUCTS_TOGGLE_STATUS,
Permission::STOCKS_VIEW,
Permission::OWNER_VERIFICATIONS_VIEW,
Permission::ORDERS_VIEW,
Permission::ORDERS_CREATE,
@ -295,9 +285,6 @@ public function permissions(): array
Permission::RAW_MATERIALS_CREATE,
Permission::RAW_MATERIALS_UPDATE,
Permission::RAW_MATERIALS_TOGGLE_STATUS,
Permission::STOCKS_VIEW,
Permission::OWNER_VERIFICATIONS_VIEW,
Permission::PRODUCTS_VIEW,
Permission::PRODUCTS_CREATE,
@ -397,7 +384,6 @@ public function permissions(): array
Permission::LEAVE_REQUESTS_DELETE,
Permission::PRODUCTS_VIEW,
Permission::STOCKS_VIEW,
Permission::STOK_OPNAMES_VIEW,
Permission::STOK_OPNAMES_CREATE,

View File

@ -36,7 +36,7 @@ public function updateSystem(UpdateSystemRequest $request): RedirectResponse
Inertia::flash('toast', ['type' => 'success', 'message' => 'Pengaturan sistem berhasil diperbarui.']);
return to_route('admin.settings.index');
return back();
}
public function updateHomepage(UpdateHomepageRequest $request): RedirectResponse
@ -45,7 +45,7 @@ public function updateHomepage(UpdateHomepageRequest $request): RedirectResponse
Inertia::flash('toast', ['type' => 'success', 'message' => 'Pengaturan homepage berhasil diperbarui.']);
return to_route('admin.settings.index');
return back();
}
public function updateSocialMedia(UpdateSocialMediaRequest $request): RedirectResponse
@ -54,7 +54,7 @@ public function updateSocialMedia(UpdateSocialMediaRequest $request): RedirectRe
Inertia::flash('toast', ['type' => 'success', 'message' => 'Pengaturan media sosial berhasil diperbarui.']);
return to_route('admin.settings.index');
return back();
}
public function updateMarketplace(UpdateMarketplaceRequest $request): RedirectResponse
@ -63,7 +63,7 @@ public function updateMarketplace(UpdateMarketplaceRequest $request): RedirectRe
Inertia::flash('toast', ['type' => 'success', 'message' => 'Pengaturan marketplace berhasil diperbarui.']);
return to_route('admin.settings.index');
return back();
}
public function updateHR(UpdateHRRequest $request): RedirectResponse
@ -72,6 +72,6 @@ public function updateHR(UpdateHRRequest $request): RedirectResponse
Inertia::flash('toast', ['type' => 'success', 'message' => 'Pengaturan HR berhasil diperbarui.']);
return to_route('admin.settings.index');
return back();
}
}

View File

@ -7,7 +7,8 @@
use App\Http\Requests\Admin\Finance\CashTransactionRequest;
use App\Http\Requests\PaginatedRequest;
use App\Models\CashTransaction;
use App\Services\Admin\Finance\CashAccountService;
use App\Services\Admin\Finance\Cash\CashAccountService;
use App\Services\Admin\Finance\Cash\CashTransactionService;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;
use Inertia\Response;
@ -15,16 +16,15 @@
class CashAccountController extends Controller
{
public function __construct(
private CashAccountService $service
private CashAccountService $cashAccountService,
private CashTransactionService $cashTransactionService,
) {}
public function index(PaginatedRequest $request): Response
{
$cashAccount = $this->service->get();
return Inertia::render('admin/finance/cash-account/index', [
'cashAccount' => $cashAccount,
'transactions' => $this->service->paginatedTransactions(
'cashAccount' => $this->cashAccountService->get(),
'transactions' => $this->cashTransactionService->paginated(
...$request->validatedWithDefaults(),
filters: $request->only(['type']),
),
@ -38,7 +38,7 @@ public function index(PaginatedRequest $request): Response
public function deposit(CashTransactionRequest $request): RedirectResponse
{
return $this->handleAction(
fn () => $this->service->deposit($request->validated()),
fn () => $this->cashTransactionService->deposit($request->validated()),
'Deposit berhasil ditambahkan.',
'admin.finance.cash-accounts.index'
);
@ -47,7 +47,7 @@ public function deposit(CashTransactionRequest $request): RedirectResponse
public function withdrawal(CashTransactionRequest $request): RedirectResponse
{
return $this->handleAction(
fn () => $this->service->withdrawal($request->validated()),
fn () => $this->cashTransactionService->withdrawal($request->validated()),
'Withdrawal berhasil ditambahkan.',
'admin.finance.cash-accounts.index'
);
@ -56,7 +56,7 @@ public function withdrawal(CashTransactionRequest $request): RedirectResponse
public function update(CashTransactionRequest $request, CashTransaction $transaction): RedirectResponse
{
return $this->handleAction(
fn () => $this->service->updateTransaction($transaction, $request->validated()),
fn () => $this->cashTransactionService->update($transaction, $request->validated()),
'Transaksi berhasil diperbarui.',
'admin.finance.cash-accounts.index'
);
@ -65,7 +65,7 @@ public function update(CashTransactionRequest $request, CashTransaction $transac
public function destroy(CashTransaction $transaction): RedirectResponse
{
return $this->handleAction(
fn () => $this->service->deleteTransaction($transaction),
fn () => $this->cashTransactionService->destroy($transaction),
'Transaksi berhasil dihapus.',
'admin.finance.cash-accounts.index'
);

View File

@ -36,7 +36,7 @@ public function index(PaginatedRequest $request): Response
public function store(EmployeeAdvanceRequest $request): RedirectResponse
{
return $this->handleAction(
fn () => $this->service->create($request->validated()),
fn () => $this->service->store($request->validated()),
'Kasbon berhasil ditambahkan.',
'admin.finance.employee-advances.index'
);
@ -54,7 +54,7 @@ public function update(EmployeeAdvanceRequest $request, EmployeeAdvance $employe
public function destroy(EmployeeAdvance $employeeAdvance): RedirectResponse
{
return $this->handleAction(
fn () => $this->service->delete($employeeAdvance),
fn () => $this->service->destroy($employeeAdvance),
'Kasbon berhasil dihapus.',
'admin.finance.employee-advances.index'
);

View File

@ -27,7 +27,7 @@ public function index(PaginatedRequest $request): Response
public function store(ExpenseRequest $request): RedirectResponse
{
return $this->handleAction(
fn () => $this->service->create($request->validated()),
fn () => $this->service->store($request->validated()),
'Pengeluaran berhasil ditambahkan.',
'admin.finance.expenses.index'
);
@ -45,7 +45,7 @@ public function update(ExpenseRequest $request, Expense $expense): RedirectRespo
public function destroy(Expense $expense): RedirectResponse
{
return $this->handleAction(
fn () => $this->service->delete($expense),
fn () => $this->service->destroy($expense),
'Pengeluaran berhasil dihapus.',
'admin.finance.expenses.index'
);

View File

@ -1,12 +1,12 @@
<?php
namespace App\Http\Controllers\Admin\Finance;
namespace App\Http\Controllers\Admin\Finance\Payroll;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Finance\PayrollAdjustmentRequest;
use App\Models\Payroll;
use App\Models\PayrollAdjustment;
use App\Services\Admin\Finance\PayrollAdjustmentService;
use App\Services\Admin\Finance\Payroll\PayrollAdjustmentService;
use Illuminate\Http\RedirectResponse;
class PayrollAdjustmentController extends Controller
@ -18,7 +18,7 @@ public function __construct(
public function store(PayrollAdjustmentRequest $request, Payroll $payroll): RedirectResponse
{
return $this->handleAction(
fn () => $this->service->create($payroll, $request->validated()),
fn () => $this->service->store($payroll, $request->validated()),
'Adjustment gaji berhasil ditambahkan.',
'admin.finance.payroll-periods.show',
parameters: ['payroll_period' => $payroll->payroll_period_id]
@ -28,7 +28,7 @@ public function store(PayrollAdjustmentRequest $request, Payroll $payroll): Redi
public function destroy(PayrollAdjustment $payrollAdjustment): RedirectResponse
{
return $this->handleAction(
fn () => $this->service->delete($payrollAdjustment),
fn () => $this->service->destroy($payrollAdjustment),
'Adjustment gaji berhasil dihapus.',
'admin.finance.payroll-periods.show',
parameters: ['payroll_period' => $payrollAdjustment->payroll->payroll_period_id]

View File

@ -1,10 +1,10 @@
<?php
namespace App\Http\Controllers\Admin\Finance;
namespace App\Http\Controllers\Admin\Finance\Payroll;
use App\Http\Controllers\Controller;
use App\Models\Payroll;
use App\Services\Admin\Finance\PayrollPeriodService;
use App\Services\Admin\Finance\Payroll\PayrollPeriodService;
use Illuminate\Http\RedirectResponse;
class PayrollController extends Controller

View File

@ -1,18 +1,21 @@
<?php
namespace App\Http\Controllers\Admin\Finance;
namespace App\Http\Controllers\Admin\Finance\Payroll;
use App\Enums\PayrollPeriodStatus;
use App\Concerns\HasRoleChecks;
use App\Enums\Role;
use App\Http\Controllers\Controller;
use App\Http\Requests\PaginatedRequest;
use App\Models\PayrollPeriod;
use App\Services\Admin\Finance\PayrollPeriodService;
use App\Services\Admin\Finance\Payroll\PayrollPeriodService;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;
use Inertia\Response;
class PayrollPeriodController extends Controller
{
use HasRoleChecks;
public function __construct(
private PayrollPeriodService $service
) {}
@ -26,22 +29,23 @@ public function index(PaginatedRequest $request): Response
public function current(): RedirectResponse
{
$now = now();
$period = PayrollPeriod::firstOrCreate(
['year' => $now->year, 'month' => $now->month],
['status' => PayrollPeriodStatus::OPEN]
);
$period = $this->service->getCurrentOrCreate();
return to_route('admin.finance.payroll-periods.show', ['payroll_period' => $period->id]);
}
public function show(PayrollPeriod $payrollPeriod): Response
{
$period = $this->service->getDetail($payrollPeriod);
$payrollPeriod->load([
'payrolls' => function ($query) {
$query->with(['employee.user.userProfile', 'payrollAdjustments'])
->when(! self::hasAnyRole([Role::DEVELOPER, Role::OWNER, Role::DIREKTUR, Role::ADMIN_TOKO]), fn ($q) => $q->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id())))
->orderBy('id');
},
]);
return Inertia::render('admin/finance/payroll-period/show', [
'payrollPeriod' => $period,
'payrollPeriod' => $payrollPeriod,
]);
}

View File

@ -6,7 +6,6 @@
use App\Http\Requests\Admin\HR\AttendanceRequest;
use App\Models\Attendance;
use App\Services\Admin\HR\AttendanceService;
use App\Settings\HRSettings;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
@ -22,38 +21,9 @@ public function index(Request $request): Response
{
$year = $request->integer('year', now()->year);
$month = $request->integer('month', now()->month);
$hrSettings = app(HRSettings::class);
$user = auth()->user();
$isAdmin = $user->hasAnyRole(['developer', 'owner', 'direktur']);
if ($isAdmin) {
return Inertia::render('admin/hr/attendance/index', [
'attendances' => $this->service->getByMonth($year, $month),
'todayAttendance' => null,
'currentYear' => $year,
'currentMonth' => $month,
'monthStats' => $this->service->getMonthStats($year, $month),
'hrSettings' => [
'scheduled_check_in_time' => $hrSettings->scheduled_check_in_time,
'scheduled_check_out_time' => $hrSettings->scheduled_check_out_time,
],
'isAdmin' => true,
]);
}
$employeeId = $user->employee?->id;
return Inertia::render('admin/hr/attendance/index', [
'attendances' => $this->service->getByMonth($year, $month, $employeeId),
'todayAttendance' => $this->service->getToday(),
'currentYear' => $year,
'currentMonth' => $month,
'monthStats' => $this->service->getMonthStats($year, $month, $employeeId),
'hrSettings' => [
'scheduled_check_in_time' => $hrSettings->scheduled_check_in_time,
'scheduled_check_out_time' => $hrSettings->scheduled_check_out_time,
],
'isAdmin' => false,
...$this->service->getIndexData($year, $month),
]);
}

View File

@ -2,20 +2,25 @@
namespace App\Http\Controllers\Admin\HR;
use App\Concerns\HasRoleChecks;
use App\Enums\Role;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\HR\EmployeeRequest;
use App\Http\Requests\PaginatedRequest;
use App\Models\User;
use App\Services\Admin\HR\EmployeeService;
use App\Services\Admin\Settings\RoleService;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;
use Inertia\Response;
use Spatie\Permission\Models\Role;
class EmployeeController extends Controller
{
use HasRoleChecks;
public function __construct(
private EmployeeService $service
private EmployeeService $service,
private RoleService $roleService,
) {}
public function index(PaginatedRequest $request): Response
@ -26,28 +31,22 @@ public function index(PaginatedRequest $request): Response
filters: $request->only(['employment_status', 'is_active', 'gender']),
),
'filters' => $request->only(['employment_status', 'is_active', 'gender']),
'canViewAll' => $this->service->canViewAll(),
'canViewAll' => self::hasAnyRole([Role::DEVELOPER, Role::OWNER, Role::DIREKTUR, Role::ADMIN_TOKO]),
]);
}
public function create(): Response
{
$user = auth()->user();
return Inertia::render('admin/hr/employee/create', [
'roles' => $this->service->canViewAll()
? Role::where('name', '!=', 'Developer')
->when($this->service->shouldHideAdminBahanBaku(), fn ($q) => $q->where('name', '!=', 'admin-bahan-baku'))
->get(['id', 'name'])
: Role::where('name', '=', $user->roles->first()?->name)->get(['id', 'name']),
'canViewAll' => $this->service->canViewAll(),
'roles' => $this->roleService->getForEmployee(),
'canViewAll' => self::hasAnyRole([Role::DEVELOPER, Role::OWNER, Role::DIREKTUR, Role::ADMIN_TOKO]),
]);
}
public function store(EmployeeRequest $request): RedirectResponse
{
return $this->handleAction(
fn () => $this->service->create($request->validated()),
fn () => $this->service->store($request->validated()),
'Pegawai berhasil ditambahkan.',
'admin.hr.employees.index'
);
@ -59,10 +58,8 @@ public function edit(User $user): Response
return Inertia::render('admin/hr/employee/edit', [
'employee' => $user,
'roles' => Role::where('name', '!=', 'Developer')
->when($this->service->shouldHideAdminBahanBaku(), fn ($q) => $q->where('name', '!=', 'admin-bahan-baku'))
->get(['id', 'name']),
'canViewAll' => $this->service->canViewAll(),
'roles' => $this->roleService->getForEmployee(),
'canViewAll' => self::hasAnyRole([Role::DEVELOPER, Role::OWNER, Role::DIREKTUR, Role::ADMIN_TOKO]),
]);
}
@ -78,7 +75,7 @@ public function update(EmployeeRequest $request, User $user): RedirectResponse
public function destroy(User $user): RedirectResponse
{
return $this->handleAction(
fn () => $this->service->delete($user),
fn () => $this->service->destroy($user),
'Pegawai berhasil dihapus.',
'admin.hr.employees.index'
);
@ -91,7 +88,7 @@ public function toggleActive(User $user): RedirectResponse
Inertia::flash('toast', ['type' => 'success', 'message' => "Pegawai berhasil {$status}."]);
return to_route('admin.hr.employees.index');
return back();
}
public function resetPassword(User $user): RedirectResponse

View File

@ -35,7 +35,7 @@ public function index(PaginatedRequest $request): Response
public function store(LeaveRequestRequest $request): RedirectResponse
{
return $this->handleAction(
fn () => $this->service->create($request->validated()),
fn () => $this->service->store($request->validated()),
'Permohonan cuti berhasil ditambahkan.',
'admin.hr.leave-requests.index'
);
@ -53,7 +53,7 @@ public function update(LeaveRequestRequest $request, LeaveRequest $leaveRequest)
public function destroy(LeaveRequest $leaveRequest): RedirectResponse
{
return $this->handleAction(
fn () => $this->service->delete($leaveRequest),
fn () => $this->service->destroy($leaveRequest),
'Permohonan cuti berhasil dihapus.',
'admin.hr.leave-requests.index'
);

View File

@ -7,6 +7,7 @@
use App\Http\Requests\PaginatedRequest;
use App\Models\Cutting;
use App\Services\Admin\Manage\CuttingService;
use App\Services\Admin\Master\RawMaterial\RawMaterialVariantService;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;
use Inertia\Response;
@ -15,6 +16,7 @@ class CuttingController extends Controller
{
public function __construct(
private CuttingService $service,
private RawMaterialVariantService $rawMaterialVariantService,
) {}
public function index(PaginatedRequest $request): Response
@ -29,14 +31,14 @@ public function index(PaginatedRequest $request): Response
public function create(): Response
{
return Inertia::render('admin/manage/cutting/create', [
'data' => $this->service->getForCreate(),
'rawMaterials' => $this->rawMaterialVariantService->getForCutting(),
]);
}
public function store(CuttingRequest $request): RedirectResponse
{
return $this->handleAction(
fn () => $this->service->create($request->validated()),
fn () => $this->service->store($request->validated()),
'Cutting berhasil ditambahkan.',
'admin.manage.cuttings.index',
'admin.manage.cuttings.create'
@ -47,7 +49,7 @@ public function edit(Cutting $cutting): Response
{
return Inertia::render('admin/manage/cutting/edit', [
'cutting' => $this->service->getForEdit($cutting),
'data' => $this->service->getForCreate(),
'rawMaterials' => $this->rawMaterialVariantService->getForCutting(),
]);
}
@ -65,7 +67,7 @@ public function update(CuttingRequest $request, Cutting $cutting): RedirectRespo
public function destroy(Cutting $cutting): RedirectResponse
{
return $this->handleAction(
fn () => $this->service->delete($cutting),
fn () => $this->service->destroy($cutting),
'Cutting berhasil dihapus.',
'admin.manage.cuttings.index'
);

View File

@ -7,6 +7,7 @@
use App\Http\Requests\PaginatedRequest;
use App\Models\Purchase;
use App\Services\Admin\Manage\PurchaseService;
use App\Services\Admin\Master\RawMaterial\RawMaterialVariantService;
use App\Services\Admin\Master\SupplierService;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;
@ -17,6 +18,7 @@ class PurchaseController extends Controller
public function __construct(
private readonly PurchaseService $service,
private readonly SupplierService $supplierService,
private readonly RawMaterialVariantService $rawMaterialVariantService,
) {}
public function index(PaginatedRequest $request): Response
@ -34,14 +36,15 @@ public function index(PaginatedRequest $request): Response
public function create(): Response
{
return Inertia::render('admin/manage/purchase/create', [
'data' => $this->service->getForCreate(),
'suppliers' => $this->supplierService->getAll(),
'rawMaterials' => $this->rawMaterialVariantService->getForCutting(),
]);
}
public function store(PurchaseRequest $request): RedirectResponse
{
return $this->handleAction(
fn () => $this->service->create($request->validated()),
fn () => $this->service->store($request->validated()),
'Belanja berhasil ditambahkan.',
'admin.manage.purchases.index',
'admin.manage.purchases.create'
@ -52,7 +55,8 @@ public function edit(Purchase $purchase): Response
{
return Inertia::render('admin/manage/purchase/edit', [
'purchase' => $this->service->getForEdit($purchase),
'data' => $this->service->getForCreate(),
'suppliers' => $this->supplierService->getAll(),
'rawMaterials' => $this->rawMaterialVariantService->getForCutting(),
]);
}
@ -70,7 +74,7 @@ public function update(PurchaseRequest $request, Purchase $purchase): RedirectRe
public function destroy(Purchase $purchase): RedirectResponse
{
return $this->handleAction(
fn () => $this->service->delete($purchase),
fn () => $this->service->destroy($purchase),
'Belanja berhasil dihapus.',
'admin.manage.purchases.index'
);

View File

@ -7,6 +7,7 @@
use App\Http\Requests\PaginatedRequest;
use App\Models\Restock;
use App\Services\Admin\Manage\RestockService;
use App\Services\Admin\Master\Product\ProductVariantService;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;
use Inertia\Response;
@ -15,6 +16,7 @@ class RestockController extends Controller
{
public function __construct(
private RestockService $service,
private ProductVariantService $productVariantService,
) {}
public function index(PaginatedRequest $request): Response
@ -29,14 +31,14 @@ public function index(PaginatedRequest $request): Response
public function create(): Response
{
return Inertia::render('admin/manage/restock/create', [
'data' => $this->service->getForCreate(),
'products' => $this->productVariantService->getForRestock(),
]);
}
public function store(RestockRequest $request): RedirectResponse
{
return $this->handleAction(
fn () => $this->service->create($request->validated()),
fn () => $this->service->store($request->validated()),
'Restock berhasil ditambahkan.',
'admin.manage.restocks.index',
'admin.manage.restocks.create'
@ -47,7 +49,7 @@ public function edit(Restock $restock): Response
{
return Inertia::render('admin/manage/restock/edit', [
'restock' => $this->service->getForEdit($restock),
'data' => $this->service->getForCreate(),
'products' => $this->productVariantService->getForRestock(),
]);
}
@ -65,7 +67,7 @@ public function update(RestockRequest $request, Restock $restock): RedirectRespo
public function destroy(Restock $restock): RedirectResponse
{
return $this->handleAction(
fn () => $this->service->delete($restock),
fn () => $this->service->destroy($restock),
'Restock berhasil dihapus.',
'admin.manage.restocks.index'
);

View File

@ -2,11 +2,17 @@
namespace App\Http\Controllers\Admin\Manage;
use App\Enums\OrderChannel;
use App\Enums\PaymentType;
use App\Enums\PriceType;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Manage\TransactionRequest;
use App\Http\Requests\PaginatedRequest;
use App\Models\Order;
use App\Models\User;
use App\Services\Admin\Manage\TransactionService;
use App\Services\Admin\Master\CustomerService;
use App\Services\Admin\Master\Product\ProductVariantService;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;
use Inertia\Response;
@ -15,6 +21,8 @@ class TransactionController extends Controller
{
public function __construct(
private TransactionService $service,
private ProductVariantService $productVariantService,
private CustomerService $customerService,
) {}
public function index(PaginatedRequest $request): Response
@ -35,14 +43,19 @@ public function index(PaginatedRequest $request): Response
public function create(): Response
{
return Inertia::render('admin/manage/transaction/create', [
'data' => $this->service->getForCreate(),
'products' => $this->productVariantService->getForTransaction(),
'customers' => $this->customerService->getAll(),
'employees' => $this->getEmployees(),
'channelOptions' => OrderChannel::toSelect(),
'paymentTypeOptions' => PaymentType::toSelect(),
'priceTypeOptions' => PriceType::toSelect()->filter(fn ($p) => $p['value'] !== PriceType::CAPITAL->value)->values(),
]);
}
public function store(TransactionRequest $request): RedirectResponse
{
return $this->handleAction(
fn () => $this->service->create($request->validated()),
fn () => $this->service->store($request->validated()),
'Transaksi berhasil ditambahkan.',
'admin.manage.transactions.index',
'admin.manage.transactions.create'
@ -53,7 +66,12 @@ public function edit(Order $transaction): Response
{
return Inertia::render('admin/manage/transaction/edit', [
'transaction' => $this->service->getForEdit($transaction),
'data' => $this->service->getForCreate(),
'products' => $this->productVariantService->getForTransaction(),
'customers' => $this->customerService->getAll(),
'employees' => $this->getEmployees(),
'channelOptions' => OrderChannel::toSelect(),
'paymentTypeOptions' => PaymentType::toSelect(),
'priceTypeOptions' => PriceType::toSelect()->filter(fn ($p) => $p['value'] !== PriceType::CAPITAL->value)->values(),
]);
}
@ -71,7 +89,7 @@ public function update(TransactionRequest $request, Order $transaction): Redirec
public function destroy(Order $transaction): RedirectResponse
{
return $this->handleAction(
fn () => $this->service->delete($transaction),
fn () => $this->service->destroy($transaction),
'Transaksi berhasil dihapus.',
'admin.manage.transactions.index'
);
@ -85,4 +103,16 @@ public function updateStatus(Order $transaction): RedirectResponse
'admin.manage.transactions.index'
);
}
private function getEmployees()
{
return User::query()
->select('id')
->active()
->with('userProfile:id,user_id,full_name')
->orderBy('id')
->get()
->filter(fn (User $user) => $user->userProfile?->full_name)
->values();
}
}

View File

@ -26,11 +26,11 @@ public function index(PaginatedRequest $request): Response
public function store(CategoryRequest $request): RedirectResponse
{
$this->service->create($request->validated());
$this->service->store($request->validated());
Inertia::flash('toast', ['type' => 'success', 'message' => 'Kategori berhasil ditambahkan.']);
return to_route('admin.master.categories.index');
return back();
}
public function update(CategoryRequest $request, Category $category): RedirectResponse
@ -39,15 +39,15 @@ public function update(CategoryRequest $request, Category $category): RedirectRe
Inertia::flash('toast', ['type' => 'success', 'message' => 'Kategori berhasil diperbarui.']);
return to_route('admin.master.categories.index');
return back();
}
public function destroy(Category $category): RedirectResponse
{
$this->service->delete($category);
$this->service->destroy($category);
Inertia::flash('toast', ['type' => 'success', 'message' => 'Kategori berhasil dihapus.']);
return to_route('admin.master.categories.index');
return back();
}
}

View File

@ -26,11 +26,11 @@ public function index(PaginatedRequest $request): Response
public function store(CustomerRequest $request): RedirectResponse
{
$this->service->create($request->validated());
$this->service->store($request->validated());
Inertia::flash('toast', ['type' => 'success', 'message' => 'Customer berhasil ditambahkan.']);
return to_route('admin.master.customers.index');
return back();
}
public function update(CustomerRequest $request, Customer $customer): RedirectResponse
@ -39,15 +39,15 @@ public function update(CustomerRequest $request, Customer $customer): RedirectRe
Inertia::flash('toast', ['type' => 'success', 'message' => 'Customer berhasil diperbarui.']);
return to_route('admin.master.customers.index');
return back();
}
public function destroy(Customer $customer): RedirectResponse
{
$this->service->delete($customer);
$this->service->destroy($customer);
Inertia::flash('toast', ['type' => 'success', 'message' => 'Customer berhasil dihapus.']);
return to_route('admin.master.customers.index');
return back();
}
}

View File

@ -84,6 +84,34 @@ public function toggleStatus(Product $product): RedirectResponse
Inertia::flash('toast', ['type' => 'success', 'message' => "Status produk berhasil diubah menjadi {$status->label()}."]);
return to_route('admin.master.products.index');
return back();
}
public function approve(Product $product): RedirectResponse
{
$this->service->approve($product);
Inertia::flash('toast', ['type' => 'success', 'message' => 'Produk berhasil disetujui.']);
return back();
}
public function reject(Product $product): RedirectResponse
{
$reason = request()->input('rejection_reason', '');
$this->service->reject($product, $reason);
Inertia::flash('toast', ['type' => 'success', 'message' => 'Produk berhasil ditolak.']);
return back();
}
public function resubmit(Product $product): RedirectResponse
{
$this->service->resubmit($product);
Inertia::flash('toast', ['type' => 'success', 'message' => 'Produk berhasil diajukan ulang.']);
return back();
}
}

View File

@ -18,8 +18,6 @@ public function __construct(
public function index(StockMutationRequest $request, Product $product, ProductVariant $variant): Response
{
$perPage = $request->validatedWithDefaults()['perPage'];
return Inertia::render('admin/master/product/variant/stock-mutations', [
'product' => [
'id' => $product->id,
@ -29,7 +27,7 @@ public function index(StockMutationRequest $request, Product $product, ProductVa
'id' => $variant->id,
'name' => $variant->name,
],
'mutations' => $this->service->paginated($variant, $perPage),
'mutations' => $this->service->paginated($variant, ...$request->validatedWithDefaults()),
]);
}
}

View File

@ -36,7 +36,7 @@ public function create(): Response
public function store(RawMaterialRequest $request): RedirectResponse
{
return $this->handleAction(
fn () => $this->service->create($request->validated()),
fn () => $this->service->store($request->validated()),
'Bahan baku berhasil ditambahkan.',
'admin.master.raw-materials.index',
'admin.master.raw-materials.create'
@ -64,7 +64,7 @@ public function update(RawMaterialRequest $request, RawMaterial $rawMaterial): R
public function destroy(RawMaterial $rawMaterial): RedirectResponse
{
return $this->handleAction(
fn () => $this->service->delete($rawMaterial),
fn () => $this->service->destroy($rawMaterial),
'Bahan baku berhasil dihapus.',
'admin.master.raw-materials.index'
);
@ -77,6 +77,6 @@ public function toggleStatus(RawMaterial $rawMaterial): RedirectResponse
Inertia::flash('toast', ['type' => 'success', 'message' => "Status bahan baku berhasil diubah menjadi {$status}."]);
return to_route('admin.master.raw-materials.index');
return back();
}
}

View File

@ -26,11 +26,11 @@ public function index(PaginatedRequest $request): Response
public function store(SupplierRequest $request): RedirectResponse
{
$this->service->create($request->validated());
$this->service->store($request->validated());
Inertia::flash('toast', ['type' => 'success', 'message' => 'Supplier berhasil ditambahkan.']);
return to_route('admin.master.suppliers.index');
return back();
}
public function update(SupplierRequest $request, Supplier $supplier): RedirectResponse
@ -39,15 +39,15 @@ public function update(SupplierRequest $request, Supplier $supplier): RedirectRe
Inertia::flash('toast', ['type' => 'success', 'message' => 'Supplier berhasil diperbarui.']);
return to_route('admin.master.suppliers.index');
return back();
}
public function destroy(Supplier $supplier): RedirectResponse
{
$this->service->delete($supplier);
$this->service->destroy($supplier);
Inertia::flash('toast', ['type' => 'success', 'message' => 'Supplier berhasil dihapus.']);
return to_route('admin.master.suppliers.index');
return back();
}
}

View File

@ -34,7 +34,7 @@ public function create(): Response
public function store(RoleRequest $request): RedirectResponse
{
return $this->handleAction(
fn () => $this->service->create($request->validated()),
fn () => $this->service->store($request->validated()),
'Role berhasil ditambahkan.',
'admin.settings.roles.index'
);
@ -43,7 +43,7 @@ public function store(RoleRequest $request): RedirectResponse
public function edit(Role $role): Response
{
return Inertia::render('admin/roles/edit', [
'role' => $this->service->getById($role->id),
'role' => $role->load('permissions'),
'permissions' => $this->service->getPermissionsByModule(),
]);
}
@ -59,10 +59,10 @@ public function update(RoleRequest $request, Role $role): RedirectResponse
public function destroy(Role $role): RedirectResponse
{
$this->service->delete($role);
$this->service->destroy($role);
Inertia::flash('toast', ['type' => 'success', 'message' => 'Role berhasil dihapus.']);
return to_route('admin.settings.roles.index');
return back();
}
}

View File

@ -0,0 +1,73 @@
<?php
namespace App\Http\Controllers;
use App\Concerns\HasRoleChecks;
use App\Enums\Role;
use App\Services\AnalysisService;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class AnalysisController extends Controller
{
use HasRoleChecks;
public function __construct(
private AnalysisService $service
) {}
public function index(Request $request): Response
{
$user = $request->user();
$isManager = self::hasAnyRole([Role::OWNER, Role::DEVELOPER, Role::ADMIN_TOKO, Role::DIREKTUR]);
$startDate = $request->input('start_date');
$endDate = $request->input('end_date');
$attendance = $this->service->getAttendanceStats($startDate, $endDate);
$myAttendance = $this->service->getMyAttendance($user, $startDate, $endDate);
$cashOverview = $this->service->getCashOverview($startDate, $endDate);
$rawMaterialStock = $this->service->getRawMaterialStock();
$productStock = $this->service->getProductStock();
$revenueSummary = $this->service->getRevenueSummary($startDate, $endDate);
$monthlyRevenue = $this->service->getMonthlyRevenue($startDate, $endDate);
$monthlyRevenueByChannel = $this->service->getMonthlyRevenueByChannel($startDate, $endDate);
$revenueByPaymentType = $this->service->getRevenueByPaymentType($startDate, $endDate);
$expenseSummary = $this->service->getExpenseSummary($startDate, $endDate);
$monthlyExpense = $this->service->getMonthlyExpense($startDate, $endDate);
$busyHours = $this->service->getBusyHours($startDate, $endDate);
$profitMetrics = $this->service->getProfitMetrics($startDate, $endDate);
$topSuppliers = $this->service->getTopSuppliers($startDate, $endDate);
$topCustomers = $this->service->getTopCustomers($startDate, $endDate);
$topProducts = $this->service->getTopProducts($startDate, $endDate);
$marketingSales = $this->service->getMarketingSales($startDate, $endDate);
$orderStats = $this->service->getOrderStats($startDate, $endDate);
return Inertia::render('admin/analysis/index', [
'filters' => [
'start_date' => $startDate,
'end_date' => $endDate,
],
'attendance' => $attendance,
'myAttendance' => $myAttendance,
'isManager' => $isManager,
'cashOverview' => $cashOverview,
'rawMaterialStock' => $rawMaterialStock,
'productStock' => $productStock,
'revenueSummary' => $revenueSummary,
'monthlyRevenue' => $monthlyRevenue,
'monthlyRevenueByChannel' => $monthlyRevenueByChannel,
'revenueByPaymentType' => $revenueByPaymentType,
'expenseSummary' => $expenseSummary,
'monthlyExpense' => $monthlyExpense,
'busyHours' => $busyHours,
'profitMetrics' => $profitMetrics,
'topSuppliers' => $topSuppliers,
'topCustomers' => $topCustomers,
'topProducts' => $topProducts,
'marketingSales' => $marketingSales,
'orderStats' => $orderStats,
]);
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace App\Http\Controllers;
use App\Services\DashboardService;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class DashboardController extends Controller
{
public function __construct(
private DashboardService $service
) {}
public function __invoke(Request $request): Response
{
$user = $request->user();
return Inertia::render('dashboard', [
'attendance' => $this->service->getAttendanceStats(),
'revenueSummary' => $this->service->getRevenueSummary(),
'expenseSummary' => $this->service->getExpenseSummary(),
'orderStats' => $this->service->getOrderStats(),
'todayAttendance' => $this->service->getTodayAttendance($user),
'isOnLeave' => $this->service->isOnLeave($user),
'canCheckIn' => $user->employee !== null,
]);
}
}

View File

@ -0,0 +1,127 @@
<?php
namespace App\Http\Controllers;
use App\Models\Category;
use App\Models\Product;
use App\Services\S3PresignedService;
use App\Settings\HomepageSettings;
use App\Settings\SocialMediaSettings;
use App\Settings\SystemSettings;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class HomepageController extends Controller
{
public function __construct(
private S3PresignedService $s3Service,
) {}
public function __invoke(Request $request): Response
{
$system = app(SystemSettings::class);
$homepage = app(HomepageSettings::class);
$socialMedia = app(SocialMediaSettings::class);
$categories = Category::select(['id', 'name', 'slug'])
->orderBy('name')
->get();
$search = $request->input('search', '');
$category = $request->input('category', '');
$productsQuery = Product::select(['id', 'name', 'slug', 'description', 'status'])
->active()
->with([
'categories:id,name,slug',
'productVariants:id,product_id,name,stock',
'productVariants.productPrices:id,variant_id,type,price',
]);
if ($search !== '') {
$productsQuery->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('description', 'like', "%{$search}%");
});
}
if ($category !== '') {
$productsQuery->whereHas('categories', function ($q) use ($category) {
$q->where('slug', $category);
});
}
$products = Inertia::scroll(
fn () => $productsQuery->orderBy('created_at', 'desc')->paginate(12)
);
$galleryImages = array_map(
fn ($key) => str_starts_with($key, 'http') ? $key : $this->s3Service->getTemporaryUrl($key, 60),
$homepage->gallery_images ?? [],
);
return Inertia::render('welcome', [
'appName' => $system->app_name,
'aboutApp' => $system->about_app,
'contactEmail' => $system->email,
'contactPhone' => $system->phone,
'contactAddress' => $system->address,
'instagramUrl' => $socialMedia->instagram_url,
'facebookUrl' => $socialMedia->facebook_url,
'tiktokUrl' => $socialMedia->tiktok_url,
'homepage' => [
'hero_badge' => $homepage->hero_badge,
'hero_title_line1' => $homepage->hero_title_line1,
'hero_title_line2' => $homepage->hero_title_line2,
'hero_title_highlight' => $homepage->hero_title_highlight,
'hero_description' => $homepage->hero_description,
'hero_cta_primary_text' => $homepage->hero_cta_primary_text,
'hero_cta_secondary_text' => $homepage->hero_cta_secondary_text,
'hero_image_url' => $homepage->hero_image_url
? (str_starts_with($homepage->hero_image_url, 'http') ? $homepage->hero_image_url : $this->s3Service->getTemporaryUrl($homepage->hero_image_url, 60))
: null,
'hero_bg_text_left' => $homepage->hero_bg_text_left,
'hero_bg_text_right' => $homepage->hero_bg_text_right,
'scroll_hashtag' => $homepage->scroll_hashtag,
'scroll_tagline' => $homepage->scroll_tagline,
'catalog_badge' => $homepage->catalog_badge,
'catalog_title' => $homepage->catalog_title,
'catalog_description' => $homepage->catalog_description,
'catalog_search_placeholder' => $homepage->catalog_search_placeholder,
'gallery_badge' => $homepage->gallery_badge,
'gallery_title' => $homepage->gallery_title,
'gallery_description' => $homepage->gallery_description,
'gallery_images' => $galleryImages,
'order_guide_badge' => $homepage->order_guide_badge,
'order_guide_title' => $homepage->order_guide_title,
'order_guide_description' => $homepage->order_guide_description,
'order_steps' => $homepage->order_steps,
'about_badge' => $homepage->about_badge,
'about_title' => $homepage->about_title,
'about_image_url' => $homepage->about_image_url
? (str_starts_with($homepage->about_image_url, 'http') ? $homepage->about_image_url : $this->s3Service->getTemporaryUrl($homepage->about_image_url, 60))
: null,
'about_features' => $homepage->about_features,
'contact_badge' => $homepage->contact_badge,
'contact_title' => $homepage->contact_title,
'contact_description' => $homepage->contact_description,
'contact_form_title' => $homepage->contact_form_title,
'footer_description' => $homepage->footer_description,
'footer_copyright' => $homepage->footer_copyright,
],
'categories' => $categories,
'products' => $products,
'filters' => [
'search' => $search,
'category' => $category,
],
'seo' => [
'title' => $system->app_name.' - '.$homepage->hero_badge,
'description' => $homepage->hero_description,
'image' => url('/assets/logo.png'),
'url' => url('/'),
],
]);
}
}

View File

@ -2,6 +2,7 @@
namespace App\Http\Middleware;
use App\Settings\SystemSettings;
use Illuminate\Http\Request;
use Inertia\Middleware;
@ -38,6 +39,7 @@ public function share(Request $request): array
return [
...parent::share($request),
'name' => config('app.name'),
'address' => app(SystemSettings::class)->address ?? '',
'auth' => [
'user' => $request->user()
? tap($request->user()->load('userProfile', 'roles'), function ($user) {

View File

@ -9,7 +9,7 @@ class CashAccountRequest extends FormRequest
{
public function authorize(): bool
{
return true;
return $this->user()->can('cash.update');
}
public function rules(): array

View File

@ -12,7 +12,8 @@ class CashTransactionRequest extends FormRequest
public function authorize(): bool
{
return true;
return $this->user()->can('cash.deposit')
|| $this->user()->can('cash.withdraw');
}
#[Override]

View File

@ -12,7 +12,7 @@ class EmployeeAdvancePaymentRequest extends FormRequest
public function authorize(): bool
{
return true;
return $this->user()->can('employee_advances.pay');
}
#[Override]

View File

@ -12,7 +12,8 @@ class EmployeeAdvanceRequest extends FormRequest
public function authorize(): bool
{
return true;
return $this->user()->can('employee_advances.create')
|| $this->user()->can('employee_advances.update');
}
#[Override]

View File

@ -12,7 +12,8 @@ class ExpenseRequest extends FormRequest
public function authorize(): bool
{
return true;
return $this->user()->can('expenses.create')
|| $this->user()->can('expenses.update');
}
#[Override]

View File

@ -14,7 +14,7 @@ class PayrollAdjustmentRequest extends FormRequest
public function authorize(): bool
{
return true;
return $this->user()->can('payroll.adjust');
}
#[Override]
@ -29,7 +29,6 @@ public function rules(): array
'type' => ['required', Rule::in(PayrollAdjustmentType::values())],
'amount' => ['required', 'integer', 'min:1'],
'description' => ['required', 'string', 'max:100'],
'attendance_id' => ['nullable', 'integer', 'exists:attendances,id'],
];
}
@ -39,7 +38,6 @@ public function attributes(): array
'type' => 'jenis',
'amount' => 'jumlah',
'description' => 'keterangan',
'attendance_id' => 'presensi',
];
}
}

View File

@ -8,15 +8,15 @@ class AttendanceRequest extends FormRequest
{
public function authorize(): bool
{
return true;
return $this->user()->can('attendances.create');
}
public function rules(): array
{
return [
'photo' => ['required'],
'latitude' => ['required', 'numeric'],
'longitude' => ['required', 'numeric'],
'latitude' => ['required', 'numeric', 'min:-90', 'max:90'],
'longitude' => ['required', 'numeric', 'min:-180', 'max:180'],
];
}

View File

@ -2,6 +2,8 @@
namespace App\Http\Requests\Admin\HR;
use App\Enums\EmploymentStatus;
use App\Enums\Gender;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
@ -9,7 +11,8 @@ class EmployeeRequest extends FormRequest
{
public function authorize(): bool
{
return true;
return $this->user()->can('employees.create')
|| $this->user()->can('employees.update');
}
public function rules(): array
@ -34,7 +37,7 @@ public function rules(): array
'role' => ['required', 'string', Rule::exists('roles', 'name')],
'full_name' => ['required', 'string', 'max:200'],
'phone_number' => ['nullable', 'string', 'max:20'],
'gender' => ['nullable', 'in:male,female'],
'gender' => ['nullable', Rule::in(Gender::values())],
'birth_date' => ['nullable', 'date'],
'address' => ['nullable', 'string'],
'join_date' => [
@ -48,7 +51,7 @@ public function rules(): array
],
'employment_status' => [
Rule::requiredIf(! $isOwner),
Rule::in(['full_time', 'part_time', 'contract', 'internship', 'resigned']),
Rule::in(EmploymentStatus::values()),
],
'base_salary' => [
Rule::requiredIf(! $isOwner),

View File

@ -2,19 +2,46 @@
namespace App\Http\Requests\Admin\HR;
use App\Models\LeaveRequest;
use Illuminate\Foundation\Http\FormRequest;
class LeaveRequestRequest extends FormRequest
{
public function authorize(): bool
{
return true;
return $this->user()->can('leave_requests.create')
|| $this->user()->can('leave_requests.update');
}
public function rules(): array
{
$leaveRequest = $this->route('leaveRequest');
return [
'start_date' => ['required', 'date', 'after_or_equal:today'],
'start_date' => [
'required',
'date',
'after:today',
function ($attribute, $value, $fail) use ($leaveRequest) {
$employee = $this->user()->employee;
if (! $employee) {
return;
}
$query = LeaveRequest::where('employee_id', $employee->id)
->where('start_date', $value)
->where('status', '!=', LeaveRequest::cancelled());
if ($leaveRequest) {
$query->where('id', '!=', $leaveRequest->id);
}
if ($query->exists()) {
$fail('Anda sudah mengajukan cuti pada tanggal ini.');
}
},
],
'end_date' => ['required', 'date', 'after_or_equal:start_date'],
];
}

View File

@ -3,12 +3,14 @@
namespace App\Http\Requests\Admin\Manage;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class CuttingRequest extends FormRequest
{
public function authorize(): bool
{
return true;
return $this->user()->can('cuttings.create')
|| $this->user()->can('cuttings.update');
}
public function rules(): array
@ -20,7 +22,7 @@ public function rules(): array
'original_outside_sample' => ['required', 'integer'],
'cutting_result' => ['required', 'integer', 'min:1'],
'materials' => ['required', 'array', 'min:1'],
'materials.*.raw_material_price_id' => ['required', 'integer', 'exists:raw_material_prices,id'],
'materials.*.raw_material_price_id' => ['required', 'integer', Rule::exists('raw_material_prices', 'id')],
'materials.*.material_usage' => ['required', 'integer', 'min:1'],
'materials.*.material_result' => ['required', 'integer'],
'materials.*.combination_index' => ['nullable', 'integer'],

View File

@ -13,7 +13,8 @@ class PurchaseRequest extends FormRequest
public function authorize(): bool
{
return true;
return $this->user()->can('purchases.create')
|| $this->user()->can('purchases.update');
}
public function prepareForValidation(): void
@ -25,18 +26,18 @@ public function rules(): array
{
return [
'mode' => ['sometimes', 'required', 'in:new,existing'],
'name' => ['required_unless:mode,existing', 'string', 'max:200'],
'unit' => [$this->isMethod('post') ? 'required_unless:mode,existing' : 'nullable', Rule::in(RawMaterialUnit::values())],
'variants' => ['required_unless:mode,existing', 'array', 'min:1'],
'variants.*.variant' => ['required_unless:mode,existing', 'string', 'max:200'],
'variants.*.price' => ['required_unless:mode,existing', 'integer', 'min:0'],
'variants.*.stock' => ['required_unless:mode,existing', 'integer', 'min:0'],
'variants.*.photo_key' => ['required_unless:mode,existing', 'string', 'max:500'],
'existing_items' => ['required_if:mode,existing', 'array', 'min:1'],
'existing_items.*.raw_material_price_id' => ['required', 'integer', 'exists:raw_material_prices,id'],
'name' => [Rule::requiredUnless(fn () => $this->input('mode') === 'existing'), 'string', 'max:200'],
'unit' => [$this->isMethod('post') ? Rule::requiredUnless(fn () => $this->input('mode') === 'existing') : 'nullable', Rule::in(RawMaterialUnit::values())],
'variants' => [Rule::requiredUnless(fn () => $this->input('mode') === 'existing'), 'array', 'min:1'],
'variants.*.variant' => [Rule::requiredUnless(fn () => $this->input('mode') === 'existing'), 'string', 'max:200'],
'variants.*.price' => [Rule::requiredUnless(fn () => $this->input('mode') === 'existing'), 'integer', 'min:0'],
'variants.*.stock' => [Rule::requiredUnless(fn () => $this->input('mode') === 'existing'), 'integer', 'min:0'],
'variants.*.photo_key' => [Rule::requiredUnless(fn () => $this->input('mode') === 'existing'), 'string', 'max:500'],
'existing_items' => [Rule::requiredIf(fn () => $this->input('mode') === 'existing'), 'array', 'min:1'],
'existing_items.*.raw_material_price_id' => ['required', 'integer', Rule::exists('raw_material_prices', 'id')],
'existing_items.*.quantity' => ['required', 'integer', 'min:1'],
'existing_items.*.unit_price' => ['required', 'integer', 'min:0'],
'supplier_id' => ['required', 'integer', 'exists:suppliers,id'],
'supplier_id' => ['required', 'integer', Rule::exists('suppliers', 'id')],
'discount' => ['nullable', 'integer', 'min:0'],
'shipping_cost' => ['nullable', 'integer', 'min:0'],
'notes' => ['nullable', 'string', 'max:100'],

View File

@ -10,7 +10,8 @@ class RestockRequest extends FormRequest
{
public function authorize(): bool
{
return true;
return $this->user()->can('restocks.create')
|| $this->user()->can('restocks.update');
}
public function rules(): array
@ -18,7 +19,7 @@ public function rules(): array
return [
'stock_type' => ['sometimes', 'required', Rule::in(ProductStockQuality::values())],
'items' => ['required', 'array', 'min:1'],
'items.*.product_variant_id' => ['required', 'integer', 'exists:product_variants,id'],
'items.*.product_variant_id' => ['required', 'integer', Rule::exists('product_variants', 'id')],
'items.*.quantity' => ['required', 'integer', 'min:1'],
'notes' => ['nullable', 'string', 'max:100'],
'photo_key' => ['nullable', 'string', 'max:500'],

View File

@ -16,7 +16,8 @@ class TransactionRequest extends FormRequest
public function authorize(): bool
{
return true;
return $this->user()->can('orders.create')
|| $this->user()->can('orders.update');
}
public function prepareForValidation(): void
@ -26,15 +27,13 @@ public function prepareForValidation(): void
public function rules(): array
{
$sellingPriceTypes = array_diff(PriceType::values(), [PriceType::CAPITAL->value]);
return [
'stock_type' => ['sometimes', 'required', Rule::in(ProductStockQuality::values())],
'channel' => ['sometimes', 'required', Rule::in(OrderChannel::values())],
'price_type' => ['sometimes', 'required', Rule::in($sellingPriceTypes)],
'price_type' => ['sometimes', 'required', Rule::in(array_diff(PriceType::values(), [PriceType::CAPITAL->value]))],
'payment_type' => ['sometimes', 'required', Rule::in(PaymentType::values())],
'customer_id' => ['nullable', 'integer', 'exists:customers,id'],
'marketing_id' => ['nullable', 'integer', 'exists:users,id'],
'customer_id' => ['nullable', 'integer', Rule::exists('customers', 'id')],
'marketing_id' => ['nullable', 'integer', Rule::exists('users', 'id')],
'discount' => ['nullable', 'integer', 'min:0'],
'nego_price' => ['nullable', 'integer'],
'is_completed' => ['sometimes', 'boolean'],
@ -42,7 +41,7 @@ public function rules(): array
'tiktok_order_id' => ['nullable', 'string', 'max:100'],
'shopee_order_id' => ['nullable', 'string', 'max:100'],
'items' => ['required', 'array', 'min:1'],
'items.*.product_variant_id' => ['required', 'integer', 'exists:product_variants,id'],
'items.*.product_variant_id' => ['required', 'integer', Rule::exists('product_variants', 'id')],
'items.*.quantity' => ['required', 'integer', 'min:1'],
'notes' => ['nullable', 'string', 'max:100'],
'photo_key' => [

View File

@ -9,7 +9,8 @@ class CategoryRequest extends FormRequest
{
public function authorize(): bool
{
return true;
return $this->user()->can('categories.create')
|| $this->user()->can('categories.update');
}
public function rules(): array
@ -17,7 +18,7 @@ public function rules(): array
$category = $this->route('category');
return [
'name' => ['required', 'string', 'max:100', Rule::unique('categories', 'name')->ignore($category)],
'name' => ['required', 'string', 'max:50', Rule::unique('categories', 'name')->ignore($category)],
];
}

View File

@ -9,7 +9,8 @@ class CustomerRequest extends FormRequest
{
public function authorize(): bool
{
return true;
return $this->user()->can('customers.create')
|| $this->user()->can('customers.update');
}
public function rules(): array

View File

@ -4,6 +4,7 @@
use App\Concerns\CurrencyStripping;
use App\Enums\PriceType;
use App\Enums\ProductStatus;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Override;
@ -14,7 +15,8 @@ class ProductRequest extends FormRequest
public function authorize(): bool
{
return true;
return $this->user()->can('products.create')
|| $this->user()->can('products.update');
}
#[Override]
@ -34,13 +36,13 @@ public function rules(): array
'max:200',
],
'description' => ['nullable', 'string'],
'status' => ['nullable', Rule::in(['active', 'inactive', 'draft'])],
'status' => ['nullable', Rule::in(ProductStatus::values())],
'category_ids' => ['required', 'array', 'min:1'],
'category_ids.*' => ['exists:categories,id'],
'category_ids.*' => [Rule::exists('categories', 'id')],
'use_same_price' => ['nullable', 'boolean'],
'shared_prices' => ['required_if:use_same_price,true', 'nullable', 'array', ...($useSamePrice ? ['size:9'] : [])],
'shared_prices.*.type' => ['required_if:use_same_price,true', 'nullable', Rule::in(PriceType::values())],
'shared_prices.*.price' => ['required_if:use_same_price,true', 'nullable', 'integer', 'min:0'],
'shared_prices' => [Rule::requiredIf(fn () => $useSamePrice), 'nullable', 'array', ...($useSamePrice ? ['size:9'] : [])],
'shared_prices.*.type' => [Rule::requiredIf(fn () => $useSamePrice), 'nullable', Rule::in(PriceType::values())],
'shared_prices.*.price' => [Rule::requiredIf(fn () => $useSamePrice), 'nullable', 'integer', 'min:0'],
'variants' => ['required', 'array', 'min:1'],
'variants.*.id' => ['nullable', 'integer'],
'variants.*.name' => ['required', 'string', 'max:200'],
@ -49,10 +51,10 @@ public function rules(): array
'variants.*.retail_stock' => ['required', 'integer', 'min:0'],
'variants.*.photo_keys' => ['required', 'array', 'min:1', 'max:5'],
'variants.*.photo_keys.*' => ['required', 'string', 'max:500'],
'variants.*.prices' => ['required_if:use_same_price,false', 'nullable', 'array', ...(! $useSamePrice ? ['size:9'] : [])],
'variants.*.prices' => [Rule::requiredIf(fn () => ! $useSamePrice), 'nullable', 'array', ...(! $useSamePrice ? ['size:9'] : [])],
'variants.*.prices.*.id' => ['nullable', 'integer'],
'variants.*.prices.*.type' => ['required_if:use_same_price,false', 'nullable', Rule::in(PriceType::values())],
'variants.*.prices.*.price' => ['required_if:use_same_price,false', 'nullable', 'integer', 'min:0'],
'variants.*.prices.*.type' => [Rule::requiredIf(fn () => ! $useSamePrice), 'nullable', Rule::in(PriceType::values())],
'variants.*.prices.*.price' => [Rule::requiredIf(fn () => ! $useSamePrice), 'nullable', 'integer', 'min:0'],
];
}

View File

@ -13,7 +13,8 @@ class ProductVariantRequest extends FormRequest
public function authorize(): bool
{
return true;
return $this->user()->can('products.create')
|| $this->user()->can('products.update');
}
public function prepareForValidation(): void

View File

@ -8,7 +8,7 @@ class TransferStockRequest extends FormRequest
{
public function authorize(): bool
{
return true;
return $this->user()->can('products.transfer_stock');
}
public function rules(): array

View File

@ -13,7 +13,8 @@ class RawMaterialRequest extends FormRequest
public function authorize(): bool
{
return true;
return $this->user()->can('raw_materials.create')
|| $this->user()->can('raw_materials.update');
}
public function prepareForValidation(): void

View File

@ -11,7 +11,8 @@ class RawMaterialVariantRequest extends FormRequest
public function authorize(): bool
{
return true;
return $this->user()->can('raw_materials.create')
|| $this->user()->can('raw_materials.update');
}
public function prepareForValidation(): void

View File

@ -9,7 +9,8 @@ class SupplierRequest extends FormRequest
{
public function authorize(): bool
{
return true;
return $this->user()->can('suppliers.create')
|| $this->user()->can('suppliers.update');
}
public function rules(): array

View File

@ -9,7 +9,8 @@ class RoleRequest extends FormRequest
{
public function authorize(): bool
{
return true;
return $this->user()->can('roles.create')
|| $this->user()->can('roles.update');
}
public function rules(): array
@ -24,7 +25,7 @@ public function rules(): array
Rule::unique('roles', 'name')->ignore($roleId, 'id'),
],
'permissions' => ['present', 'array'],
'permissions.*' => ['string', 'exists:permissions,name'],
'permissions.*' => ['string', Rule::exists('permissions', 'name')],
];
}

View File

@ -12,7 +12,7 @@ class UpdateHRRequest extends FormRequest
public function authorize(): bool
{
return true;
return $this->user()->can('settings.update_hr');
}
#[Override]

View File

@ -8,7 +8,7 @@ class UpdateHomepageRequest extends FormRequest
{
public function authorize(): bool
{
return true;
return $this->user()->can('settings.update_homepage');
}
public function rules(): array

View File

@ -8,7 +8,7 @@ class UpdateMarketplaceRequest extends FormRequest
{
public function authorize(): bool
{
return true;
return $this->user()->can('settings.update_marketplace');
}
private function feeRuleRules(): array

View File

@ -8,7 +8,7 @@ class UpdateSocialMediaRequest extends FormRequest
{
public function authorize(): bool
{
return true;
return $this->user()->can('settings.update_social_media');
}
public function rules(): array

View File

@ -8,7 +8,7 @@ class UpdateSystemRequest extends FormRequest
{
public function authorize(): bool
{
return true;
return $this->user()->can('settings.update_system');
}
public function rules(): array

View File

@ -27,7 +27,7 @@ public function rules(): array
'required',
'string',
'email',
'max:255',
'max:100',
Rule::unique('users', 'email')->ignore($userId),
],
'username' => [

View File

@ -2,11 +2,14 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Appends;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Appends(['formatted_read_at'])]
#[Guarded(['id'])]
class AppNotification extends Model
{
@ -22,6 +25,13 @@ protected function casts(): array
];
}
protected function formattedReadAt(): Attribute
{
return Attribute::make(
get: fn () => $this->read_at?->translatedFormat('l, d F Y H:i'),
);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);

View File

@ -2,6 +2,7 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Appends;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@ -11,6 +12,7 @@
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;
#[Appends(['formatted_attendance_date', 'formatted_check_in_at', 'formatted_check_out_at'])]
#[Guarded(['id'])]
class Attendance extends Model implements HasMedia
{
@ -33,7 +35,21 @@ protected function casts(): array
protected function formattedAttendanceDate(): Attribute
{
return Attribute::make(
get: fn ($value) => $value ? \Carbon\Carbon::parse($value)->translatedFormat('l, d F Y') : null,
get: fn () => $this->attendance_date?->translatedFormat('l, d F Y'),
);
}
protected function formattedCheckInAt(): Attribute
{
return Attribute::make(
get: fn () => $this->check_in_at?->translatedFormat('l, d F Y H:i'),
);
}
protected function formattedCheckOutAt(): Attribute
{
return Attribute::make(
get: fn () => $this->check_out_at?->translatedFormat('l, d F Y H:i'),
);
}

View File

@ -2,12 +2,15 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Appends;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
#[Appends(['formatted_material_result', 'formatted_material_usage'])]
#[Guarded(['id'])]
class CuttingMaterial extends Model
{
@ -21,6 +24,20 @@ protected function casts(): array
];
}
protected function formattedMaterialResult(): Attribute
{
return Attribute::make(
get: fn () => number_format($this->material_result, 0, ',', '.'),
);
}
protected function formattedMaterialUsage(): Attribute
{
return Attribute::make(
get: fn () => number_format($this->material_usage, 0, ',', '.'),
);
}
public function combination(): BelongsTo
{
return $this->belongsTo(CuttingMaterialCombination::class, 'combination_id');

View File

@ -2,13 +2,16 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Appends;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
#[Appends(['formatted_material_result'])]
#[Guarded(['id'])]
class CuttingMaterialCombination extends Model
{
@ -21,6 +24,13 @@ protected function casts(): array
];
}
protected function formattedMaterialResult(): Attribute
{
return Attribute::make(
get: fn () => number_format($this->material_result, 0, ',', '.'),
);
}
public function cutting(): BelongsTo
{
return $this->belongsTo(Cutting::class);

View File

@ -2,12 +2,15 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Appends;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
#[Appends(['formatted_cutting_result', 'formatted_original_outside_sample', 'formatted_sample'])]
#[Guarded(['id'])]
class CuttingResult extends Model
{
@ -22,6 +25,27 @@ protected function casts(): array
];
}
protected function formattedCuttingResult(): Attribute
{
return Attribute::make(
get: fn () => number_format($this->cutting_result, 0, ',', '.'),
);
}
protected function formattedOriginalOutsideSample(): Attribute
{
return Attribute::make(
get: fn () => number_format($this->original_outside_sample, 0, ',', '.'),
);
}
protected function formattedSample(): Attribute
{
return Attribute::make(
get: fn () => number_format($this->sample, 0, ',', '.'),
);
}
public function cutting(): BelongsTo
{
return $this->belongsTo(Cutting::class);

View File

@ -70,7 +70,7 @@ protected function formattedRemainingAmount(): Attribute
protected function statusLabel(): Attribute
{
return Attribute::make(
get: fn () => $this->status->label(),
get: fn () => $this->status?->label(),
);
}

View File

@ -33,7 +33,7 @@ protected function formattedAmount(): Attribute
protected function formattedPaidAt(): Attribute
{
return Attribute::make(
get: fn () => $this->paid_at?->translatedFormat('l, d F Y'),
get: fn () => $this->paid_at?->translatedFormat('l, d F Y H:i'),
);
}

View File

@ -45,7 +45,7 @@ protected function casts(): array
protected function channelLabel(): Attribute
{
return Attribute::make(
get: fn () => $this->channel->label(),
get: fn () => $this->channel?->label(),
);
}
@ -73,14 +73,14 @@ protected function formattedNegoPrice(): Attribute
protected function paymentTypeLabel(): Attribute
{
return Attribute::make(
get: fn () => $this->payment_type->label(),
get: fn () => $this->payment_type?->label(),
);
}
protected function statusLabel(): Attribute
{
return Attribute::make(
get: fn () => $this->status->label(),
get: fn () => $this->status?->label(),
);
}

View File

@ -1,38 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
#[Guarded(['id'])]
class OwnerVerificationRequest extends Model
{
use HasFactory;
protected function casts(): array
{
return [
'payload' => 'array',
'verified_at' => 'datetime',
];
}
public function subject(): MorphTo
{
return $this->morphTo();
}
public function submittedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'submitted_by_id');
}
public function verifiedBy(): BelongsTo
{
return $this->belongsTo(User::class, 'verified_by_id');
}
}

View File

@ -3,6 +3,7 @@
namespace App\Models;
use App\Enums\PayrollPeriodStatus;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Attributes\Appends;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Attributes\Scope;
@ -14,7 +15,7 @@
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
#[Appends(['status_label'])]
#[Appends(['formatted_closed_at', 'month_name', 'status_label'])]
#[Guarded(['id'])]
class PayrollPeriod extends Model
{
@ -30,6 +31,20 @@ protected function casts(): array
];
}
protected function formattedClosedAt(): Attribute
{
return Attribute::make(
get: fn () => $this->closed_at?->translatedFormat('l, d F Y H:i'),
);
}
protected function monthName(): Attribute
{
return Attribute::make(
get: fn () => $this->month ? Carbon::create()->month($this->month)->translatedFormat('F') : null,
);
}
protected function statusLabel(): Attribute
{
return Attribute::make(

View File

@ -61,6 +61,18 @@ protected function inactive(Builder $query): void
$query->where('status', ProductStatus::INACTIVE);
}
#[Scope]
protected function pending(Builder $query): void
{
$query->where('status', ProductStatus::PENDING);
}
#[Scope]
protected function rejected(Builder $query): void
{
$query->where('status', ProductStatus::REJECTED);
}
public function categories(): BelongsToMany
{
return $this->belongsToMany(Category::class, 'product_categories')

View File

@ -2,11 +2,14 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Appends;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Appends(['formatted_quantity', 'formatted_retail_stock_before', 'formatted_retail_stock_after', 'formatted_stock_before', 'formatted_stock_after'])]
#[Guarded(['id'])]
class RetailStockHistory extends Model
{
@ -25,6 +28,41 @@ protected function casts(): array
];
}
protected function formattedQuantity(): Attribute
{
return Attribute::make(
get: fn () => number_format($this->quantity, 0, ',', '.'),
);
}
protected function formattedRetailStockBefore(): Attribute
{
return Attribute::make(
get: fn () => number_format($this->retail_stock_before, 0, ',', '.'),
);
}
protected function formattedRetailStockAfter(): Attribute
{
return Attribute::make(
get: fn () => number_format($this->retail_stock_after, 0, ',', '.'),
);
}
protected function formattedStockBefore(): Attribute
{
return Attribute::make(
get: fn () => number_format($this->stock_before, 0, ',', '.'),
);
}
protected function formattedStockAfter(): Attribute
{
return Attribute::make(
get: fn () => number_format($this->stock_after, 0, ',', '.'),
);
}
public function productVariant(): BelongsTo
{
return $this->belongsTo(ProductVariant::class);

View File

@ -2,12 +2,15 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Appends;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
#[Appends(['formatted_quantity', 'formatted_stock_after', 'formatted_stock_before'])]
#[Guarded(['id'])]
class StockMutation extends Model
{
@ -22,6 +25,27 @@ protected function casts(): array
];
}
protected function formattedQuantity(): Attribute
{
return Attribute::make(
get: fn () => number_format($this->quantity, 0, ',', '.'),
);
}
protected function formattedStockAfter(): Attribute
{
return Attribute::make(
get: fn () => number_format($this->stock_after, 0, ',', '.'),
);
}
protected function formattedStockBefore(): Attribute
{
return Attribute::make(
get: fn () => number_format($this->stock_before, 0, ',', '.'),
);
}
public function source(): MorphTo
{
return $this->morphTo();

View File

@ -156,16 +156,6 @@ public function orderItems(): HasMany
return $this->hasMany(OrderItem::class);
}
public function ownerVerificationRequestsSubmitted(): HasMany
{
return $this->hasMany(OwnerVerificationRequest::class, 'submitted_by_id');
}
public function ownerVerificationRequestsVerified(): HasMany
{
return $this->hasMany(OwnerVerificationRequest::class, 'verified_by_id');
}
public function paidPayrolls(): HasMany
{
return $this->hasMany(Payroll::class, 'paid_by_id');

View File

@ -24,7 +24,6 @@ public function migrate(): array
return $row;
});
$results['homepage_configurations'] = $this->migrateTable('homepage_configurations');
$results['owner_verification_requests'] = $this->migrateTable('owner_verification_requests');
$results['notifications'] = $this->migrateTable('notifications');
return $results;

View File

@ -46,15 +46,6 @@ public function getHomepageData(): array
];
}
private function getTemporaryUrl(string $key): string
{
if (str_starts_with($key, 'http')) {
return $key;
}
return $this->s3Service->getTemporaryUrl($key, 60);
}
public function getSocialMediaData(): array
{
$settings = app(SocialMediaSettings::class);
@ -167,4 +158,13 @@ public function updateHR(array $data): void
$settings->fill($data);
$settings->save();
}
private function getTemporaryUrl(string $key): string
{
if (str_starts_with($key, 'http')) {
return $key;
}
return $this->s3Service->getTemporaryUrl($key, 60);
}
}

View File

@ -0,0 +1,13 @@
<?php
namespace App\Services\Admin\Finance\Cash;
use App\Models\CashAccount;
class CashAccountService
{
public function get(): ?CashAccount
{
return CashAccount::select(['id', 'name', 'balance'])->first();
}
}

View File

@ -1,8 +1,9 @@
<?php
namespace App\Services\Admin\Finance;
namespace App\Services\Admin\Finance\Cash;
use App\Enums\CashTransactionType;
use App\Enums\Role;
use App\Models\CashAccount;
use App\Models\CashTransaction;
use App\Services\Concerns\HandlesCashTransactions;
@ -11,45 +12,22 @@
use App\Services\S3PresignedService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Pagination\LengthAwarePaginator as PaginationLengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class CashAccountService
class CashTransactionService
{
use HandlesCashTransactions, RegistersMedia;
public function __construct(
private S3PresignedService $s3Service,
private CashAccountService $cashAccountService
) {}
public function get(): ?CashAccount
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator
{
return CashAccount::select(['id', 'name', 'balance'])->first();
}
public function getAllTransactions(array $filters = []): Collection
{
$cashAccount = $this->get();
if (! $cashAccount) {
return collect();
}
return $cashAccount->cashTransactions()
->select(['id', 'created_by_id', 'reference_type', 'reference_id', 'amount', 'balance_after', 'type', 'description', 'created_at'])
->with('createdBy.userProfile', 'media')
->when($filters['type'] ?? null, function ($query, $type) {
$query->where('type', $type);
})
->latest()
->get()
->map(fn (CashTransaction $transaction) => $this->formatTransaction($transaction));
}
public function paginatedTransactions(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator
{
$cashAccount = $this->get();
$cashAccount = $this->cashAccountService->get();
if (! $cashAccount) {
return new PaginationLengthAwarePaginator(collect(), 0, $perPage);
@ -70,29 +48,6 @@ public function paginatedTransactions(int $perPage = 25, string $search = '', st
return $paginator;
}
private function formatTransaction(CashTransaction $transaction): array
{
$media = $transaction->getFirstMedia('receipts');
if (! $media) {
return $transaction->toArray() + [
'receipt_key' => null,
'receipt_url' => null,
];
}
$s3Key = $media->file_name;
if (str_starts_with($s3Key, '/') || ! str_contains($s3Key, '/')) {
$s3Key = $media->getPath();
}
return $transaction->toArray() + [
'receipt_key' => $s3Key,
'receipt_url' => $this->s3Service->getTemporaryUrl($s3Key),
];
}
public function deposit(array $data): CashTransaction
{
$transaction = DB::transaction(fn () => $this->creditCash(
@ -100,12 +55,12 @@ public function deposit(array $data): CashTransaction
description: $data['description'],
));
if (! empty($data['receipt_key'])) {
$this->registerMedia($transaction, $data['receipt_key'], 'receipts', ['thumb' => true], $data['file_size'] ?? null, $data['file_mime_type'] ?? null);
if (array_key_exists('receipt_key', $data)) {
$this->syncReceipt($transaction, $data['receipt_key'] ?? null, 'receipts', 'cash_transaction_receipt', $data['file_size'] ?? null, $data['file_mime_type'] ?? null);
}
NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'],
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Setoran Kas Toko',
body: 'Setoran sebesar Rp '.number_format($data['amount'], 0, ',', '.').' berhasil dicatat'.' oleh '.auth()->user()->full_name.'.',
url: route('admin.finance.cash-accounts.index'),
@ -122,12 +77,12 @@ public function withdrawal(array $data): CashTransaction
type: CashTransactionType::WITHDRAWAL,
));
if (! empty($data['receipt_key'])) {
$this->registerMedia($transaction, $data['receipt_key'], 'receipts', ['thumb' => true], $data['file_size'] ?? null, $data['file_mime_type'] ?? null);
if (array_key_exists('receipt_key', $data)) {
$this->syncReceipt($transaction, $data['receipt_key'] ?? null, 'receipts', 'cash_transaction_receipt', $data['file_size'] ?? null, $data['file_mime_type'] ?? null);
}
NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'],
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Penarikan Kas Toko',
body: 'Penarikan sebesar Rp '.number_format($data['amount'], 0, ',', '.').' berhasil dicatat'.' oleh '.auth()->user()->full_name.'.',
url: route('admin.finance.cash-accounts.index'),
@ -136,7 +91,7 @@ public function withdrawal(array $data): CashTransaction
return $transaction;
}
public function updateTransaction(CashTransaction $transaction, array $data): CashTransaction
public function update(CashTransaction $transaction, array $data): CashTransaction
{
$transaction = DB::transaction(function () use ($transaction, $data) {
if (! in_array($transaction->type, [CashTransactionType::DEPOSIT, CashTransactionType::WITHDRAWAL])) {
@ -168,27 +123,14 @@ public function updateTransaction(CashTransaction $transaction, array $data): Ca
]);
if (array_key_exists('receipt_key', $data)) {
$currentMedia = $transaction->getFirstMedia('receipts');
$currentKey = $currentMedia?->file_name;
if ($currentMedia && ! str_contains($currentKey, '/')) {
$currentKey = $currentMedia->getPath();
}
if ($data['receipt_key'] !== $currentKey) {
$transaction->clearMediaCollection('receipts');
if (! empty($data['receipt_key'])) {
$this->registerMedia($transaction, $data['receipt_key'], 'receipts', ['thumb' => true], $data['file_size'] ?? null, $data['file_mime_type'] ?? null);
}
}
$this->syncReceipt($transaction, $data['receipt_key'] ?? null, 'receipts', 'cash_transaction_receipt', $data['file_size'] ?? null, $data['file_mime_type'] ?? null);
}
return $transaction;
});
NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'],
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Transaksi Kas Diperbarui',
body: 'Transaksi kas sebesar Rp '.number_format($transaction->amount, 0, ',', '.').' berhasil diperbarui'.' oleh '.auth()->user()->full_name.'.',
url: route('admin.finance.cash-accounts.index'),
@ -197,7 +139,7 @@ public function updateTransaction(CashTransaction $transaction, array $data): Ca
return $transaction;
}
public function deleteTransaction(CashTransaction $transaction): bool
public function destroy(CashTransaction $transaction): bool
{
return DB::transaction(function () use ($transaction) {
if (! in_array($transaction->type, [CashTransactionType::DEPOSIT, CashTransactionType::WITHDRAWAL])) {
@ -221,13 +163,18 @@ public function deleteTransaction(CashTransaction $transaction): bool
$cashAccount->update(['balance' => $newBalance]);
$media = $transaction->getFirstMedia('receipts');
if ($media) {
Cache::forget("cash_transaction_receipt_{$media->id}");
}
$transaction->clearMediaCollection('receipts');
$deleted = $transaction->delete();
if ($deleted) {
NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'],
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Transaksi Kas Dihapus',
body: 'Transaksi kas sebesar Rp '.number_format($transaction->amount, 0, ',', '.').' berhasil dihapus'.' oleh '.auth()->user()->full_name.'.',
url: route('admin.finance.cash-accounts.index'),
@ -237,4 +184,27 @@ public function deleteTransaction(CashTransaction $transaction): bool
return $deleted;
});
}
private function formatTransaction(CashTransaction $transaction): array
{
$media = $transaction->getFirstMedia('receipts');
if (! $media) {
return $transaction->toArray() + [
'receipt_key' => null,
'receipt_url' => null,
];
}
$s3Key = $media->file_name;
if (str_starts_with($s3Key, '/') || ! str_contains($s3Key, '/')) {
$s3Key = $media->getPath();
}
return $transaction->toArray() + [
'receipt_key' => $s3Key,
'receipt_url' => $this->s3Service->getTemporaryUrl($s3Key),
];
}
}

View File

@ -2,48 +2,35 @@
namespace App\Services\Admin\Finance;
use App\Concerns\HasRoleChecks;
use App\Enums\CashTransactionType;
use App\Enums\EmployeeAdvanceStatus;
use App\Enums\Role;
use App\Models\EmployeeAdvance;
use App\Models\EmployeeAdvancePayment;
use App\Services\Concerns\HandlesCashTransactions;
use App\Services\NotificationService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class EmployeeAdvanceService
{
use HandlesCashTransactions;
private function canViewAll(): bool
{
return auth()->user()->hasAnyRole(['developer', 'owner', 'direktur', 'admin-toko']);
}
public function getAll(array $filters = []): Collection
{
return EmployeeAdvance::select(['id', 'employee_id', 'amount', 'paid_amount', 'description', 'due_date', 'status', 'created_at'])
->with(['employee.user.userProfile', 'payments.paidBy.userProfile'])
->when(! $this->canViewAll(), fn ($q) => $q->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id())))
->latest()
->get();
}
use HandlesCashTransactions, HasRoleChecks;
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator
{
return EmployeeAdvance::query()
->select(['id', 'employee_id', 'amount', 'paid_amount', 'description', 'due_date', 'status', 'created_at'])
->with(['employee.user.userProfile', 'payments.paidBy.userProfile'])
->when(! $this->canViewAll(), fn ($q) => $q->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id())))
->when(! self::hasAnyRole([Role::DEVELOPER, Role::OWNER, Role::DIREKTUR, Role::ADMIN_TOKO]), fn ($q) => $q->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id())))
->when($filters['status'] ?? null, fn ($q) => $q->where('status', $filters['status']))
->when($search, fn ($q) => $q->where('description', 'like', "%{$search}%"))
->orderBy($sort, $direction)
->paginate($perPage);
}
public function create(array $data): EmployeeAdvance
public function store(array $data): EmployeeAdvance
{
$employee = auth()->user()->employee;
@ -64,7 +51,7 @@ public function create(array $data): EmployeeAdvance
$employeeAdvance->load('employee.user');
NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'],
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Kasbon Baru',
body: 'Kasbon sebesar Rp '.number_format($data['amount'], 0, ',', '.')." dari {$employeeAdvance->employee->name} menunggu persetujuan".' oleh '.auth()->user()->full_name.'.',
url: route('admin.finance.employee-advances.index'),
@ -123,7 +110,7 @@ public function update(EmployeeAdvance $employeeAdvance, array $data): EmployeeA
return $employeeAdvance;
}
public function delete(EmployeeAdvance $employeeAdvance): bool
public function destroy(EmployeeAdvance $employeeAdvance): bool
{
return DB::transaction(function () use ($employeeAdvance) {
if ($employeeAdvance->status === EmployeeAdvanceStatus::APPROVED && $employeeAdvance->cash_transaction_id) {
@ -181,7 +168,7 @@ public function approve(EmployeeAdvance $employeeAdvance): EmployeeAdvance
});
NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'],
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Kasbon Disetujui',
body: 'Kasbon sebesar Rp '.number_format($employeeAdvance->amount, 0, ',', '.').' telah disetujui'.' oleh '.auth()->user()->full_name.'.',
url: route('admin.finance.employee-advances.index'),
@ -234,7 +221,7 @@ public function pay(EmployeeAdvance $employeeAdvance, int $amount): EmployeeAdva
: 'Pembayaran kasbon sebesar Rp '.number_format($amount, 0, ',', '.').' oleh '.auth()->user()->full_name.'. Sisa: Rp '.number_format($employeeAdvance->amount - $employeeAdvance->paid_amount, 0, ',', '.').'.';
NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'],
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: $employeeAdvance->status === EmployeeAdvanceStatus::PAID ? 'Kasbon Dibayar' : 'Pembayaran Kasbon',
body: $notificationBody,
url: route('admin.finance.employee-advances.index'),

View File

@ -3,6 +3,7 @@
namespace App\Services\Admin\Finance;
use App\Enums\CashTransactionType;
use App\Enums\Role;
use App\Models\CashAccount;
use App\Models\Expense;
use App\Services\Concerns\HandlesCashTransactions;
@ -10,7 +11,6 @@
use App\Services\NotificationService;
use App\Services\S3PresignedService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
@ -23,15 +23,6 @@ public function __construct(
private S3PresignedService $s3Service,
) {}
public function getAll(array $filters = []): Collection
{
return Expense::select(['id', 'created_by_id', 'amount', 'description', 'created_at'])
->with('createdBy.userProfile', 'media')
->latest()
->get()
->map(fn (Expense $expense) => $this->formatExpense($expense));
}
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator
{
$paginator = Expense::query()
@ -46,36 +37,7 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
return $paginator;
}
private function formatExpense(Expense $expense): array
{
$media = $expense->getFirstMedia('receipts');
if (! $media) {
return $expense->toArray() + [
'receipt_key' => null,
'receipt_url' => null,
];
}
$s3Key = $media->file_name;
// Handle old data where file_name is just the filename, not full path
if (str_starts_with($s3Key, '/') || ! str_contains($s3Key, '/')) {
$s3Key = $media->getPath();
}
$cacheKey = "expense_receipt_{$media->id}";
$receiptUrl = Cache::remember($cacheKey, now()->addMinutes(55), function () use ($s3Key) {
return $this->s3Service->getTemporaryUrl($s3Key);
});
return $expense->toArray() + [
'receipt_key' => $s3Key,
'receipt_url' => $receiptUrl,
];
}
public function create(array $data): Expense
public function store(array $data): Expense
{
$expense = DB::transaction(function () use ($data) {
$cashTransaction = $this->debitCash(
@ -90,15 +52,15 @@ public function create(array $data): Expense
'description' => $data['description'],
]);
if (! empty($data['receipt_key'])) {
$this->registerMedia($expense, $data['receipt_key'], 'receipts', ['thumb' => true], $data['file_size'] ?? null, $data['file_mime_type'] ?? null);
if (array_key_exists('receipt_key', $data)) {
$this->syncReceipt($expense, $data['receipt_key'] ?? null, 'receipts', 'expense_receipt', $data['file_size'] ?? null, $data['file_mime_type'] ?? null);
}
return $expense;
});
NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'],
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Pengeluaran Baru',
body: 'Pengeluaran sebesar Rp '.number_format($data['amount'], 0, ',', '.').' berhasil dicatat'.' oleh '.auth()->user()->full_name.'.',
url: route('admin.finance.expenses.index'),
@ -138,33 +100,14 @@ public function update(Expense $expense, array $data): Expense
]);
if (array_key_exists('receipt_key', $data)) {
$currentMedia = $expense->getFirstMedia('receipts');
$currentKey = $currentMedia?->file_name;
// Normalize: if file_name is just a filename (old data), use getPath()
if ($currentMedia && ! str_contains($currentKey, '/')) {
$currentKey = $currentMedia->getPath();
}
if ($data['receipt_key'] !== $currentKey) {
// Invalidate old receipt cache
if ($currentMedia) {
Cache::forget("expense_receipt_{$currentMedia->id}");
}
$expense->clearMediaCollection('receipts');
if (! empty($data['receipt_key'])) {
$this->registerMedia($expense, $data['receipt_key'], 'receipts', ['thumb' => true], $data['file_size'] ?? null, $data['file_mime_type'] ?? null);
}
}
$this->syncReceipt($expense, $data['receipt_key'] ?? null, 'receipts', 'expense_receipt', $data['file_size'] ?? null, $data['file_mime_type'] ?? null);
}
return $expense;
});
NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'],
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Pengeluaran Diperbarui',
body: 'Pengeluaran sebesar Rp '.number_format($expense->amount, 0, ',', '.').' berhasil diperbarui'.' oleh '.auth()->user()->full_name.'.',
url: route('admin.finance.expenses.index'),
@ -173,7 +116,7 @@ public function update(Expense $expense, array $data): Expense
return $expense;
}
public function delete(Expense $expense): bool
public function destroy(Expense $expense): bool
{
return DB::transaction(function () use ($expense) {
$this->creditCash(
@ -182,7 +125,6 @@ public function delete(Expense $expense): bool
type: CashTransactionType::DEPOSIT,
);
// Invalidate receipt cache
$media = $expense->getFirstMedia('receipts');
if ($media) {
Cache::forget("expense_receipt_{$media->id}");
@ -194,7 +136,7 @@ public function delete(Expense $expense): bool
if ($deleted) {
NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'],
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Pengeluaran Dihapus',
body: 'Pengeluaran sebesar Rp '.number_format($expense->amount, 0, ',', '.').' berhasil dihapus'.' oleh '.auth()->user()->full_name.'.',
url: route('admin.finance.expenses.index'),
@ -204,4 +146,32 @@ public function delete(Expense $expense): bool
return $deleted;
});
}
private function formatExpense(Expense $expense): array
{
$media = $expense->getFirstMedia('receipts');
if (! $media) {
return $expense->toArray() + [
'receipt_key' => null,
'receipt_url' => null,
];
}
$s3Key = $media->file_name;
if (str_starts_with($s3Key, '/') || ! str_contains($s3Key, '/')) {
$s3Key = $media->getPath();
}
$cacheKey = "expense_receipt_{$media->id}";
$receiptUrl = Cache::remember($cacheKey, now()->addMinutes(55), function () use ($s3Key) {
return $this->s3Service->getTemporaryUrl($s3Key);
});
return $expense->toArray() + [
'receipt_key' => $s3Key,
'receipt_url' => $receiptUrl,
];
}
}

View File

@ -1,6 +1,6 @@
<?php
namespace App\Services\Admin\Finance;
namespace App\Services\Admin\Finance\Payroll;
use App\Enums\PayrollAdjustmentType;
use App\Enums\PayrollStatus;
@ -11,7 +11,7 @@
class PayrollAdjustmentService
{
public function create(Payroll $payroll, array $data): PayrollAdjustment
public function store(Payroll $payroll, array $data): PayrollAdjustment
{
if ($payroll->status !== PayrollStatus::UNPAID) {
throw ValidationException::withMessages([
@ -35,7 +35,7 @@ public function create(Payroll $payroll, array $data): PayrollAdjustment
});
}
public function delete(PayrollAdjustment $adjustment): bool
public function destroy(PayrollAdjustment $adjustment): bool
{
$payroll = $adjustment->payroll;

View File

@ -1,57 +1,29 @@
<?php
namespace App\Services\Admin\Finance;
namespace App\Services\Admin\Finance\Payroll;
use App\Concerns\HasRoleChecks;
use App\Enums\CashTransactionType;
use App\Enums\PayrollPeriodStatus;
use App\Enums\PayrollStatus;
use App\Enums\Role;
use App\Models\Payroll;
use App\Models\PayrollPeriod;
use App\Services\Concerns\HandlesCashTransactions;
use App\Services\NotificationService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class PayrollPeriodService
{
use HandlesCashTransactions;
private function canViewAll(): bool
{
return auth()->user()->hasAnyRole(['developer', 'owner', 'direktur', 'admin-toko']);
}
public function getAll(array $filters = []): Collection
{
return PayrollPeriod::select(['id', 'year', 'month', 'status', 'closed_at', 'created_at'])
->when(! $this->canViewAll(), function ($query) {
$query->withCount(['payrolls as payrolls_count' => fn ($q) => $q->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id()))])
->withSum(['payrolls as payrolls_sum_total_amount' => fn ($q) => $q->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id()))], 'total_amount')
->withSum(['payrolls as payrolls_sum_bonus_amount' => fn ($q) => $q->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id()))], 'bonus_amount')
->withSum(['payrolls as payrolls_sum_deduction_amount' => fn ($q) => $q->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id()))], 'deduction_amount')
->withCount(['payrolls as paid_count' => fn ($q) => $q->paid()->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id()))])
->withCount(['payrolls as cancelled_count' => fn ($q) => $q->cancelled()->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id()))]);
})
->when($this->canViewAll(), function ($query) {
$query->withCount('payrolls')
->withSum('payrolls', 'total_amount')
->withSum('payrolls', 'bonus_amount')
->withSum('payrolls', 'deduction_amount')
->withCount(['payrolls as paid_count' => fn ($q) => $q->paid()])
->withCount(['payrolls as cancelled_count' => fn ($q) => $q->cancelled()]);
})
->latest('year')
->latest('month')
->get();
}
use HandlesCashTransactions, HasRoleChecks;
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator
{
return PayrollPeriod::query()
->select(['id', 'year', 'month', 'status', 'closed_at', 'created_at'])
->when(! $this->canViewAll(), function ($query) {
->when(! self::hasAnyRole([Role::DEVELOPER, Role::OWNER, Role::DIREKTUR, Role::ADMIN_TOKO]), function ($query) {
$query->withCount(['payrolls as payrolls_count' => fn ($q) => $q->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id()))])
->withSum(['payrolls as payrolls_sum_total_amount' => fn ($q) => $q->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id()))], 'total_amount')
->withSum(['payrolls as payrolls_sum_bonus_amount' => fn ($q) => $q->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id()))], 'bonus_amount')
@ -59,7 +31,7 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
->withCount(['payrolls as paid_count' => fn ($q) => $q->paid()->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id()))])
->withCount(['payrolls as cancelled_count' => fn ($q) => $q->cancelled()->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id()))]);
})
->when($this->canViewAll(), function ($query) {
->when(self::hasAnyRole([Role::DEVELOPER, Role::OWNER, Role::DIREKTUR, Role::ADMIN_TOKO]), function ($query) {
$query->withCount('payrolls')
->withSum('payrolls', 'total_amount')
->withSum('payrolls', 'bonus_amount')
@ -72,15 +44,14 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
->paginate($perPage);
}
public function getDetail(PayrollPeriod $period): PayrollPeriod
public function getCurrentOrCreate(): PayrollPeriod
{
return $period->load([
'payrolls' => function ($query) {
$query->with(['employee.user.userProfile', 'payrollAdjustments'])
->when(! $this->canViewAll(), fn ($q) => $q->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id())))
->orderBy('id');
},
]);
$now = now();
return PayrollPeriod::firstOrCreate(
['year' => $now->year, 'month' => $now->month],
['status' => PayrollPeriodStatus::OPEN]
);
}
public function close(PayrollPeriod $period): PayrollPeriod
@ -161,7 +132,7 @@ public function pay(Payroll $payroll): Payroll
$employeeUser = $payroll->employee->user ?? null;
NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'],
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Gaji Dibayar',
body: "Gaji karyawan {$payroll->employee->name} sebesar {$payroll->formatted_amount} telah dibayar".' oleh '.auth()->user()->full_name.'.',
url: route('admin.finance.payroll-periods.index'),
@ -192,7 +163,7 @@ public function cancel(Payroll $payroll): Payroll
$employeeUser = $payroll->employee->user ?? null;
NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'],
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Gaji Dibatalkan',
body: "Gaji karyawan {$payroll->employee->name} sebesar {$payroll->formatted_amount} telah dibatalkan".' oleh '.auth()->user()->full_name.'.',
url: route('admin.finance.payroll-periods.index'),

View File

@ -2,30 +2,54 @@
namespace App\Services\Admin\HR;
use App\Concerns\HasRoleChecks;
use App\Enums\Role;
use App\Models\Attendance;
use App\Models\Employee;
use App\Models\LeaveRequest;
use App\Models\User;
use App\Services\Concerns\RegistersMedia;
use App\Services\NotificationService;
use App\Services\S3PresignedService;
use App\Settings\HRSettings;
use Carbon\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Validation\ValidationException;
class AttendanceService
{
use RegistersMedia;
use HasRoleChecks, RegistersMedia;
public function __construct(
private S3PresignedService $s3Service,
private EmployeeService $employeeService,
) {}
public function getAll(): Collection
public function getIndexData(int $year, int $month): array
{
return Attendance::with(['employee.user.userProfile', 'media'])
->latest('attendance_date')
->get()
->map(fn (Attendance $attendance) => $this->formatAttendance($attendance));
$user = auth()->user();
$isAdmin = self::hasAnyRole([Role::DEVELOPER, Role::OWNER]);
$hrSettings = app(HRSettings::class);
$employeeId = $isAdmin ? null : $user->employee?->id;
return [
'attendances' => $this->getByMonth($year, $month, $employeeId),
'leaves' => $this->getLeavesByMonth($year, $month, $employeeId),
'employees' => $isAdmin ? $this->employeeService->getAll() : [],
'todayAttendance' => $isAdmin ? null : $this->getToday(),
'currentYear' => $year,
'currentMonth' => $month,
'monthStats' => $this->getMonthStats($year, $month, $employeeId),
'hrSettings' => [
'scheduled_check_in_time' => $hrSettings->scheduled_check_in_time,
'scheduled_check_out_time' => $hrSettings->scheduled_check_out_time,
],
'isOnLeave' => $isAdmin ? false : $this->isOnLeave($user),
'canCheckIn' => $isAdmin ? false : $user->employee !== null,
'isAdmin' => $isAdmin,
];
}
public function getByMonth(int $year, int $month, ?int $employeeId = null): Collection
@ -53,55 +77,64 @@ public function getToday(): ?array
return $this->getByDate(now()->toDateString());
}
public function getAllEmployees(): Collection
public function getLeavesByMonth(int $year, int $month, ?int $employeeId = null): Collection
{
return Employee::with(['user.userProfile', 'user.roles'])
->whereHas('user', fn ($q) => $q->where('is_active', true))
->get();
$startOfMonth = Carbon::createFromDate($year, $month, 1)->startOfMonth();
$endOfMonth = $startOfMonth->copy()->endOfMonth();
return LeaveRequest::approved()
->with('employee.user.userProfile')
->where('start_date', '<=', $endOfMonth)
->where('end_date', '>=', $startOfMonth)
->when($employeeId, fn ($q) => $q->where('employee_id', $employeeId))
->get()
->map(fn (LeaveRequest $leave) => [
'id' => $leave->id,
'employee_id' => $leave->employee_id,
'start_date' => $leave->start_date->toDateString(),
'end_date' => $leave->end_date->toDateString(),
'total_days' => $leave->total_days,
'status' => $leave->status->value,
'employee_name' => $leave->employee?->user?->userProfile?->full_name ?? '-',
]);
}
public function getMonthStats(int $year, int $month, ?int $employeeId = null): array
{
$startOfMonth = Carbon::createFromDate($year, $month, 1)->startOfMonth();
$endOfMonth = $startOfMonth->copy()->endOfMonth();
$today = Carbon::today();
$statEnd = $endOfMonth->lte($today) ? $endOfMonth : $today;
$workingDays = 0;
$current = $startOfMonth->copy();
while ($current->lte($endOfMonth)) {
while ($current->lte($statEnd)) {
if (! in_array($current->dayOfWeek, [Carbon::SATURDAY, Carbon::SUNDAY])) {
$workingDays++;
}
$current->addDay();
}
$attendanceQuery = Attendance::whereYear('attendance_date', $year)
->whereMonth('attendance_date', $month);
if ($employeeId) {
$attendanceQuery->where('employee_id', $employeeId);
return $this->getMonthStatsForEmployee($year, $month, $startOfMonth, $statEnd, $workingDays, $employeeId);
}
$attendanceCount = $attendanceQuery->count();
$leaveQuery = LeaveRequest::approved()
->where('start_date', '<=', $endOfMonth)
->where('end_date', '>=', $startOfMonth);
if ($employeeId) {
$leaveQuery->where('employee_id', $employeeId);
return $this->getMonthStatsForAll($year, $month, $startOfMonth, $statEnd, $workingDays);
}
public function isOnLeave(User $user): bool
{
$employee = $user->employee;
if (! $employee) {
return false;
}
$leaveDays = $leaveQuery->get()
->reduce(function ($carry, $leave) use ($startOfMonth, $endOfMonth) {
$leaveStart = max($leave->start_date->timestamp, $startOfMonth->timestamp);
$leaveEnd = min($leave->end_date->timestamp, $endOfMonth->timestamp);
$days = Carbon::createFromTimestamp($leaveStart)->diffInDays(Carbon::createFromTimestamp($leaveEnd)) + 1;
return $carry + max(0, $days);
}, 0);
return [
'working_days' => $workingDays,
'present' => $attendanceCount,
'absent' => max(0, $workingDays - $attendanceCount - $leaveDays),
'leave' => $leaveDays,
];
return LeaveRequest::approved()
->where('employee_id', $employee->id)
->where('start_date', '<=', now()->toDateString())
->where('end_date', '>=', now()->toDateString())
->exists();
}
public function checkIn(array $data): Attendance
@ -109,7 +142,9 @@ public function checkIn(array $data): Attendance
$employee = auth()->user()->employee;
if (! $employee) {
throw new \Exception('Anda tidak terdaftar sebagai karyawan.');
throw ValidationException::withMessages([
'employee' => 'Anda tidak terdaftar sebagai karyawan.',
]);
}
$today = now()->toDateString();
@ -119,7 +154,9 @@ public function checkIn(array $data): Attendance
->first();
if ($existing) {
throw new \Exception('Anda sudah melakukan presensi hari ini.');
throw ValidationException::withMessages([
'attendance' => 'Anda sudah melakukan presensi hari ini.',
]);
}
$attendance = Attendance::create([
@ -135,7 +172,7 @@ public function checkIn(array $data): Attendance
}
NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur'],
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR],
title: 'Presensi Masuk',
body: 'Presensi masuk oleh '.auth()->user()->full_name.'.',
url: route('admin.hr.attendances.index'),
@ -157,7 +194,7 @@ public function checkOut(Attendance $attendance, array $data): Attendance
}
NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur'],
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR],
title: 'Presensi Pulang',
body: 'Presensi pulang oleh '.auth()->user()->full_name.'.',
url: route('admin.hr.attendances.index'),
@ -166,6 +203,93 @@ public function checkOut(Attendance $attendance, array $data): Attendance
return $attendance;
}
private function getMonthStatsForEmployee(int $year, int $month, Carbon $startOfMonth, Carbon $statEnd, int $workingDays, int $employeeId): array
{
$attendanceQuery = Attendance::whereYear('attendance_date', $year)
->whereMonth('attendance_date', $month)
->where('attendance_date', '<=', $statEnd->toDateString())
->where('employee_id', $employeeId);
$attendanceDates = $attendanceQuery->pluck('attendance_date')
->map(fn ($d) => Carbon::parse($d)->toDateString())
->filter(fn ($d) => ! in_array(Carbon::parse($d)->dayOfWeek, [Carbon::SATURDAY, Carbon::SUNDAY]))
->unique()
->values();
$attendanceCount = $attendanceDates->count();
$leaveQuery = LeaveRequest::approved()
->where('start_date', '<=', $statEnd)
->where('end_date', '>=', $startOfMonth)
->where('employee_id', $employeeId);
$leaveRequests = $leaveQuery->get();
$leaveCount = $leaveRequests->count();
$leaveDates = collect();
$leaveRequests->each(function ($leave) use (&$leaveDates, $startOfMonth, $statEnd) {
$leaveStart = Carbon::parse($leave->start_date)->startOfDay()->lte($startOfMonth) ? $startOfMonth->copy() : Carbon::parse($leave->start_date)->startOfDay();
$leaveEnd = Carbon::parse($leave->end_date)->startOfDay()->gte($statEnd) ? $statEnd->copy() : Carbon::parse($leave->end_date)->startOfDay();
$current = $leaveStart->copy();
while ($current->lte($leaveEnd)) {
if (! in_array($current->dayOfWeek, [Carbon::SATURDAY, Carbon::SUNDAY])) {
$leaveDates->push($current->toDateString());
}
$current->addDay();
}
});
$leaveDates = $leaveDates->unique()->values();
$coveredDates = $attendanceDates->merge($leaveDates)->unique()->count();
$absent = max(0, $workingDays - $coveredDates);
return [
'working_days' => $workingDays,
'present' => $attendanceCount,
'absent' => $absent,
'leave' => $leaveCount,
];
}
private function getMonthStatsForAll(int $year, int $month, Carbon $startOfMonth, Carbon $statEnd, int $workingDays): array
{
$totalEmployees = Employee::whereHas('user', fn ($q) => $q->where('is_active', true))->count();
$presentCount = Attendance::whereYear('attendance_date', $year)
->whereMonth('attendance_date', $month)
->where('attendance_date', '<=', $statEnd->toDateString())
->whereHas('employee', fn ($q) => $q->whereHas('user', fn ($uq) => $uq->where('is_active', true)))
->count();
$leaveRequests = LeaveRequest::approved()
->where('start_date', '<=', $statEnd)
->where('end_date', '>=', $startOfMonth)
->whereHas('employee', fn ($q) => $q->whereHas('user', fn ($uq) => $uq->where('is_active', true)))
->get();
$leaveDays = 0;
$leaveRequests->each(function ($leave) use (&$leaveDays, $startOfMonth, $statEnd) {
$leaveStart = Carbon::parse($leave->start_date)->startOfDay()->lte($startOfMonth) ? $startOfMonth->copy() : Carbon::parse($leave->start_date)->startOfDay();
$leaveEnd = Carbon::parse($leave->end_date)->startOfDay()->gte($statEnd) ? $statEnd->copy() : Carbon::parse($leave->end_date)->startOfDay();
$current = $leaveStart->copy();
while ($current->lte($leaveEnd)) {
if (! in_array($current->dayOfWeek, [Carbon::SATURDAY, Carbon::SUNDAY])) {
$leaveDays++;
}
$current->addDay();
}
});
$absent = max(0, ($totalEmployees * $workingDays) - $presentCount - $leaveDays);
return [
'working_days' => $workingDays,
'present' => $presentCount,
'absent' => $absent,
'leave' => $leaveDays,
'total_employees' => $totalEmployees,
];
}
private function formatAttendance(Attendance $attendance): array
{
$toArray = $attendance->toArray();

View File

@ -2,46 +2,17 @@
namespace App\Services\Admin\HR;
use App\Concerns\HasRoleChecks;
use App\Enums\Role;
use App\Models\Employee;
use App\Models\User;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class EmployeeService
{
private const ADMIN_ROLES = ['developer', 'owner', 'direktur', 'admin-toko'];
private const RESTRICTED_ROLES = ['admin-toko', 'direktur'];
public function canViewAll(): bool
{
return auth()->user()->hasAnyRole(self::ADMIN_ROLES);
}
public function shouldHideAdminBahanBaku(): bool
{
return auth()->user()->hasAnyRole(self::RESTRICTED_ROLES);
}
public function getAll(array $filters = []): Collection
{
return User::select(['id', 'email', 'username', 'is_active'])
->where(fn ($q) => $q->whereHas('employee')->orWhereHas('roles', fn ($rq) => $rq->where('name', 'Owner')))
->with([
'userProfile' => fn ($q) => $q->select(['id', 'user_id', 'full_name', 'phone_number', 'gender']),
'employee' => fn ($q) => $q->select(['id', 'user_id', 'join_date', 'employment_status', 'base_salary']),
'roles' => fn ($q) => $q->select(['id', 'name']),
])
->when(! $this->canViewAll(), function ($q) {
$userRoles = auth()->user()->roles->pluck('name');
$q->whereHas('roles', fn ($rq) => $rq->whereIn('name', $userRoles));
})
->when($this->shouldHideAdminBahanBaku(), fn ($q) => $q->whereDoesntHave('roles', fn ($rq) => $rq->where('name', 'admin-bahan-baku')))
->when($filters['employment_status'] ?? null, fn ($q, $status) => $q->whereHas('employee', fn ($eq) => $eq->where('employment_status', $status)))
->when(isset($filters['is_active']) && $filters['is_active'] !== '', fn ($q) => $q->where('is_active', filter_var($filters['is_active'], FILTER_VALIDATE_BOOLEAN)))
->when($filters['gender'] ?? null, fn ($q, $gender) => $q->whereHas('userProfile', fn ($uq) => $uq->where('gender', $gender)))
->latest()
->get();
}
use HasRoleChecks;
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator
{
@ -53,11 +24,11 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
'employee' => fn ($q) => $q->select(['id', 'user_id', 'join_date', 'employment_status', 'base_salary']),
'roles' => fn ($q) => $q->select(['id', 'name']),
])
->when(! $this->canViewAll(), function ($q) {
->when(! self::hasAnyRole([Role::DEVELOPER, Role::OWNER, Role::DIREKTUR, Role::ADMIN_TOKO]), function ($q) {
$userRoles = auth()->user()->roles->pluck('name');
$q->whereHas('roles', fn ($rq) => $rq->whereIn('name', $userRoles));
})
->when($this->shouldHideAdminBahanBaku(), fn ($q) => $q->whereDoesntHave('roles', fn ($rq) => $rq->where('name', 'admin-bahan-baku')))
->when(self::hasAnyRole([Role::ADMIN_TOKO, Role::DIREKTUR]), fn ($q) => $q->whereDoesntHave('roles', fn ($rq) => $rq->where('name', Role::ADMIN_BAHAN_BAKU->value)))
->when($search, fn ($q) => $q->whereHas('userProfile', fn ($uq) => $uq->where('full_name', 'like', "%{$search}%")))
->when($filters['employment_status'] ?? null, fn ($q, $status) => $q->whereHas('employee', fn ($eq) => $eq->where('employment_status', $status)))
->when(isset($filters['is_active']) && $filters['is_active'] !== '', fn ($q) => $q->where('is_active', filter_var($filters['is_active'], FILTER_VALIDATE_BOOLEAN)))
@ -66,7 +37,18 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
->paginate($perPage);
}
public function create(array $data): User
public function getAll(): Collection
{
return Employee::with(['user.userProfile', 'user.roles'])
->whereHas('user', fn ($q) => $q->where('is_active', true))
->get()
->map(fn (Employee $employee) => [
'id' => $employee->id,
'name' => $employee->user?->userProfile?->full_name ?? '-',
]);
}
public function store(array $data): User
{
return DB::transaction(function () use ($data) {
$user = User::create([
@ -132,7 +114,7 @@ public function update(User $user, array $data): User
return $user->fresh(['userProfile', 'employee']);
}
public function delete(User $user): bool
public function destroy(User $user): bool
{
return DB::transaction(function () use ($user) {
$user->employee()->delete();

View File

@ -2,39 +2,26 @@
namespace App\Services\Admin\HR;
use App\Concerns\HasRoleChecks;
use App\Enums\LeaveRequestStatus;
use App\Enums\Role;
use App\Models\LeaveRequest;
use App\Services\NotificationService;
use Carbon\Carbon;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class LeaveRequestService
{
private function canViewAll(): bool
{
return auth()->user()->hasAnyRole(['developer', 'owner', 'direktur', 'admin-toko']);
}
public function getAll(array $filters = []): Collection
{
return LeaveRequest::select(['id', 'employee_id', 'start_date', 'end_date', 'total_days', 'status', 'created_at'])
->with(['employee.user.userProfile'])
->when(! $this->canViewAll(), fn ($q) => $q->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id())))
->when($filters['status'] ?? null, function ($query, $status) {
$query->where('status', $status);
})
->latest()
->get();
}
use HasRoleChecks;
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator
{
return LeaveRequest::query()
->select(['id', 'employee_id', 'start_date', 'end_date', 'total_days', 'status', 'created_at'])
->with(['employee.user.userProfile'])
->when(! $this->canViewAll(), fn ($q) => $q->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id())))
->when(! self::hasAnyRole([Role::DEVELOPER, Role::OWNER, Role::DIREKTUR, Role::ADMIN_TOKO]), fn ($q) => $q->whereHas('employee', fn ($eq) => $eq->where('user_id', auth()->id())))
->when($search, fn ($q) => $q->whereHas('employee.user.userProfile', fn ($uq) => $uq->where('full_name', 'like', "%{$search}%")))
->when($filters['status'] ?? null, function ($query, $status) {
$query->where('status', $status);
@ -43,13 +30,15 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
->paginate($perPage);
}
public function create(array $data): LeaveRequest
public function store(array $data): LeaveRequest
{
$leaveRequest = DB::transaction(function () use ($data) {
$employee = auth()->user()->employee;
if (! $employee) {
throw new \Exception('Anda tidak terdaftar sebagai karyawan.');
throw ValidationException::withMessages([
'employee' => 'Anda tidak terdaftar sebagai karyawan.',
]);
}
$startDate = new Carbon($data['start_date']);
@ -68,7 +57,7 @@ public function create(array $data): LeaveRequest
$leaveRequest->load('employee.user');
NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'],
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Pengajuan Cuti Baru',
body: "Pengajuan cuti {$leaveRequest->total_days} hari oleh ".auth()->user()->full_name.'.',
url: route('admin.hr.leave-requests.index'),
@ -95,7 +84,7 @@ public function update(LeaveRequest $leaveRequest, array $data): LeaveRequest
});
}
public function delete(LeaveRequest $leaveRequest): bool
public function destroy(LeaveRequest $leaveRequest): bool
{
return $leaveRequest->delete();
}
@ -111,7 +100,7 @@ public function approve(LeaveRequest $leaveRequest): LeaveRequest
$employeeUser = $leaveRequest->employee->user ?? null;
NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'],
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Cuti Disetujui',
body: "Cuti {$leaveRequest->employee->name} telah disetujui oleh ".auth()->user()->full_name.'.',
url: route('admin.hr.leave-requests.index'),
@ -132,7 +121,7 @@ public function reject(LeaveRequest $leaveRequest): LeaveRequest
$employeeUser = $leaveRequest->employee->user ?? null;
NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'],
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Cuti Ditolak',
body: "Cuti {$leaveRequest->employee->name} telah ditolak oleh ".auth()->user()->full_name.'.',
url: route('admin.hr.leave-requests.index'),

View File

@ -6,7 +6,6 @@
use App\Models\CuttingMaterial;
use App\Models\CuttingMaterialCombination;
use App\Models\CuttingResult;
use App\Models\RawMaterial;
use App\Models\RawMaterialPrice;
use App\Services\Concerns\RegistersMedia;
use App\Services\S3PresignedService;
@ -61,28 +60,6 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
return $paginator;
}
public function getForCreate(): array
{
return [
'rawMaterials' => RawMaterial::query()
->select(['id', 'name', 'unit', 'is_active'])
->with([
'rawMaterialPrices:id,raw_material_id,variant,price,stock',
])
->active()
->orderBy('name')
->get()
->each(function (RawMaterial $rawMaterial) {
$rawMaterial->rawMaterialPrices->each(function (RawMaterialPrice $price) {
$media = $price->getFirstMedia('photos');
$price->photo_url = $media
? $this->s3Service->getTemporaryUrl($media->file_name)
: null;
});
}),
];
}
public function getForEdit(Cutting $cutting): array
{
$cutting->load([
@ -143,7 +120,7 @@ public function getForEdit(Cutting $cutting): array
];
}
public function create(array $data): Cutting
public function store(array $data): Cutting
{
return DB::transaction(function () use ($data) {
foreach ($data['materials'] as $materialData) {
@ -349,7 +326,7 @@ public function update(Cutting $cutting, array $data): Cutting
});
}
public function delete(Cutting $cutting): bool
public function destroy(Cutting $cutting): bool
{
return DB::transaction(function () use ($cutting) {
$cutting->load('cuttingMaterials.rawMaterialPrice');

View File

@ -2,6 +2,7 @@
namespace App\Services\Admin\Manage;
use App\Enums\Role;
use App\Models\Purchase;
use App\Models\PurchaseItem;
use App\Models\RawMaterial;
@ -154,16 +155,16 @@ public function getForEdit(Purchase $purchase): array
];
}
public function create(array $data): Purchase
public function store(array $data): Purchase
{
if (($data['mode'] ?? 'new') === 'existing') {
return $this->createFromExisting($data);
return $this->storeFromExisting($data);
}
return $this->createNew($data);
return $this->storeNew($data);
}
private function createFromExisting(array $data): Purchase
private function storeFromExisting(array $data): Purchase
{
return DB::transaction(function () use ($data) {
$now = now();
@ -219,7 +220,7 @@ private function createFromExisting(array $data): Purchase
}
NotificationService::notify(
roles: ['Owner', 'Developer', 'Admin Bahan Baku'],
roles: [Role::OWNER, Role::DEVELOPER, Role::ADMIN_BAHAN_BAKU],
title: 'Belanja Baru',
body: 'Belanja bahan baku sebesar Rp '.number_format($total, 0, ',', '.').' berhasil dicatat oleh '.auth()->user()->full_name.'.',
url: route('admin.manage.purchases.index'),
@ -229,7 +230,7 @@ private function createFromExisting(array $data): Purchase
});
}
private function createNew(array $data): Purchase
private function storeNew(array $data): Purchase
{
return DB::transaction(function () use ($data) {
$rawMaterial = RawMaterial::create([
@ -309,7 +310,7 @@ private function createNew(array $data): Purchase
}
NotificationService::notify(
roles: ['Owner', 'Developer', 'Admin Bahan Baku'],
roles: [Role::OWNER, Role::DEVELOPER, Role::ADMIN_BAHAN_BAKU],
title: 'Belanja Baru',
body: 'Belanja bahan baku sebesar Rp '.number_format($total, 0, ',', '.').' berhasil dicatat oleh '.auth()->user()->full_name.'.',
url: route('admin.manage.purchases.index'),
@ -334,12 +335,6 @@ public function update(Purchase $purchase, array $data): Purchase
}
});
$oldMaterial = $oldItems
->map(fn (PurchaseItem $item) => $item->rawMaterialPrice?->rawMaterial)
->filter()
->unique(fn (RawMaterial $material) => $material->id)
->first();
$oldMaterial = $oldItems
->map(fn (PurchaseItem $item) => $item->rawMaterialPrice?->rawMaterial)
->filter()
@ -466,7 +461,7 @@ public function update(Purchase $purchase, array $data): Purchase
});
}
public function delete(Purchase $purchase): bool
public function destroy(Purchase $purchase): bool
{
return DB::transaction(function () use ($purchase) {
$purchase->load('purchaseItems.rawMaterialPrice');

View File

@ -4,7 +4,7 @@
use App\Enums\PriceType;
use App\Enums\ProductStockQuality;
use App\Models\Product;
use App\Enums\Role;
use App\Models\ProductVariant;
use App\Models\Restock;
use App\Models\RestockItem;
@ -59,65 +59,7 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
return $paginator;
}
public function getForCreate(): array
{
return [
'products' => Product::query()
->select(['id', 'name', 'status'])
->with([
'productVariants:id,product_id,name,stock,reject_stock',
'productVariants.productPrices:id,variant_id,type,price',
])
->active()
->orderBy('name')
->get()
->each(function (Product $product) {
$product->productVariants->each(function (ProductVariant $variant) {
$media = $variant->getFirstMedia('photos');
$variant->photo_url = $media
? $this->s3Service->getTemporaryUrl($media->file_name)
: null;
$capitalPrice = $variant->productPrices
->first(fn ($price) => $price->type === PriceType::CAPITAL);
$variant->capital_price = $capitalPrice?->price ?? 0;
$rejectPrice = $variant->productPrices
->first(fn ($price) => $price->type === PriceType::REJECT);
$variant->reject_price = $rejectPrice?->price ?? 0;
});
}),
];
}
public function getForEdit(Restock $restock): array
{
$restock->load([
'restockItems' => fn ($q) => $q
->orderByRaw('(SELECT name FROM product_variants WHERE product_variants.id = restock_items.product_variant_id)'),
'restockItems.productVariant.product',
]);
$media = $restock->getFirstMedia('photos');
return [
'id' => $restock->id,
'stock_type' => $restock->stock_type->value,
'notes' => $restock->notes,
'photo_key' => $media?->file_name,
'photo_url' => $media
? $this->s3Service->getTemporaryUrl($media->file_name)
: null,
'items' => $restock->restockItems->map(fn (RestockItem $item) => [
'id' => $item->id,
'product_variant_id' => $item->product_variant_id,
'quantity' => $item->quantity,
'unit_price' => $item->unit_price,
])->values(),
];
}
public function create(array $data): Restock
public function store(array $data): Restock
{
return DB::transaction(function () use ($data) {
$now = now();
@ -143,7 +85,7 @@ public function create(array $data): Restock
$this->syncPhoto($restock, $data);
NotificationService::notify(
roles: ['Owner', 'Developer', 'Admin Toko'],
roles: [Role::OWNER, Role::DEVELOPER, Role::ADMIN_TOKO],
title: 'Restock Baru',
body: 'Restock '.($stockType === ProductStockQuality::GOOD->value ? 'produk' : 'reject').' sebesar Rp '.number_format($subtotal, 0, ',', '.').' berhasil dicatat oleh '.auth()->user()->full_name.'.',
url: route('admin.manage.restocks.index'),
@ -189,7 +131,7 @@ public function update(Restock $restock, array $data): Restock
});
}
public function delete(Restock $restock): bool
public function destroy(Restock $restock): bool
{
return DB::transaction(function () use ($restock) {
$restock->load('restockItems');

View File

@ -7,10 +7,10 @@
use App\Enums\PaymentType;
use App\Enums\PriceType;
use App\Enums\ProductStockQuality;
use App\Enums\Role;
use App\Models\Customer;
use App\Models\Order;
use App\Models\OrderItem;
use App\Models\Product;
use App\Models\ProductVariant;
use App\Models\User;
use App\Services\Concerns\HasStockAdjustment;
@ -41,7 +41,7 @@ public function __construct(
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator
{
$paginator = Order::query()
->select(['id', 'created_by_id', 'customer_id', 'marketing_id', 'order_number', 'channel', 'price_type', 'status', 'payment_type', 'subtotal', 'discount', 'nego_price', 'total_amount', 'cogs', 'notes', 'created_at'])
->select(['id', 'created_by_id', 'customer_id', 'marketing_id', 'order_number', 'channel', 'price_type', 'status', 'payment_type', 'tiktok_order_id', 'shopee_order_id', 'subtotal', 'discount', 'nego_price', 'total_amount', 'cogs', 'notes', 'created_at'])
->with([
'createdBy:id',
'createdBy.userProfile:id,user_id,full_name',
@ -96,7 +96,7 @@ public function getSummary(array $filters = []): array
$query = Order::query()
->selectRaw('COUNT(*) as total_orders')
->selectRaw('COALESCE(SUM(subtotal), 0) as total_subtotal')
->selectRaw('COALESCE(SUM(subtotal) - SUM(COALESCE(nego_price, subtotal)), 0) as total_discount')
->selectRaw('COALESCE(SUM(discount) + SUM(COALESCE(nego_price, 0)), 0) as total_discount')
->selectRaw('COALESCE(SUM(total_amount), 0) as total_amount')
->selectRaw('COALESCE(SUM(cogs), 0) as total_cogs')
->when($filters['status'] ?? null, fn ($q, $status) => $q->where('status', $status))
@ -143,83 +143,7 @@ public function getFilterOptions(): array
];
}
public function getForCreate(): array
{
return [
'products' => Product::query()
->select(['id', 'name', 'status'])
->with([
'productVariants:id,product_id,name,stock,reject_stock',
'productVariants.productPrices:id,variant_id,type,price',
])
->active()
->orderBy('name')
->get()
->each(function (Product $product) {
$product->productVariants->each(function (ProductVariant $variant) {
$media = $variant->getFirstMedia('photos');
$variant->photo_url = $media
? $this->s3Service->getTemporaryUrl($media->file_name)
: null;
$prices = $variant->productPrices->mapWithKeys(fn ($p) => [$p->type->value => $p->price]);
$variant->prices = $prices;
});
}),
'customers' => Customer::query()
->select(['id', 'name'])
->orderBy('name')
->get(),
'employees' => User::query()
->select('id')
->active()
->with('userProfile:id,user_id,full_name')
->orderBy('id')
->get()
->filter(fn (User $user) => $user->userProfile?->full_name)
->values(),
'channelOptions' => OrderChannel::toSelect(),
'paymentTypeOptions' => PaymentType::toSelect(),
'priceTypeOptions' => PriceType::toSelect()->filter(fn ($p) => $p['value'] !== PriceType::CAPITAL->value)->values(),
];
}
public function getForEdit(Order $order): array
{
$order->load('orderItems.productVariant.product');
$media = $order->getFirstMedia('photos');
return [
'id' => $order->id,
'order_number' => $order->order_number,
'stock_type' => $order->orderItems->first()?->stock_type?->value ?? ProductStockQuality::GOOD->value,
'channel' => $order->channel?->value ?? OrderChannel::STORE->value,
'price_type' => $order->price_type?->value ?? PriceType::RETAIL->value,
'payment_type' => $order->payment_type?->value ?? PaymentType::CASH->value,
'customer_id' => $order->customer_id,
'marketing_id' => $order->marketing_id,
'discount' => $order->discount,
'nego_price' => $order->nego_price,
'is_completed' => $order->status === OrderStatus::COMPLETED,
'is_affiliate' => $order->is_affiliate,
'tiktok_order_id' => $order->tiktok_order_id,
'shopee_order_id' => $order->shopee_order_id,
'notes' => $order->notes,
'photo_key' => $media?->file_name,
'photo_url' => $media
? $this->s3Service->getTemporaryUrl($media->file_name)
: null,
'items' => $order->orderItems->map(fn (OrderItem $item) => [
'id' => $item->id,
'product_variant_id' => $item->product_variant_id,
'quantity' => $item->quantity,
'unit_price' => $item->unit_price,
])->values(),
];
}
public function create(array $data): Order
public function store(array $data): Order
{
return DB::transaction(function () use ($data) {
$now = now();
@ -234,7 +158,7 @@ public function create(array $data): Order
$discount = (int) ($data['discount'] ?? 0);
$negoPrice = ! empty($data['nego_price']) ? (int) $data['nego_price'] : null;
$totalAmount = $subtotal - $discount + ($negoPrice ?? 0);
$totalAmount = $subtotal - $discount - ($negoPrice ?? 0);
$order = Order::create([
'created_by_id' => auth()->id(),
@ -268,7 +192,7 @@ public function create(array $data): Order
}
NotificationService::notify(
roles: ['Owner', 'Developer', 'Admin Toko'],
roles: [Role::OWNER, Role::DEVELOPER, Role::ADMIN_TOKO],
title: 'Transaksi Baru',
body: 'Transaksi '.$order->order_number.' sebesar Rp '.number_format($totalAmount, 0, ',', '.').' berhasil dicatat oleh '.auth()->user()->full_name.'.',
url: route('admin.manage.transactions.index'),
@ -301,7 +225,7 @@ public function update(Order $order, array $data): Order
$discount = (int) ($data['discount'] ?? 0);
$negoPrice = ! empty($data['nego_price']) ? (int) $data['nego_price'] : null;
$totalAmount = $subtotal - $discount + ($negoPrice ?? 0);
$totalAmount = $subtotal - $discount - ($negoPrice ?? 0);
foreach ($itemRows as &$row) {
$row['order_id'] = $order->id;
@ -339,7 +263,7 @@ public function update(Order $order, array $data): Order
});
}
public function delete(Order $order): bool
public function destroy(Order $order): bool
{
return DB::transaction(function () use ($order) {
$order->load('orderItems');

View File

@ -8,11 +8,6 @@
class CategoryService
{
public function getAll(array $filters = []): Collection
{
return Category::select(['id', 'name'])->latest()->get();
}
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator
{
return Category::query()
@ -22,7 +17,12 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
->paginate($perPage);
}
public function create(array $data): Category
public function getAll(): Collection
{
return Category::select(['id', 'name'])->latest()->get();
}
public function store(array $data): Category
{
return Category::create($data);
}
@ -34,7 +34,7 @@ public function update(Category $category, array $data): Category
return $category;
}
public function delete(Category $category): bool
public function destroy(Category $category): bool
{
return $category->delete();
}

View File

@ -8,11 +8,6 @@
class CustomerService
{
public function getAll(array $filters = []): Collection
{
return Customer::select(['id', 'name', 'phone_number', 'address'])->latest()->get();
}
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator
{
return Customer::query()
@ -22,7 +17,12 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
->paginate($perPage);
}
public function create(array $data): Customer
public function getAll(): Collection
{
return Customer::select(['id', 'name'])->orderBy('name')->get();
}
public function store(array $data): Customer
{
return Customer::create($data);
}
@ -34,7 +34,7 @@ public function update(Customer $customer, array $data): Customer
return $customer;
}
public function delete(Customer $customer): bool
public function destroy(Customer $customer): bool
{
return $customer->delete();
}

View File

@ -2,70 +2,48 @@
namespace App\Services\Admin\Master\Product;
use App\Concerns\HasRoleChecks;
use App\Enums\ProductStatus;
use App\Enums\Role;
use App\Models\Product;
use App\Models\ProductVariant;
use App\Services\NotificationService;
use App\Services\S3PresignedService;
use App\Services\StockMutationService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class ProductService
{
use HasRoleChecks;
public function __construct(
private ProductVariantService $variantService,
private S3PresignedService $s3Service,
private StockMutationService $stockMutationService,
) {}
public function getNames(): array
public function getNames(): Collection
{
return Product::where('status', '!=', 'deleted')
->orderBy('name')
->pluck('name')
->unique()
->values()
->toArray();
}
public function getAll(array $filters = []): Collection
{
$products = Product::select(['id', 'name', 'slug', 'description', 'status'])
->with([
'categories:id,name',
'productVariants:id,product_id,name,stock,reject_stock,retail_stock',
'productVariants.productPrices:id,variant_id,type,price',
'productVariants.media',
])
->when($filters['status'] ?? null, fn ($q, $status) => $q->where('status', $status))
->when($filters['name'] ?? null, fn ($q, $name) => $q->where('name', 'like', "%{$name}%"))
->latest()
return Product::select(['id', 'name'])
->get();
$products->each(function ($product) {
$product->productVariants->each(function ($variant) {
$media = $variant->getMedia('photos');
$variant->photo_urls = $media->map(fn ($m) => $this->s3Service->getTemporaryUrl($m->file_name))->toArray();
});
});
return $products;
}
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator
{
$paginator = Product::query()
->select(['id', 'name', 'slug', 'description', 'status'])
->select(['id', 'name', 'slug', 'description', 'status', 'rejection_reason'])
->with([
'categories:id,name',
'productVariants:id,product_id,name,stock,reject_stock,retail_stock',
'productVariants.productPrices:id,variant_id,type,price',
])
->when($search, fn ($q) => $q->where('name', 'like', "%{$search}%"))
->when($filters['name'] ?? null, fn ($q, $name) => $q->where('name', 'like', "%{$name}%"))
->when($filters['status'] ?? null, fn ($q, $status) => $q->where('status', $status))
->when($filters['category'] ?? null, fn ($q, $categoryId) => $q->whereHas('categories', function ($cq) use ($categoryId) {
->when($search, fn($q) => $q->where('name', 'like', "%{$search}%"))
->when($filters['name'] ?? null, fn($q, $name) => $q->where('name', 'like', "%{$name}%"))
->when($filters['status'] ?? null, fn($q, $status) => $q->where('status', $status))
->when($filters['category'] ?? null, fn($q, $categoryId) => $q->whereHas('categories', function ($cq) use ($categoryId) {
$cq->where('categories.id', $categoryId);
}))
->when(($filters['stock'] ?? null) === 'empty', function ($q) {
@ -80,20 +58,24 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
$paginator->getCollection()->each(function ($product) {
$product->productVariants->each(function ($variant) {
$media = $variant->getMedia('photos');
$variant->photo_urls = $media->map(fn ($m) => $this->s3Service->getTemporaryUrl($m->file_name))->toArray();
$variant->photo_urls = $media->map(fn($m) => $this->s3Service->getTemporaryUrl($m->file_name))->toArray();
});
});
return $paginator;
}
public function create(array $data): Product
public function store(array $data): Product
{
$product = DB::transaction(function () use ($data) {
$status = self::hasAnyRole([Role::DEVELOPER, Role::OWNER])
? ($data['status'] ?? ProductStatus::ACTIVE)
: ProductStatus::PENDING;
$product = DB::transaction(function () use ($data, $status) {
$product = Product::create([
'name' => $data['name'],
'description' => $data['description'] ?? null,
'status' => $data['status'] ?? 'active',
'status' => $status,
]);
$product->categories()->sync($data['category_ids']);
@ -102,7 +84,7 @@ public function create(array $data): Product
// Bulk insert variants
$now = now();
$variantRows = collect($data['variants'])->map(fn ($v) => [
$variantRows = collect($data['variants'])->map(fn($v) => [
'product_id' => $product->id,
'name' => $v['name'],
'stock' => $v['stock'],
@ -116,7 +98,7 @@ public function create(array $data): Product
// Map variant name -> variant ID
$insertedVariants = ProductVariant::where('product_id', $product->id)->get();
$variantMap = $insertedVariants->mapWithKeys(fn ($v) => [$v->name => $v->id]);
$variantMap = $insertedVariants->mapWithKeys(fn($v) => [$v->name => $v->id]);
// Bulk insert prices
$priceRows = [];
@ -160,9 +142,9 @@ public function create(array $data): Product
});
NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'],
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Produk Baru',
body: "Produk \"{$product->name}\" berhasil ditambahkan".' oleh '.auth()->user()->full_name.'.',
body: "Produk \"{$product->name}\" berhasil ditambahkan" . ' oleh ' . auth()->user()->full_name . '.',
url: route('admin.master.products.index'),
);
@ -180,7 +162,7 @@ public function getForEdit(Product $product): array
$variants = $product->productVariants->map(function (ProductVariant $variant) {
$media = $variant->getMedia('photos');
$photoKeys = $media->pluck('file_name')->toArray();
$photoUrls = $media->map(fn ($m) => $this->s3Service->getTemporaryUrl($m->file_name))->toArray();
$photoUrls = $media->map(fn($m) => $this->s3Service->getTemporaryUrl($m->file_name))->toArray();
return [
'id' => $variant->id,
@ -190,7 +172,7 @@ public function getForEdit(Product $product): array
'retail_stock' => $variant->retail_stock,
'photo_keys' => $photoKeys,
'photo_urls' => $photoUrls,
'prices' => $variant->productPrices->map(fn ($p) => [
'prices' => $variant->productPrices->map(fn($p) => [
'type' => $p->type->value,
'price' => $p->price,
]),
@ -209,11 +191,19 @@ public function getForEdit(Product $product): array
public function update(Product $product, array $data): Product
{
$this->assertNotPending($product);
$product = DB::transaction(function () use ($product, $data) {
// Auto-resubmit: non-verifier editing rejected product → status becomes pending
$newStatus = $data['status'] ?? $product->status;
if ($product->status === ProductStatus::REJECTED && ! self::hasAnyRole([Role::DEVELOPER, Role::OWNER])) {
$newStatus = ProductStatus::PENDING;
}
$product->update([
'name' => $data['name'],
'description' => $data['description'] ?? null,
'status' => $data['status'] ?? $product->status,
'status' => $newStatus,
]);
$product->categories()->sync($data['category_ids']);
@ -240,7 +230,7 @@ public function update(Product $product, array $data): Product
$existingVariantsMap = ProductVariant::whereIn('id', $existingVariantIds)
->with('media')
->get()
->mapWithKeys(fn ($v) => [$v->id => $v]);
->mapWithKeys(fn($v) => [$v->id => $v]);
// Collect old stock data + identify changed variants
$oldStockDataMap = [];
@ -266,7 +256,7 @@ public function update(Product $product, array $data): Product
// Bulk update changed variants (only changed ones, not all)
if ($changedIds !== []) {
$changedUpdates = collect($data['variants'])
->filter(fn ($v) => isset($v['id']) && in_array($v['id'], $changedIds));
->filter(fn($v) => isset($v['id']) && in_array($v['id'], $changedIds));
foreach ($changedUpdates as $variantData) {
$existingVariantsMap[$variantData['id']]->update([
@ -279,11 +269,11 @@ public function update(Product $product, array $data): Product
}
// Bulk create new variants
$newVariantsData = collect($data['variants'])->filter(fn ($v) => ! isset($v['id']));
$newVariantsData = collect($data['variants'])->filter(fn($v) => ! isset($v['id']));
$newVariantIdMap = [];
if ($newVariantsData->isNotEmpty()) {
$newVariantRows = $newVariantsData->map(fn ($v) => [
$newVariantRows = $newVariantsData->map(fn($v) => [
'product_id' => $product->id,
'name' => $v['name'],
'stock' => $v['stock'],
@ -300,7 +290,7 @@ public function update(Product $product, array $data): Product
->whereIn('name', $newVariantsData->pluck('name')->toArray())
->get();
$newVariantIdMap = $newlyCreated->mapWithKeys(fn ($v) => [$v->name => $v->id])->toArray();
$newVariantIdMap = $newlyCreated->mapWithKeys(fn($v) => [$v->name => $v->id])->toArray();
}
// Build variant_id lookup: existing by id, new by name
@ -364,7 +354,7 @@ public function update(Product $product, array $data): Product
}
}
$changedModels = collect($changedIds)->map(fn ($id) => $existingVariantsMap[$id])->filter();
$changedModels = collect($changedIds)->map(fn($id) => $existingVariantsMap[$id])->filter();
$this->stockMutationService->recordBulkAdjustment(
$changedModels,
$oldStockDataMap,
@ -403,17 +393,19 @@ public function update(Product $product, array $data): Product
});
NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'],
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Produk Diperbarui',
body: "Produk \"{$product->name}\" berhasil diperbarui".' oleh '.auth()->user()->full_name.'.',
body: "Produk \"{$product->name}\" berhasil diperbarui" . ' oleh ' . auth()->user()->full_name . '.',
url: route('admin.master.products.index'),
);
return $product;
}
public function delete(Product $product): bool
public function destroy(Product $product): bool
{
$this->assertNotPending($product);
$result = DB::transaction(function () use ($product) {
$product->productVariants->each(function (ProductVariant $variant) {
$variant->productPrices()->delete();
@ -427,9 +419,9 @@ public function delete(Product $product): bool
});
NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'],
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Produk Dihapus',
body: "Produk \"{$product->name}\" berhasil dihapus".' oleh '.auth()->user()->full_name.'.',
body: "Produk \"{$product->name}\" berhasil dihapus" . ' oleh ' . auth()->user()->full_name . '.',
url: route('admin.master.products.index'),
);
@ -438,8 +430,65 @@ public function delete(Product $product): bool
public function toggleStatus(Product $product): void
{
$this->assertNotPending($product);
$product->update([
'status' => $product->status->value === 'active' ? 'inactive' : 'active',
]);
}
public function approve(Product $product): void
{
$product->update([
'status' => ProductStatus::ACTIVE,
]);
NotificationService::notify(
roles: [Role::OWNER, Role::DEVELOPER],
title: 'Produk Disetujui',
body: "Produk \"{$product->name}\" telah disetujui oleh " . auth()->user()->full_name . '.',
url: route('admin.master.products.index'),
additionalUser: $product->createdBy ?? null,
);
}
public function reject(Product $product, string $reason = ''): void
{
$product->update([
'status' => ProductStatus::REJECTED,
'rejection_reason' => $reason,
]);
NotificationService::notify(
roles: [Role::OWNER, Role::DEVELOPER],
title: 'Produk Ditolak',
body: "Produk \"{$product->name}\" telah ditolak oleh " . auth()->user()->full_name . '.',
url: route('admin.master.products.index'),
additionalUser: $product->createdBy ?? null,
);
}
public function resubmit(Product $product): void
{
$product->update([
'status' => ProductStatus::PENDING,
'rejection_reason' => null,
]);
NotificationService::notify(
roles: [Role::OWNER, Role::DEVELOPER],
title: 'Produk Diajukan Ulang',
body: "Produk \"{$product->name}\" telah diajukan ulang oleh " . auth()->user()->full_name . '.',
url: route('admin.master.products.index'),
);
}
private function assertNotPending(Product $product): void
{
if ($product->status === ProductStatus::PENDING && ! self::hasAnyRole([Role::DEVELOPER, Role::OWNER])) {
throw ValidationException::withMessages([
'status' => 'Produk sedang menunggu verifikasi dan tidak dapat diubah.',
]);
}
}
}

View File

@ -2,6 +2,10 @@
namespace App\Services\Admin\Master\Product;
use App\Concerns\HasRoleChecks;
use App\Enums\PriceType;
use App\Enums\ProductStatus;
use App\Enums\Role;
use App\Models\Product;
use App\Models\ProductVariant;
use App\Services\Concerns\RegistersMedia;
@ -13,13 +17,66 @@
class ProductVariantService
{
use RegistersMedia;
use HasRoleChecks, RegistersMedia;
public function __construct(
private S3PresignedService $s3Service,
private StockMutationService $stockMutationService,
) {}
public function getForRestock(): array
{
return Product::query()
->select(['id', 'name', 'status'])
->with([
'productVariants:id,product_id,name,stock,reject_stock',
'productVariants.productPrices:id,variant_id,type,price',
])
->active()
->orderBy('name')
->get()
->each(function (Product $product) {
$product->productVariants->each(function (ProductVariant $variant) {
$media = $variant->getFirstMedia('photos');
$variant->photo_url = $media
? $this->s3Service->getTemporaryUrl($media->file_name)
: null;
$capitalPrice = $variant->productPrices
->first(fn ($price) => $price->type === PriceType::CAPITAL);
$variant->capital_price = $capitalPrice?->price ?? 0;
$rejectPrice = $variant->productPrices
->first(fn ($price) => $price->type === PriceType::REJECT);
$variant->reject_price = $rejectPrice?->price ?? 0;
});
});
}
public function getForTransaction(): array
{
return Product::query()
->select(['id', 'name', 'status'])
->with([
'productVariants:id,product_id,name,stock,reject_stock',
'productVariants.productPrices:id,variant_id,type,price',
])
->active()
->orderBy('name')
->get()
->each(function (Product $product) {
$product->productVariants->each(function (ProductVariant $variant) {
$media = $variant->getFirstMedia('photos');
$variant->photo_url = $media
? $this->s3Service->getTemporaryUrl($media->file_name)
: null;
$prices = $variant->productPrices->mapWithKeys(fn ($p) => [$p->type->value => $p->price]);
$variant->prices = $prices;
});
});
}
public function getForEdit(ProductVariant $variant): array
{
$variant->load([
@ -49,6 +106,8 @@ public function getForEdit(ProductVariant $variant): array
public function update(ProductVariant $variant, array $data): ProductVariant
{
$this->assertNotPending($variant->product);
DB::transaction(function () use ($variant, $data) {
$oldData = $variant->only(['stock', 'reject_stock', 'retail_stock']);
@ -83,7 +142,7 @@ public function update(ProductVariant $variant, array $data): ProductVariant
});
NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'],
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Varian Diperbarui',
body: "Varian \"{$variant->name}\" berhasil diperbarui".' oleh '.auth()->user()->full_name.'.',
url: route('admin.master.products.index'),
@ -92,8 +151,10 @@ public function update(ProductVariant $variant, array $data): ProductVariant
return $variant->fresh();
}
public function delete(Product $product, ProductVariant $variant): bool
public function destroy(Product $product, ProductVariant $variant): bool
{
$this->assertNotPending($product);
$result = DB::transaction(function () use ($variant) {
$variant->productPrices()->delete();
$variant->clearMediaCollection('photos');
@ -102,7 +163,7 @@ public function delete(Product $product, ProductVariant $variant): bool
});
NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'],
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Varian Dihapus',
body: "Varian \"{$variant->name}\" dari produk \"{$product->name}\" berhasil dihapus".' oleh '.auth()->user()->full_name.'.',
url: route('admin.master.products.index'),
@ -134,6 +195,8 @@ public function transferStock(ProductVariant $variant, array $data): ProductVari
{
$quantity = (int) $data['quantity'];
$this->assertNotPending($variant->product);
if ($variant->stock < $quantity) {
throw ValidationException::withMessages([
'quantity' => "Stok bagus tidak mencukupi. Stok tersedia: {$variant->stock}.",
@ -161,7 +224,7 @@ public function transferStock(ProductVariant $variant, array $data): ProductVari
});
NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'],
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Transfer Stok',
body: "{$quantity} unit dari varian \"{$variant->name}\" berhasil ditransfer dari stok bagus ke stok ecer".' oleh '.auth()->user()->full_name.'.',
url: route('admin.master.products.index'),
@ -169,4 +232,13 @@ public function transferStock(ProductVariant $variant, array $data): ProductVari
return $variant->fresh();
}
private function assertNotPending(Product $product): void
{
if ($product->status === ProductStatus::PENDING && ! self::hasAnyRole([Role::DEVELOPER, Role::OWNER])) {
throw ValidationException::withMessages([
'status' => 'Produk sedang menunggu verifikasi dan tidak dapat diubah.',
]);
}
}
}

View File

@ -7,7 +7,6 @@
use App\Services\Concerns\RegistersMedia;
use App\Services\S3PresignedService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\DB;
class RawMaterialService
@ -18,27 +17,6 @@ public function __construct(
private S3PresignedService $s3Service,
) {}
public function getAll(array $filters = []): Collection
{
return RawMaterial::select(['id', 'name', 'unit', 'is_active'])
->with([
'rawMaterialPrices:id,raw_material_id,variant,price,stock',
'rawMaterialPrices.media',
])
->when($filters['is_active'] ?? null, fn ($q, $isActive) => $q->where('is_active', $isActive === 'true'))
->when($filters['name'] ?? null, fn ($q, $name) => $q->where('name', 'like', "%{$name}%"))
->latest()
->get()
->each(function ($rawMaterial) {
$rawMaterial->rawMaterialPrices->each(function ($price) {
$media = $price->getMedia('photos');
$price->photo_url = $media->first()
? $this->s3Service->getTemporaryUrl($media->first()->file_name)
: null;
});
});
}
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator
{
$paginator = RawMaterial::query()
@ -71,7 +49,7 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
return $paginator;
}
public function create(array $data): RawMaterial
public function store(array $data): RawMaterial
{
return DB::transaction(function () use ($data) {
$rawMaterial = RawMaterial::create([
@ -220,7 +198,7 @@ public function update(RawMaterial $rawMaterial, array $data): RawMaterial
});
}
public function delete(RawMaterial $rawMaterial): bool
public function destroy(RawMaterial $rawMaterial): bool
{
return DB::transaction(function () use ($rawMaterial) {
$rawMaterial->rawMaterialPrices->each(function (RawMaterialPrice $price) {

View File

@ -2,6 +2,7 @@
namespace App\Services\Admin\Master\RawMaterial;
use App\Enums\Role;
use App\Models\RawMaterial;
use App\Models\RawMaterialPrice;
use App\Services\Concerns\RegistersMedia;
@ -17,6 +18,27 @@ public function __construct(
private S3PresignedService $s3Service,
) {}
public function getForCutting(): array
{
return RawMaterial::query()
->select(['id', 'name', 'unit', 'is_active'])
->with([
'rawMaterialPrices:id,raw_material_id,variant,price,stock',
])
->active()
->orderBy('name')
->get()
->each(function (RawMaterial $rawMaterial) {
$rawMaterial->rawMaterialPrices->each(function (RawMaterialPrice $price) {
$media = $price->getFirstMedia('photos');
$price->photo_url = $media
? $this->s3Service->getTemporaryUrl($media->file_name)
: null;
});
})
->toArray();
}
public function getForEdit(RawMaterialPrice $variant): array
{
$variant->load('media');
@ -61,7 +83,7 @@ public function update(RawMaterialPrice $variant, array $data): RawMaterialPrice
});
NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'],
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Varian Diperbarui',
body: "Varian \"{$variant->variant}\" berhasil diperbarui".' oleh '.auth()->user()->full_name.'.',
url: route('admin.master.raw-materials.index'),
@ -70,7 +92,7 @@ public function update(RawMaterialPrice $variant, array $data): RawMaterialPrice
return $variant->fresh();
}
public function delete(RawMaterial $rawMaterial, RawMaterialPrice $variant): bool
public function destroy(RawMaterial $rawMaterial, RawMaterialPrice $variant): bool
{
$result = DB::transaction(function () use ($variant) {
$variant->clearMediaCollection('photos');
@ -79,7 +101,7 @@ public function delete(RawMaterial $rawMaterial, RawMaterialPrice $variant): boo
});
NotificationService::notify(
roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'],
roles: [Role::OWNER, Role::DEVELOPER, Role::DIREKTUR, Role::ADMIN_TOKO],
title: 'Varian Dihapus',
body: "Varian \"{$variant->variant}\" dari bahan baku \"{$rawMaterial->name}\" berhasil dihapus".' oleh '.auth()->user()->full_name.'.',
url: route('admin.master.raw-materials.index'),

View File

@ -8,11 +8,6 @@
class SupplierService
{
public function getAll(array $filters = []): Collection
{
return Supplier::select(['id', 'name', 'phone_number', 'address'])->latest()->get();
}
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator
{
return Supplier::query()
@ -22,7 +17,12 @@ public function paginated(int $perPage = 25, string $search = '', string $sort =
->paginate($perPage);
}
public function create(array $data): Supplier
public function getAll(): Collection
{
return Supplier::select(['id', 'name', 'phone_number', 'address'])->latest()->get();
}
public function store(array $data): Supplier
{
return Supplier::create($data);
}
@ -34,7 +34,7 @@ public function update(Supplier $supplier, array $data): Supplier
return $supplier;
}
public function delete(Supplier $supplier): bool
public function destroy(Supplier $supplier): bool
{
return $supplier->delete();
}

View File

@ -2,37 +2,32 @@
namespace App\Services\Admin\Settings;
use App\Concerns\HasRoleChecks;
use App\Enums\Role;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
use Spatie\Permission\Models\Role as SpatieRole;
class RoleService
{
public function getAll(): Collection
{
return Role::withCount('permissions')->get();
}
use HasRoleChecks;
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator
{
return Role::query()
return SpatieRole::query()
->select(['id', 'name'])
->withCount('permissions')
->when($search, fn ($q) => $q->where('name', 'like', "%{$search}%"))
->orderBy($sort, $direction)
->paginate($perPage);
}
public function getById(int $id): Role
{
return Role::with('permissions')->findOrFail($id);
}
public function create(array $data): Role
public function store(array $data): Role
{
return DB::transaction(function () use ($data) {
$role = Role::create(['name' => $data['name']]);
$role = SpatieRole::create(['name' => $data['name']]);
$role->syncPermissions($data['permissions']);
return $role;
@ -49,7 +44,7 @@ public function update(Role $role, array $data): Role
return $role->fresh('permissions');
}
public function delete(Role $role): bool
public function destroy(Role $role): bool
{
return $role->delete();
}
@ -61,4 +56,22 @@ public function getPermissionsByModule(): array
->map(fn ($group) => $group->pluck('name')->map(fn ($name) => explode('.', $name, 2)[1])->values()->toArray())
->toArray();
}
public function getForEmployee(): Collection
{
$query = SpatieRole::where('name', '!=', Role::DEVELOPER->value);
$user = auth()->user();
if (self::hasAnyRole([Role::ADMIN_TOKO, Role::DIREKTUR])) {
$query->where('name', '!=', Role::ADMIN_BAHAN_BAKU->value);
}
if (! self::hasAnyRole([Role::DEVELOPER, Role::OWNER, Role::DIREKTUR, Role::ADMIN_TOKO])) {
$userRoles = $user->roles->pluck('name');
$query->whereIn('name', $userRoles);
}
return $query->get(['id', 'name']);
}
}

View File

@ -0,0 +1,578 @@
<?php
namespace App\Services;
use App\Enums\CashTransactionType;
use App\Enums\OrderChannel;
use App\Enums\OrderStatus;
use App\Enums\PaymentType;
use App\Enums\PriceType;
use App\Enums\RawMaterialUnit;
use App\Models\Attendance;
use App\Models\CashAccount;
use App\Models\Employee;
use App\Models\EmployeeAdvance;
use App\Models\Expense;
use App\Models\LeaveRequest;
use App\Models\Order;
use App\Models\ProductVariant;
use App\Models\Purchase;
use App\Models\PurchaseItem;
use App\Models\RawMaterialPrice;
use App\Models\RestockItem;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
class AnalysisService
{
public function getAttendanceStats(?string $startDate, ?string $endDate): array
{
$start = $startDate ? Carbon::parse($startDate) : Carbon::now()->startOfMonth();
$end = $endDate ? Carbon::parse($endDate) : Carbon::now();
$employees = Employee::whereHas('user', fn ($q) => $q
->where('is_active', true)
->whereHas('roles', fn ($r) => $r
->whereHas('permissions', fn ($p) => $p
->where('name', 'attendances.create')
)
)
)
->where('join_date', '<=', $end)
->where(function ($q) use ($start) {
$q->whereNull('resign_date')->orWhere('resign_date', '>=', $start);
})
->get();
$employeeIds = $employees->pluck('id');
$employeeCount = $employeeIds->count();
$present = Attendance::whereIn('employee_id', $employeeIds)
->whereBetween('attendance_date', [$start->toDateString(), $end->toDateString()])
->distinct('employee_id')
->count('employee_id');
$onLeave = LeaveRequest::approved()
->where('start_date', '<=', $end)
->where('end_date', '>=', $start)
->whereIn('employee_id', $employeeIds)
->count();
$absent = max(0, $employeeCount - $present - $onLeave);
return [
'total_employees' => $employeeCount,
'present' => $present,
'absent' => $absent,
'on_leave' => $onLeave,
'percentage' => $employeeCount > 0 ? round(($present / $employeeCount) * 100) : 0,
];
}
public function getMyAttendance(User $user, ?string $startDate, ?string $endDate): ?array
{
$employee = $user->employee;
if (! $employee) {
return null;
}
$start = $startDate ? Carbon::parse($startDate) : Carbon::now()->startOfMonth();
$end = $endDate ? Carbon::parse($endDate) : Carbon::now();
$workingDays = 0;
$current = $start->copy();
while ($current->lte($end)) {
if (! in_array($current->dayOfWeek, [Carbon::SATURDAY, Carbon::SUNDAY])) {
$workingDays++;
}
$current->addDay();
}
$present = Attendance::where('employee_id', $employee->id)
->whereBetween('attendance_date', [$start, $end])
->count();
$leaveDays = LeaveRequest::approved()
->where('employee_id', $employee->id)
->where('start_date', '<=', $end)
->where('end_date', '>=', $start)
->get()
->reduce(function ($carry, $leave) use ($start, $end) {
$leaveStart = max($leave->start_date->timestamp, $start->timestamp);
$leaveEnd = min($leave->end_date->timestamp, $end->timestamp);
$days = Carbon::createFromTimestamp($leaveStart)->diffInDays(Carbon::createFromTimestamp($leaveEnd)) + 1;
return $carry + max(0, $days);
}, 0);
$absent = max(0, $workingDays - $present - $leaveDays);
return [
'total_days' => $workingDays,
'present_days' => $present,
'absent_days' => $absent,
'leave_days' => $leaveDays,
'percentage' => $workingDays > 0 ? round(($present / $workingDays) * 100) : 0,
];
}
public function getCashOverview(?string $startDate, ?string $endDate): array
{
$cashAccount = CashAccount::first();
if (! $cashAccount) {
return [
'total_balance' => 0,
'total_transactions' => 0,
'total_deposit' => 0,
'total_withdrawal' => 0,
];
}
$transactions = $cashAccount->cashTransactions();
$this->applyDateFilter($transactions, $startDate, $endDate, 'cash_transactions.created_at');
return [
'total_balance' => $cashAccount->balance,
'total_transactions' => (clone $transactions)->count(),
'total_deposit' => (int) (clone $transactions)->where('type', CashTransactionType::DEPOSIT)->sum('amount'),
'total_withdrawal' => (int) (clone $transactions)->where('type', CashTransactionType::WITHDRAWAL)->sum('amount'),
];
}
public function getRawMaterialStock(): array
{
$query = PurchaseItem::query()
->join('purchases', 'purchase_items.purchase_id', '=', 'purchases.id')
->leftJoin('raw_material_prices', 'purchase_items.raw_material_price_id', '=', 'raw_material_prices.id')
->leftJoin('raw_materials', 'raw_material_prices.raw_material_id', '=', 'raw_materials.id');
$items = $query->select('purchase_items.*', 'raw_materials.unit')
->get();
$totalQty = $items->sum('quantity');
$totalValue = $items->sum('subtotal');
$byUnit = [
'yard' => $items->filter(fn ($i) => $i->unit === RawMaterialUnit::YARD)->sum('quantity'),
'meter' => $items->filter(fn ($i) => $i->unit === RawMaterialUnit::METER)->sum('quantity'),
'kilogram' => $items->filter(fn ($i) => $i->unit === RawMaterialUnit::KG)->sum('quantity'),
];
return [
'total_stock' => $totalQty,
'total_value' => $totalValue,
'by_unit' => $byUnit,
];
}
public function getProductStock(): array
{
$query = RestockItem::query()
->join('restocks', 'restock_items.restock_id', '=', 'restocks.id');
$items = $query->select('restock_items.*', 'restocks.stock_type')
->get();
$totalQty = $items->sum('quantity');
$totalValue = $items->sum('subtotal');
$byType = $items->groupBy(fn ($i) => $i->stock_type ?? 'unknown')
->map(fn ($group) => $group->sum('quantity'))
->toArray();
return [
'total_stock' => $totalQty,
'total_value' => $totalValue,
'by_type' => $byType,
];
}
public function getRevenueSummary(?string $startDate, ?string $endDate): array
{
$query = Order::where('orders.status', OrderStatus::COMPLETED);
$this->applyDateFilter($query, $startDate, $endDate, 'orders.created_at');
$stats = (clone $query)
->selectRaw('COUNT(*) as total_orders')
->selectRaw('COALESCE(SUM(total_amount), 0) as total_revenue')
->selectRaw('COALESCE(SUM(discount), 0) as total_discount')
->selectRaw('COALESCE(SUM(nego_price), 0) as total_deduction')
->selectRaw('COALESCE(SUM(cogs), 0) as total_cogs')
->first();
return [
'total_revenue' => (int) $stats->total_revenue,
'total_discount' => (int) $stats->total_discount,
'total_deduction' => (int) $stats->total_deduction,
'cogs' => (int) $stats->total_cogs,
'net' => (int) $stats->total_revenue - (int) $stats->total_cogs,
'total_orders' => (int) $stats->total_orders,
'avg_order' => $stats->total_orders > 0 ? (int) ($stats->total_revenue / $stats->total_orders) : 0,
];
}
public function getMonthlyRevenue(?string $startDate, ?string $endDate): array
{
$query = Order::where('orders.status', OrderStatus::COMPLETED);
$this->applyDateFilter($query, $startDate, $endDate, 'orders.created_at');
$monthly = (clone $query)
->selectRaw("DATE_FORMAT(created_at, '%b %Y') as month")
->selectRaw('COALESCE(SUM(total_amount), 0) as total')
->selectRaw('COALESCE(SUM(total_amount) - SUM(cogs), 0) as net')
->selectRaw('COALESCE(SUM(discount), 0) as discount')
->selectRaw('COALESCE(SUM(nego_price), 0) as deduction')
->selectRaw('COALESCE(SUM(cogs), 0) as cogs')
->groupBy(DB::raw("DATE_FORMAT(created_at, '%Y-%m')"), DB::raw("DATE_FORMAT(created_at, '%b %Y')"))
->orderBy(DB::raw("DATE_FORMAT(created_at, '%Y-%m')"))
->get()
->map(fn ($item) => [
'month' => $item->month,
'total' => (int) $item->total,
'net' => (int) $item->net,
'discount' => (int) $item->discount,
'deduction' => (int) $item->deduction,
'cogs' => (int) $item->cogs,
]);
return $monthly->values()->toArray();
}
public function getMonthlyRevenueByChannel(?string $startDate, ?string $endDate): array
{
$query = Order::where('orders.status', OrderStatus::COMPLETED);
$this->applyDateFilter($query, $startDate, $endDate, 'orders.created_at');
$monthly = (clone $query)
->selectRaw("DATE_FORMAT(created_at, '%b %Y') as month")
->selectRaw("COALESCE(SUM(CASE WHEN channel = 'store' THEN total_amount ELSE 0 END), 0) as store")
->selectRaw("COALESCE(SUM(CASE WHEN channel = 'shopee' THEN total_amount ELSE 0 END), 0) as shopee")
->selectRaw("COALESCE(SUM(CASE WHEN channel = 'tiktok' THEN total_amount ELSE 0 END), 0) as tiktok")
->groupBy(DB::raw("DATE_FORMAT(created_at, '%Y-%m')"), DB::raw("DATE_FORMAT(created_at, '%b %Y')"))
->orderBy(DB::raw("DATE_FORMAT(created_at, '%Y-%m')"))
->get()
->map(fn ($item) => [
'month' => $item->month,
'store' => (int) $item->store,
'shopee' => (int) $item->shopee,
'tiktok' => (int) $item->tiktok,
]);
return $monthly->values()->toArray();
}
public function getRevenueByPaymentType(?string $startDate, ?string $endDate): array
{
$query = Order::where('orders.status', OrderStatus::COMPLETED);
$this->applyDateFilter($query, $startDate, $endDate, 'orders.created_at');
$data = (clone $query)
->select('payment_type')
->selectRaw('COALESCE(SUM(total_amount), 0) as total')
->groupBy('payment_type')
->get()
->map(fn ($item) => [
'payment_type' => $item->payment_type,
'label' => $item->payment_type->label(),
'total' => (int) $item->total,
]);
return $data->toArray();
}
public function getExpenseSummary(?string $startDate, ?string $endDate): array
{
$expenseQuery = Expense::query();
$this->applyDateFilter($expenseQuery, $startDate, $endDate, 'expenses.created_at');
$advanceQuery = EmployeeAdvance::where('status', 'paid');
$this->applyDateFilter($advanceQuery, $startDate, $endDate, 'employee_advances.created_at');
$purchaseQuery = Purchase::query();
$this->applyDateFilter($purchaseQuery, $startDate, $endDate, 'purchases.created_at');
$expenseTotal = (clone $expenseQuery)->sum('amount');
$advanceTotal = (clone $advanceQuery)->sum('amount');
$purchaseTotal = (clone $purchaseQuery)->sum('total');
return [
'total' => (int) ($expenseTotal + $advanceTotal + $purchaseTotal),
'purchase_total' => (int) $purchaseTotal,
'expense_total' => (int) $expenseTotal,
'advance_total' => (int) $advanceTotal,
];
}
public function getMonthlyExpense(?string $startDate, ?string $endDate): array
{
$expenseMonthly = Expense::query();
$this->applyDateFilter($expenseMonthly, $startDate, $endDate, 'expenses.created_at');
$expenseByMonth = (clone $expenseMonthly)->toBase()
->selectRaw("DATE_FORMAT(created_at, '%b %Y') as month")
->selectRaw('COALESCE(SUM(amount), 0) as expense')
->groupBy(DB::raw("DATE_FORMAT(created_at, '%Y-%m')"), DB::raw("DATE_FORMAT(created_at, '%b %Y')"))
->get()
->keyBy('month');
$purchaseMonthly = Purchase::query();
$this->applyDateFilter($purchaseMonthly, $startDate, $endDate, 'purchases.created_at');
$purchaseByMonth = (clone $purchaseMonthly)->toBase()
->selectRaw("DATE_FORMAT(created_at, '%b %Y') as month")
->selectRaw('COALESCE(SUM(total), 0) as purchase')
->groupBy(DB::raw("DATE_FORMAT(created_at, '%Y-%m')"), DB::raw("DATE_FORMAT(created_at, '%b %Y')"))
->get()
->keyBy('month');
$advanceMonthly = EmployeeAdvance::where('status', 'paid');
$this->applyDateFilter($advanceMonthly, $startDate, $endDate, 'employee_advances.created_at');
$advanceByMonth = (clone $advanceMonthly)->toBase()
->selectRaw("DATE_FORMAT(created_at, '%b %Y') as month")
->selectRaw('COALESCE(SUM(amount), 0) as advance')
->groupBy(DB::raw("DATE_FORMAT(created_at, '%Y-%m')"), DB::raw("DATE_FORMAT(created_at, '%b %Y')"))
->get()
->keyBy('month');
$allMonths = [];
foreach ([$expenseByMonth, $purchaseByMonth, $advanceByMonth] as $data) {
foreach ($data as $month => $row) {
if (! array_key_exists($month, $allMonths)) {
$allMonths[$month] = ['month' => $month, 'total' => 0, 'purchase' => 0, 'expense' => 0, 'advance' => 0];
}
}
}
foreach ($allMonths as $month => &$row) {
$row['purchase'] = (int) ($purchaseByMonth[$month]->purchase ?? 0);
$row['expense'] = (int) ($expenseByMonth[$month]->expense ?? 0);
$row['advance'] = (int) ($advanceByMonth[$month]->advance ?? 0);
$row['total'] = $row['purchase'] + $row['expense'] + $row['advance'];
}
return array_values($allMonths);
}
public function getBusyHours(?string $startDate, ?string $endDate): array
{
$query = Order::where('orders.status', OrderStatus::COMPLETED);
$this->applyDateFilter($query, $startDate, $endDate, 'orders.created_at');
$hours = range(0, 23);
$hourCounts = (clone $query)
->selectRaw('HOUR(created_at) as hour')
->selectRaw('COUNT(*) as orders')
->groupBy(DB::raw('HOUR(created_at)'))
->pluck('orders', 'hour')
->toArray();
return array_map(function ($h) use ($hourCounts) {
return [
'hour' => sprintf('%02d:00', $h),
'orders' => (int) ($hourCounts[$h] ?? 0),
];
}, $hours);
}
public function getProfitMetrics(?string $startDate, ?string $endDate): array
{
$query = Order::where('orders.status', OrderStatus::COMPLETED);
$this->applyDateFilter($query, $startDate, $endDate, 'orders.created_at');
$stats = (clone $query)
->selectRaw('COUNT(*) as total_orders')
->selectRaw('COALESCE(SUM(total_amount), 0) as total_revenue')
->selectRaw('COALESCE(SUM(cogs), 0) as hpp')
->first();
$totalProductsSold = (clone $query)
->join('order_items', 'orders.id', '=', 'order_items.order_id')
->sum('order_items.quantity');
$grossProfit = $stats->total_revenue - $stats->hpp;
$netProfit = $grossProfit;
$profitMargin = $stats->total_revenue > 0 ? round(($netProfit / $stats->total_revenue) * 100, 1) : 0;
$aov = $stats->total_orders > 0 ? (int) ($stats->total_revenue / $stats->total_orders) : 0;
$itemsPerTransaction = $stats->total_orders > 0 ? round($totalProductsSold / $stats->total_orders, 1) : 0;
return [
'total_orders' => (int) $stats->total_orders,
'total_products_sold' => (int) $totalProductsSold,
'hpp' => (int) $stats->hpp,
'gross_profit' => (int) $grossProfit,
'net_profit' => (int) $netProfit,
'profit_margin' => $profitMargin,
'aov' => $aov,
'items_per_transaction' => $itemsPerTransaction,
];
}
public function getTopSuppliers(?string $startDate, ?string $endDate): array
{
$query = Purchase::query();
$this->applyDateFilter($query, $startDate, $endDate, 'purchases.created_at');
return (clone $query)->toBase()
->join('suppliers', 'purchases.supplier_id', '=', 'suppliers.id')
->select('suppliers.name')
->selectRaw('COALESCE(SUM(purchases.total), 0) as total_amount')
->selectRaw('COUNT(*) as purchase_count')
->groupBy('suppliers.name')
->orderByDesc('total_amount')
->limit(5)
->get()
->toArray();
}
public function getTopCustomers(?string $startDate, ?string $endDate): array
{
$query = Order::where('orders.status', OrderStatus::COMPLETED)->whereNotNull('orders.customer_id');
$this->applyDateFilter($query, $startDate, $endDate, 'orders.created_at');
return (clone $query)->toBase()
->join('customers', 'orders.customer_id', '=', 'customers.id')
->select('customers.name')
->selectRaw('COALESCE(SUM(orders.total_amount), 0) as total_amount')
->selectRaw('COUNT(*) as order_count')
->groupBy('customers.name')
->orderByDesc('total_amount')
->limit(5)
->get()
->toArray();
}
public function getTopProducts(?string $startDate, ?string $endDate): array
{
$query = Order::where('orders.status', OrderStatus::COMPLETED);
$this->applyDateFilter($query, $startDate, $endDate, 'orders.created_at');
return (clone $query)->toBase()
->join('order_items', 'orders.id', '=', 'order_items.order_id')
->join('product_variants', 'order_items.product_variant_id', '=', 'product_variants.id')
->join('products', 'product_variants.product_id', '=', 'products.id')
->select('products.name')
->selectRaw('SUM(order_items.quantity) as total_qty')
->selectRaw('COALESCE(SUM(order_items.subtotal), 0) as total_revenue')
->groupBy('products.name')
->orderByDesc('total_qty')
->limit(5)
->get()
->toArray();
}
public function getMarketingSales(?string $startDate, ?string $endDate): array
{
$query = Order::where('orders.status', OrderStatus::COMPLETED)
->whereNotNull('orders.marketing_id');
$this->applyDateFilter($query, $startDate, $endDate, 'orders.created_at');
$orders = (clone $query)
->join('users', 'orders.marketing_id', '=', 'users.id')
->leftJoin('user_profiles', 'users.id', '=', 'user_profiles.user_id')
->select('orders.marketing_id', 'user_profiles.full_name as marketing_name')
->selectRaw('COUNT(DISTINCT orders.id) as total_orders')
->selectRaw('COALESCE(SUM(orders.total_amount), 0) as total_revenue')
->selectRaw('COALESCE(SUM(orders.subtotal), 0) as total_subtotal')
->selectRaw('COALESCE(SUM(orders.discount), 0) as total_discount')
->groupBy('orders.marketing_id', 'user_profiles.full_name')
->get();
$productCounts = (clone $query)
->join('order_items', 'orders.id', '=', 'order_items.order_id')
->selectRaw('orders.marketing_id, SUM(order_items.quantity) as total_qty')
->groupBy('orders.marketing_id')
->pluck('total_qty', 'marketing_id');
return $orders->map(function ($item) use ($productCounts) {
$totalOrders = (int) $item->total_orders;
$totalRevenue = (int) $item->total_revenue;
return [
'marketing_name' => $item->marketing_name,
'total_orders' => $totalOrders,
'total_products_sold' => (int) ($productCounts[$item->marketing_id] ?? 0),
'total_revenue' => $totalRevenue,
'total_subtotal' => (int) $item->total_subtotal,
'total_discount' => (int) $item->total_discount,
'avg_order' => $totalOrders > 0 ? (int) ($totalRevenue / $totalOrders) : 0,
];
})->toArray();
}
public function getOrderStats(?string $startDate, ?string $endDate): array
{
$baseQuery = Order::query();
$this->applyDateFilter($baseQuery, $startDate, $endDate, 'orders.created_at');
$byChannel = collect(OrderChannel::values())->map(function ($channel) use ($baseQuery) {
$count = (clone $baseQuery)->where('channel', $channel)->count();
$label = OrderChannel::from($channel)->label();
return [
'channel' => $channel,
'label' => $label,
'count' => $count,
'total' => (int) (clone $baseQuery)->where('channel', $channel)->sum('total_amount'),
];
});
$byPaymentType = collect(PaymentType::values())->map(function ($paymentType) use ($baseQuery) {
$count = (clone $baseQuery)->where('payment_type', $paymentType)->count();
$label = PaymentType::from($paymentType)->label();
return [
'payment_type' => $paymentType,
'label' => $label,
'count' => $count,
'total' => (int) (clone $baseQuery)->where('payment_type', $paymentType)->sum('total_amount'),
];
});
$byMarketing = (clone $baseQuery)
->whereNotNull('marketing_id')
->select('marketing_id')
->selectRaw('COUNT(*) as count')
->selectRaw('COALESCE(SUM(total_amount), 0) as total')
->groupBy('marketing_id')
->with('marketing:id')
->get()
->map(fn($item) => [
'name' => $item->marketing?->userProfile->full_name ?? '-',
'count' => $item->count,
'total' => (int) $item->total,
]);
$byStatus = collect(OrderStatus::values())->map(function ($status) use ($baseQuery) {
$count = (clone $baseQuery)->where('status', $status)->count();
$label = OrderStatus::from($status)->label();
return [
'status' => $status,
'label' => $label,
'count' => $count,
];
});
return [
'by_channel' => $byChannel,
'by_payment_type' => $byPaymentType,
'by_marketing' => $byMarketing,
'by_status' => $byStatus,
];
}
private function applyDateFilter($query, ?string $startDate, ?string $endDate, string $dateColumn = 'created_at'): void
{
if ($startDate) {
$query->whereDate($dateColumn, '>=', $startDate);
}
if ($endDate) {
$query->whereDate($dateColumn, '<=', $endDate);
}
}
}

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